From ecde58d84dd220f92ac4506df04a65593429ca0a Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 18:12:07 -0700 Subject: [PATCH 01/42] docs(viewer): design spec for web viewer migration Replace DearPyGui desktop GUI with browser-based web viewer using FastAPI + Vite + React + shadcn/ui + Tailwind. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-10-web-viewer-migration-design.md | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-web-viewer-migration-design.md diff --git a/docs/superpowers/specs/2026-04-10-web-viewer-migration-design.md b/docs/superpowers/specs/2026-04-10-web-viewer-migration-design.md new file mode 100644 index 0000000..38aa4c2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-web-viewer-migration-design.md @@ -0,0 +1,416 @@ +# Web Viewer Migration Design Spec + +**Date:** 2026-04-10 +**Status:** Approved +**Scope:** Replace DearPyGui desktop viewer with browser-based web viewer + +## Summary + +Replace the current DearPyGui desktop GUI viewer with a web browser-based viewer. The Python SDK starts a FastAPI server and opens a browser tab. The web frontend is built with Vite + React + shadcn/ui + Tailwind, porting the design system from the opengraph-studio/recorder project. The public API (`viewer.launch(session)`) remains unchanged. + +## Decisions + +| Item | Decision | +|------|----------| +| Feature scope | Recorder design system + SyncField SDK features only (no config/task/calibration) | +| Styling | shadcn/ui + Tailwind CSS | +| Code location | Monorepo — `src/syncfield/viewer/frontend/` inside syncfield-python | +| Camera streaming | MJPEG stream | +| Sensor streaming | SSE (Server-Sent Events) | +| Package manager | yarn | +| API design | Hybrid — REST + WebSocket + MJPEG + SSE | +| Dev/deploy split | Vite dev server (dev) → static assets in FastAPI (prod) | + +## Architecture + +### Data Flow + +``` +SessionOrchestrator + │ +SessionPoller (10Hz, unchanged) + │ +SessionSnapshot (frozen dataclass, unchanged) + │ +FastAPI Server (server.py) + ├── WebSocket /ws/control ──→ JSON snapshot broadcast (10Hz) + │ ←─ control commands + ├── MJPEG /stream/video/{id} ──→ latest_frame JPEG continuous stream + ├── SSE /stream/sensor/{id} ──→ plot_points data push + └── REST /api/* ──→ status, discover, stream management + │ +React App (browser) + ├── useSession() hook ──→ WebSocket state + commands + ├── ──→ native MJPEG rendering + ├── useSensorStream() hook ──→ SSE → chart rendering + └── useDiscovery() hook ──→ REST scan trigger +``` + +### Directory Structure + +``` +src/syncfield/viewer/ +├── __init__.py # launch(), launch_passive() public API (signature unchanged) +├── app.py # ViewerApp: FastAPI + uvicorn + webbrowser.open() +├── server.py # FastAPI app, routing, WebSocket/MJPEG/SSE handlers +├── poller.py # SessionPoller (unchanged) +├── state.py # SessionSnapshot, StreamSnapshot (unchanged) +├── frontend/ # Vite + React project +│ ├── package.json +│ ├── yarn.lock +│ ├── vite.config.ts +│ ├── tsconfig.json +│ ├── tailwind.config.ts +│ ├── components.json # shadcn/ui config +│ ├── index.html +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── hooks/ +│ │ │ ├── use-session.ts +│ │ │ ├── use-sensor-stream.ts +│ │ │ └── use-discovery.ts +│ │ ├── components/ +│ │ │ ├── ui/ # shadcn/ui components (Button, Dialog, Toast, Table) +│ │ │ ├── header.tsx +│ │ │ ├── control-panel.tsx +│ │ │ ├── stream-card.tsx +│ │ │ ├── video-preview.tsx +│ │ │ ├── sensor-chart.tsx +│ │ │ ├── health-table.tsx +│ │ │ ├── session-clock.tsx +│ │ │ ├── discovery-modal.tsx +│ │ │ └── countdown-overlay.tsx +│ │ ├── lib/ +│ │ │ ├── types.ts +│ │ │ ├── format.ts +│ │ │ └── utils.ts # shadcn cn() utility +│ │ └── styles/ +│ │ └── globals.css +│ └── __tests__/ +│ ├── format.test.ts +│ ├── use-session.test.ts +│ └── use-sensor-stream.test.ts +├── static/ # vite build output (.gitignore) +│ ├── index.html +│ └── assets/ +``` + +## Python Backend (server.py) + +### Public API + +```python +def launch(session, *, host="127.0.0.1", port=8420, title="SyncField"): + """Start web server + open browser, blocking. Ctrl+C to exit.""" + +@contextmanager +def launch_passive(session, *, host="127.0.0.1", port=8420, title="SyncField"): + """Background web server. Caller controls session lifecycle.""" +``` + +Signature matches existing DearPyGui API. `host` and `port` are new optional parameters (non-breaking). + +### Endpoints + +``` +WebSocket: + /ws/control + server→client: SessionSnapshot JSON (10Hz broadcast) + client→server: {"action": "connect"|"disconnect"|"record"|"stop"|"cancel"} + +MJPEG: + /stream/video/{stream_id} + StreamingResponse(multipart/x-mixed-replace) + latest_frame → cv2.imencode(".jpg") → yield + +SSE: + /stream/sensor/{stream_id} + EventSource, text/event-stream + channel values push (~10Hz) + +REST: + GET /api/status # current SessionSnapshot (one-shot) + POST /api/discover # trigger device scan (async, result via WebSocket) + POST /api/streams/{id} # add discovered device to session + DELETE /api/streams/{id} # remove stream + +Static: + /* # built React app (SPA fallback → index.html) +``` + +### WebSocket Protocol + +**Server → Client (10Hz):** + +```json +{ + "type": "snapshot", + "state": "recording", + "host_id": "mac_studio", + "elapsed_s": 12.345, + "chirp": {"enabled": true, "start_ns": 123456, "stop_ns": null}, + "streams": { + "mac_webcam": { + "id": "mac_webcam", + "kind": "video", + "frame_count": 370, + "effective_hz": 29.8, + "last_sample_ms_ago": 33, + "provides_audio_track": false, + "produces_file": true, + "health_count": 0 + } + }, + "health_log": [ + {"stream_id": "iphone", "kind": "drop", "at_s": 5.2, "detail": "frame skip"} + ], + "output_dir": "...ep_20260410_143022_a1b2c3" +} +``` + +`latest_frame` and `plot_points` are excluded from WebSocket — they use dedicated MJPEG/SSE channels. + +**Server → Client (countdown events):** + +```json +{"type": "countdown", "count": 3} +{"type": "countdown", "count": 2} +{"type": "countdown", "count": 1} +``` + +**Client → Server:** + +```json +{"action": "connect"} +{"action": "disconnect"} +{"action": "record", "countdown_s": 3} +{"action": "stop"} +{"action": "cancel"} +``` + +5 actions only, 1:1 mapping to current DearPyGui viewer buttons. + +### snapshot_to_dict() + +Converts frozen dataclass to JSON-serializable dict. Excludes `latest_frame` (numpy array) and `plot_points` (deque) to keep WebSocket payload lightweight. + +## React Frontend + +### Component Tree + +``` +App +├── Header +│ ├── Logo "SyncField" +│ ├── Host ID +│ ├── State dot + label (● RECORDING) +│ ├── Elapsed timer (MM:SS.mmm) +│ └── "Discover Devices" button → DiscoveryModal +├── ControlPanel +│ ├── Connect / Disconnect buttons +│ ├── Record / Stop buttons +│ └── Cancel button +├── SessionClock +│ ├── sync_point display +│ ├── chirp status +│ └── tone config +├── StreamsSection (horizontal scroll) +│ └── StreamCard (per stream) +│ ├── Header: stream ID + status dot + remove button +│ ├── Tags: kind · audio · file +│ ├── Body (by kind): +│ │ ├── VideoPreview — +│ │ ├── SensorChart — SSE + realtime SVG line chart +│ │ └── "no preview" placeholder +│ └── Footer: frame count · Hz · last sample ago +├── HealthTable (shadcn Table) +│ └── Columns: Time | Stream | Kind | Detail +├── Footer +│ ├── output path (right 60 chars, truncated) +│ └── wall clock (ISO datetime) +├── CountdownOverlay (conditional) +│ └── 3 → 2 → 1 large number + animation +└── DiscoveryModal (shadcn Dialog) + ├── Scan status indicator + ├── Device list (checkbox + name + adapter info) + └── Rescan / Close / Add buttons +``` + +### State Management + +```typescript +// hooks/use-session.ts +function useSession(): { + snapshot: SessionSnapshot | null + sendCommand: (action: string, data?: object) => void + connectionStatus: "connecting" | "connected" | "disconnected" +} +``` + +Single WebSocket connection. Reconnect on disconnect. All control via `sendCommand()`. + +### Sensor Streaming + +```typescript +// hooks/use-sensor-stream.ts +function useSensorStream(streamId: string): { + channels: Record + labels: number[] + isConnected: boolean +} +``` + +EventSource (SSE). Per-channel rolling buffer, max 300 points. SensorChart renders custom SVG directly — no chart library dependency. + +### Audio Feedback + +Audio playback architecture: + +- **Recording PC (Python/sounddevice):** Plays countdown ticks (C6, 1047Hz, 100ms) and chirps (400-2500Hz FM sweep, 500ms) via PortAudio. Captured by microphones for cross-correlation sync. Completely unchanged by this migration. +- **Browser (Web Audio API):** Plays countdown tick sounds (C6, 1047Hz, 100ms) for user feedback only. Does NOT play chirps — chirps serve physical capture purposes and are only meaningful on the recording PC. + +Countdown tick in browser is triggered by WebSocket `{"type": "countdown", "count": N}` messages. + +### Design Tokens + +Recorder's CSS custom properties mapped to Tailwind config: + +```typescript +// tailwind.config.ts +{ + colors: { + background: "hsl(60 7% 95%)", // Recorder --bg + foreground: "hsl(0 0% 13%)", // Recorder --text + primary: "hsl(153 35% 38%)", // Recorder --primary (teal) + muted: "hsl(0 0% 42%)", // Recorder --text-secondary + destructive: "hsl(0 65% 48%)", // Recorder --status-rec + }, + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + } +} +``` + +shadcn/ui components (Button, Dialog, Table, Toast) use Recorder's color palette. + +### Testing + +| Target | Tool | Scope | +|--------|------|-------| +| `format.ts` | Vitest | Pure function unit tests | +| `use-session.ts` | Vitest + mock WebSocket | Connection/reconnect, message parsing, command sending | +| `use-sensor-stream.ts` | Vitest + mock EventSource | SSE connection, rolling buffer, disconnect | +| `types.ts` | Vitest | Type guard/parser functions | + +UI component render tests (React Testing Library) excluded from initial scope. + +## Build & Deploy + +### Development + +``` +Terminal 1: cd src/syncfield/viewer/frontend && yarn dev + → Vite dev server :5173 (HMR) + +Terminal 2: python examples/iphone_mac_webcam/record.py + → FastAPI :8420 + → Browser opens :5173, API proxied to :8420 +``` + +```typescript +// vite.config.ts +export default defineConfig({ + server: { + proxy: { + "/ws": { target: "ws://localhost:8420", ws: true }, + "/api": "http://localhost:8420", + "/stream": "http://localhost:8420", + }, + }, + build: { + outDir: "../static", + emptyOutDir: true, + }, +}) +``` + +### Production (pip install) + +``` +pip install syncfield[viewer] +→ FastAPI serves viewer/static/ built assets +→ webbrowser.open("http://localhost:8420") +``` + +### pyproject.toml + +```toml +[project.optional-dependencies] +viewer = [ + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "opencv-python>=4.8.0", +] +# dearpygui removed + +[tool.setuptools.package-data] +"syncfield.viewer" = ["static/**/*"] +``` + +### Build Command + +```makefile +build-viewer: + cd src/syncfield/viewer/frontend && yarn install --frozen-lockfile && yarn build +``` + +`static/` directory is in `.gitignore`. CI runs `yarn build` before Python package build. + +## Migration: What Changes + +### Removed + +| File | Reason | +|------|--------| +| `viewer/theme.py` | Replaced by Tailwind design tokens | +| `viewer/fonts.py` | Browser font loading | +| `viewer/widgets/` (entire directory) | React components | + +### Rewritten + +| File | Change | +|------|--------| +| `viewer/app.py` | DearPyGui context/render loop → FastAPI + uvicorn + webbrowser.open() | +| `viewer/__init__.py` | Internal implementation swap only, public signature preserved | +| `viewer/demo.py` | Remove DearPyGui dependency, use web viewer | + +### Unchanged + +| File | Reason | +|------|--------| +| `viewer/poller.py` | Web server consumes SessionPoller identically | +| `viewer/state.py` | SessionSnapshot/StreamSnapshot shared by both backends | + +### Added + +| File | Purpose | +|------|---------| +| `viewer/server.py` | FastAPI app with all endpoints | +| `viewer/frontend/` | Vite + React project | +| `viewer/static/` | Build output (.gitignore) | + +### Dependencies + +``` +Removed: dearpygui +Added: fastapi>=0.104.0, uvicorn[standard]>=0.24.0 +Kept: opencv-python>=4.8.0 (already used by adapters) +``` + +### Backward Compatibility + +- `syncfield.viewer.launch(session)` — same signature, same blocking behavior +- `syncfield.viewer.launch_passive(session)` — same context manager pattern +- `host`, `port` parameters are new optionals (non-breaking) +- All `examples/` user code unchanged From 3651fc320502f91f61b055597d105c99d7ab700d Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 19:37:55 -0700 Subject: [PATCH 02/42] feat(viewer): replace DearPyGui desktop viewer with browser-based web viewer Replace the GPU-accelerated DearPyGui desktop GUI with a FastAPI + React web viewer. The Python SDK starts a uvicorn server and opens a browser tab. The public API (viewer.launch / viewer.launch_passive) is preserved with the same signatures plus new optional host/port parameters. Backend: FastAPI server with WebSocket (10Hz snapshot broadcast + control), MJPEG video streaming, SSE sensor data push, and REST discovery endpoints. Frontend: Vite + React + Tailwind CSS, porting the Recorder design system (Inter font, teal primary, warm gray palette). Components include video preview, real-time SVG sensor charts, health table, countdown overlay, and device discovery modal. Dependencies changed: dearpygui removed, fastapi + uvicorn + opencv-python added. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 + pyproject.toml | 18 +- src/syncfield/viewer/__init__.py | 21 +- src/syncfield/viewer/app.py | 239 +-- src/syncfield/viewer/demo.py | 278 +--- src/syncfield/viewer/fonts.py | 200 --- .../viewer/{widgets => }/formatting.py | 0 src/syncfield/viewer/frontend/index.html | 18 + src/syncfield/viewer/frontend/package.json | 29 + src/syncfield/viewer/frontend/src/App.tsx | 151 ++ .../frontend/src/components/control-panel.tsx | 103 ++ .../src/components/countdown-overlay.tsx | 23 + .../src/components/discovery-modal.tsx | 182 ++ .../viewer/frontend/src/components/footer.tsx | 19 + .../viewer/frontend/src/components/header.tsx | 79 + .../frontend/src/components/health-table.tsx | 64 + .../frontend/src/components/sensor-chart.tsx | 161 ++ .../frontend/src/components/session-clock.tsx | 48 + .../frontend/src/components/stream-card.tsx | 99 ++ .../frontend/src/components/video-preview.tsx | 21 + .../frontend/src/hooks/use-discovery.ts | 72 + .../frontend/src/hooks/use-sensor-stream.ts | 103 ++ .../viewer/frontend/src/hooks/use-session.ts | 116 ++ .../viewer/frontend/src/lib/format.ts | 75 + .../viewer/frontend/src/lib/types.ts | 103 ++ .../viewer/frontend/src/lib/utils.ts | 7 + src/syncfield/viewer/frontend/src/main.tsx | 10 + .../viewer/frontend/src/styles/globals.css | 104 ++ src/syncfield/viewer/frontend/tsconfig.json | 25 + src/syncfield/viewer/frontend/vite.config.ts | 24 + src/syncfield/viewer/frontend/yarn.lock | 1459 +++++++++++++++++ src/syncfield/viewer/server.py | 414 +++++ src/syncfield/viewer/theme.py | 358 ---- src/syncfield/viewer/widgets/__init__.py | 7 - .../viewer/widgets/discovery_modal.py | 595 ------- src/syncfield/viewer/widgets/layout.py | 863 ---------- src/syncfield/viewer/widgets/stream_card.py | 441 ----- tests/unit/viewer/test_formatting.py | 8 +- tests/unit/viewer/test_poller.py | 4 - tests/unit/viewer/test_state.py | 4 - 40 files changed, 3648 insertions(+), 2902 deletions(-) delete mode 100644 src/syncfield/viewer/fonts.py rename src/syncfield/viewer/{widgets => }/formatting.py (100%) create mode 100644 src/syncfield/viewer/frontend/index.html create mode 100644 src/syncfield/viewer/frontend/package.json create mode 100644 src/syncfield/viewer/frontend/src/App.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/control-panel.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/countdown-overlay.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/discovery-modal.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/footer.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/header.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/health-table.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/sensor-chart.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/session-clock.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/stream-card.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/video-preview.tsx create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-discovery.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-sensor-stream.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-session.ts create mode 100644 src/syncfield/viewer/frontend/src/lib/format.ts create mode 100644 src/syncfield/viewer/frontend/src/lib/types.ts create mode 100644 src/syncfield/viewer/frontend/src/lib/utils.ts create mode 100644 src/syncfield/viewer/frontend/src/main.tsx create mode 100644 src/syncfield/viewer/frontend/src/styles/globals.css create mode 100644 src/syncfield/viewer/frontend/tsconfig.json create mode 100644 src/syncfield/viewer/frontend/vite.config.ts create mode 100644 src/syncfield/viewer/frontend/yarn.lock create mode 100644 src/syncfield/viewer/server.py delete mode 100644 src/syncfield/viewer/theme.py delete mode 100644 src/syncfield/viewer/widgets/__init__.py delete mode 100644 src/syncfield/viewer/widgets/discovery_modal.py delete mode 100644 src/syncfield/viewer/widgets/layout.py delete mode 100644 src/syncfield/viewer/widgets/stream_card.py diff --git a/.gitignore b/.gitignore index b7594e7..45e5fde 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,11 @@ htmlcov/ # Worktrees .worktrees/ +# Viewer frontend +src/syncfield/viewer/static/ +src/syncfield/viewer/frontend/node_modules/ +src/syncfield/viewer/frontend/dist/ + # OS .DS_Store Thumbs.db diff --git a/pyproject.toml b/pyproject.toml index 23f2d47..a304555 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ ble = ["bleak>=0.21"] # extra's opencv-python for the MP4 writer. Install `syncfield[oak,uvc]` # (or `syncfield[all]`) for the full OAK capture path. oak = ["depthai>=3.0.0"] -# The desktop viewer uses dearpygui (GPU-accelerated, light 1.8 MiB wheel). -# It also reuses numpy from the audio extra when rendering video frames — -# if you install ``syncfield[viewer]`` on its own, numpy is pulled in too. +# The web viewer uses FastAPI + uvicorn and opens a browser tab. +# opencv-python is needed for MJPEG encoding of video frames. viewer = [ - "dearpygui>=2.0", - "numpy>=1.21", + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "opencv-python>=4.8.0", ] # mDNS-based multi-host session rendezvous (syncfield.multihost). Required # only when you want a leader/follower session discovered automatically @@ -52,10 +52,11 @@ multihost = [ all = [ "sounddevice>=0.4.6", "numpy>=1.21", - "opencv-python>=4.5", + "opencv-python>=4.8.0", "bleak>=0.21", "depthai>=3.0.0", - "dearpygui>=2.0", + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", "zeroconf>=0.130", ] @@ -68,6 +69,9 @@ Issues = "https://github.com/OpenGraphLabs/syncfield-python/issues" [tool.hatch.build.targets.wheel] packages = ["src/syncfield"] +[tool.hatch.build.targets.wheel.force-include] +"src/syncfield/viewer/static" = "syncfield/viewer/static" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] diff --git a/src/syncfield/viewer/__init__.py b/src/syncfield/viewer/__init__.py index fa2cb6f..6443956 100644 --- a/src/syncfield/viewer/__init__.py +++ b/src/syncfield/viewer/__init__.py @@ -1,4 +1,4 @@ -"""SyncField desktop viewer — a MuJoCo-style bundled GUI. +"""SyncField web viewer — browser-based session monitor and control. Usage:: @@ -8,7 +8,7 @@ session = sf.SessionOrchestrator(host_id="rig_01", output_dir="./data") session.add(...) - # Blocking mode — opens the window, returns when it closes + # Blocking mode — opens browser, returns on Ctrl+C syncfield.viewer.launch(session) # Passive mode — context manager, caller keeps control of the session @@ -18,26 +18,23 @@ time.sleep(0.1) session.stop() -The viewer renders in the same process as the SDK. No HTTP, no IPC — the -poller holds a reference to the :class:`SessionOrchestrator` and reads its -state directly. Video frames are published by each adapter via a -thread-safe ``latest_frame`` property and uploaded to the GPU as raw -textures. +The viewer starts a FastAPI server and opens a browser tab. The React +frontend connects via WebSocket for real-time state updates, MJPEG for +video preview, and SSE for sensor chart data. Requires the ``viewer`` extra:: pip install 'syncfield[viewer]' -which installs ``dearpygui`` and ``numpy``. The SDK core stays stdlib-only -for users who never open the GUI. +which installs ``fastapi``, ``uvicorn``, and ``opencv-python``. """ from __future__ import annotations try: - import dearpygui.dearpygui as _dpg # noqa: F401 - import numpy as _np # noqa: F401 -except ImportError as exc: # pragma: no cover - exercised at import time on CI + import fastapi as _fastapi # noqa: F401 + import uvicorn as _uvicorn # noqa: F401 +except ImportError as exc: # pragma: no cover raise ImportError( "syncfield.viewer requires the 'viewer' extra. " "Install with `pip install 'syncfield[viewer]'`." diff --git a/src/syncfield/viewer/app.py b/src/syncfield/viewer/app.py index 1f05ef2..acaf8ce 100644 --- a/src/syncfield/viewer/app.py +++ b/src/syncfield/viewer/app.py @@ -1,40 +1,30 @@ -"""Desktop viewer application — MuJoCo-style launcher for SyncField sessions. +"""Web viewer application — FastAPI + uvicorn + browser launcher. -This module owns the top-level DearPyGui context, the render loop, and the -small lifecycle machinery that makes :func:`launch` / :func:`launch_passive` -feel natural. The actual widget construction lives in -:mod:`syncfield.viewer.widgets` to keep this file focused on "how the app -runs" rather than "what each panel looks like". +Replaces the DearPyGui desktop viewer with a browser-based UI. The +public API (:func:`launch` / :func:`launch_passive`) has the same +signature so user scripts are unchanged. Thread model: -- **Main thread** runs the DearPyGui render loop. -- **Poller thread** (daemon, started by :class:`SessionPoller`) populates - :class:`SessionSnapshot`\\ s at 10 Hz. -- **Control worker thread** (daemon, started on button click) runs - ``session.start()`` / ``session.stop()`` so the UI never blocks on SDK - lifecycle calls. - -DearPyGui itself is single-threaded for all UI mutation — the render loop -is the only thing that calls ``dpg.set_value`` / ``dpg.configure_item``. -Snapshots flow main thread via a single lock-guarded read. +- **Main thread** (blocking mode): runs ``uvicorn.run()``. +- **Background thread** (passive mode): runs uvicorn via a separate + ``asyncio`` event loop so the caller keeps control. +- **Poller thread** (daemon): same as before — 10 Hz snapshot polling. """ from __future__ import annotations import logging import threading -import time +import webbrowser from contextlib import contextmanager from typing import Iterator, Optional -import dearpygui.dearpygui as dpg +import uvicorn from syncfield.orchestrator import SessionOrchestrator -from syncfield.viewer import theme -from syncfield.viewer.fonts import FontRegistry, load_fonts from syncfield.viewer.poller import SessionPoller -from syncfield.viewer.widgets.layout import ViewerLayout +from syncfield.viewer.server import ViewerServer logger = logging.getLogger(__name__) @@ -47,23 +37,19 @@ class ViewerHandle: """Minimal handle exposed by :func:`launch_passive`. - Mirrors the shape of ``mujoco.viewer.launch_passive`` so callers coming - from the MuJoCo / rerun worlds have zero friction. + Mirrors the shape of the previous DearPyGui handle so callers + coming from the desktop viewer have zero friction. """ def __init__(self, app: "ViewerApp") -> None: self._app = app def is_running(self) -> bool: - """Return True while the viewer window is still open.""" + """Return True while the web server is still running.""" return self._app.is_running() - def sync(self) -> None: - """Render one frame. Use in passive mode when you own the loop.""" - self._app.render_one_frame() - def close(self) -> None: - """Close the viewer window and stop the poller.""" + """Stop the web server and the poller.""" self._app.close() @@ -75,23 +61,29 @@ def close(self) -> None: def launch( session: SessionOrchestrator, *, + host: str = "127.0.0.1", + port: int = 8420, title: str = "SyncField", ) -> None: - """Open the viewer and block until the window is closed. + """Start the web viewer and block until Ctrl+C. - In blocking mode the viewer *owns* the session lifecycle — the user - clicks Record / Stop / Cancel in the UI, and the worker threads call - the corresponding ``SessionOrchestrator`` methods. When the window - closes, any in-progress recording is stopped cleanly. + Opens a browser tab pointing at the viewer. In blocking mode the + viewer *owns* the session lifecycle — the user clicks Record / Stop + in the browser, and the server dispatches the corresponding + ``SessionOrchestrator`` methods. Args: session: The orchestrator to observe and control. - title: Window title. Default ``"SyncField"``. + host: Bind address. Default ``"127.0.0.1"`` (localhost only). + port: Bind port. Default ``8420``. + title: Browser tab title. Default ``"SyncField"``. """ - app = ViewerApp(session, title=title) + app = ViewerApp(session, host=host, port=port, title=title) try: app.setup() app.run() + except KeyboardInterrupt: + pass finally: app.close() @@ -100,14 +92,16 @@ def launch( def launch_passive( session: SessionOrchestrator, *, + host: str = "127.0.0.1", + port: int = 8420, title: str = "SyncField", ) -> Iterator[ViewerHandle]: """Open the viewer in **passive** mode and return a handle. Use this when the caller owns the session lifecycle — e.g. a script - that wants the GUI as an observer while it runs its own start/stop - logic. The viewer's render loop runs on a background thread so the - caller keeps control of the main thread. + that wants the web UI as an observer while it runs its own start/stop + logic. The web server runs on a background thread so the caller keeps + control of the main thread. Example:: @@ -116,15 +110,8 @@ def launch_passive( while viewer.is_running(): time.sleep(0.1) session.stop() - - Note: - Passive mode runs the DearPyGui render loop on a background - thread. DPG is designed for a single UI thread and this works - reliably on macOS and Linux in practice, but the blocking - :func:`launch` path is the "MuJoCo-canonical" one if you don't - need to share the main thread. """ - app = ViewerApp(session, title=title) + app = ViewerApp(session, host=host, port=port, title=title) app.setup() bg_thread = threading.Thread( @@ -145,164 +132,88 @@ def launch_passive( class ViewerApp: - """Owns the DearPyGui context and render loop for one viewer window. - - Separated from the module-level helpers so it can be instantiated - directly in tests (or, in the future, embedded in a larger GUI). - """ + """Owns the FastAPI server and uvicorn lifecycle for one viewer session.""" def __init__( self, session: SessionOrchestrator, *, + host: str = "127.0.0.1", + port: int = 8420, title: str = "SyncField", - viewport_pos: Optional[tuple] = None, ) -> None: self._session = session + self._host = host + self._port = port self._title = title - self._viewport_pos = viewport_pos self._poller = SessionPoller(session) - self._layout: Optional[ViewerLayout] = None - self._fonts: FontRegistry = FontRegistry() + self._server: Optional[ViewerServer] = None + self._uvicorn_server: Optional[uvicorn.Server] = None self._running = False self._setup_done = False - self._close_requested = False # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def setup(self) -> None: - """Create the DPG context, build the layout, and start the poller.""" + """Create the FastAPI app and start the poller.""" if self._setup_done: return - - dpg.create_context() - dpg.create_viewport( - title=self._title, - width=theme.VIEWPORT_WIDTH, - height=theme.VIEWPORT_HEIGHT, - small_icon="", - large_icon="", - resizable=True, - ) - - # Load system fonts into the DPG registry before any widget is - # created — otherwise the first frame renders with the default - # ASCII-only bitmap font and every non-ASCII glyph flashes as '?'. - self._fonts = load_fonts() - if self._fonts.ui is not None: - dpg.bind_font(self._fonts.ui) - - # Bind the global theme before any widgets are created so the - # first frame doesn't flash with the default dark theme. - global_theme_tag = theme.build_theme() - dpg.bind_theme(global_theme_tag) - - # Viewport clear color matches the app background so the window - # chrome edge doesn't leak through. - dpg.set_viewport_clear_color( - [c / 255 for c in theme.BG_APP] + self._server = ViewerServer( + self._session, self._poller, title=self._title, ) - - self._layout = ViewerLayout(self._session, fonts=self._fonts) - self._layout.build() - - dpg.setup_dearpygui() - dpg.show_viewport() - - # Pin the viewport to a specific on-screen position when the caller - # supplies one (used by the screenshot harness to place the window - # at a known coordinate). - if self._viewport_pos is not None: - try: - dpg.set_viewport_pos(self._viewport_pos) - except Exception: - pass - - # Make the primary window fill the viewport so resizing feels - # native. The layout's main window is tagged "main_window". - dpg.set_primary_window("main_window", True) - self._poller.start() self._setup_done = True def run(self) -> None: - """Run the render loop on the calling thread. + """Run uvicorn on the calling thread (blocking). - Exits when the viewport is closed or :meth:`close` is called. + Opens a browser tab after a short delay to let the server bind. """ if not self._setup_done: self.setup() + + assert self._server is not None + + # Open browser in a background thread after a short delay + url = f"http://{self._host}:{self._port}" + threading.Thread( + target=self._open_browser, args=(url,), daemon=True, + ).start() + self._running = True + config = uvicorn.Config( + app=self._server.app, + host=self._host, + port=self._port, + log_level="warning", + ) + self._uvicorn_server = uvicorn.Server(config) try: - while dpg.is_dearpygui_running() and not self._close_requested: - self.render_one_frame() + self._uvicorn_server.run() finally: self._running = False - def render_one_frame(self) -> None: - """Render a single DPG frame after syncing from the latest snapshot.""" - snapshot = self._poller.get_snapshot() - if snapshot is not None and self._layout is not None: - self._layout.update(snapshot) - dpg.render_dearpygui_frame() - def close(self) -> None: - """Stop the poller, tear down the session, and destroy the DPG context.""" - if self._close_requested: - return - self._close_requested = True - - # Return the session to IDLE before tearing down DPG so any - # connected devices are released cleanly. Runs on the caller's - # thread (typically the main thread during app shutdown). - if self._layout is not None: - try: - self._layout.teardown_session() - except Exception: - logger.exception("Viewer session teardown failed") + """Stop uvicorn, the poller, and tear down the session.""" + # Signal uvicorn to shut down + if self._uvicorn_server is not None: + self._uvicorn_server.should_exit = True self._poller.stop() - try: - if dpg.is_dearpygui_running(): - dpg.stop_dearpygui() - except Exception: - pass - try: - dpg.destroy_context() - except Exception: - pass + self._running = False self._setup_done = False def is_running(self) -> bool: - return self._running and not self._close_requested - - # ------------------------------------------------------------------ - # Session control (called from widget callbacks) - # ------------------------------------------------------------------ - - def request_start(self) -> None: - """Kick off ``session.start()`` on a worker thread so the UI stays live.""" - threading.Thread( - target=self._safe_call, - args=(self._session.start,), - name="syncfield-viewer-start", - daemon=True, - ).start() - - def request_stop(self) -> None: - """Kick off ``session.stop()`` on a worker thread.""" - threading.Thread( - target=self._safe_call, - args=(self._session.stop,), - name="syncfield-viewer-stop", - daemon=True, - ).start() + return self._running @staticmethod - def _safe_call(fn) -> None: + def _open_browser(url: str) -> None: + """Open the viewer URL in the default browser after a brief settle.""" + import time + time.sleep(0.8) try: - fn() + webbrowser.open(url) except Exception: - logger.exception("Viewer session control call failed") + logger.debug("Could not open browser — visit %s manually", url) diff --git a/src/syncfield/viewer/demo.py b/src/syncfield/viewer/demo.py index e744f93..ce924db 100644 --- a/src/syncfield/viewer/demo.py +++ b/src/syncfield/viewer/demo.py @@ -1,4 +1,4 @@ -"""Headless-safe demo for the SyncField desktop viewer. +"""Headless-safe demo for the SyncField web viewer. Run with:: @@ -8,17 +8,12 @@ streams (two synthetic video sources, one IMU with a BNO-style signal, one JSONL-ish logger, and a custom sensor) so the viewer has plausible data to render without any hardware connected. - -This module doubles as the screenshot harness — it accepts -``--snapshot path.png`` to quit the viewer after a warmup period and -save the window bitmap to disk. """ from __future__ import annotations import argparse import math -import os import sys import threading import time @@ -40,7 +35,7 @@ # --------------------------------------------------------------------------- -# Fake video stream — generates a moving gradient so screenshots look "live" +# Fake video stream — generates a moving gradient so the viewer looks "live" # --------------------------------------------------------------------------- @@ -49,11 +44,7 @@ class SyntheticVideoStream(StreamBase): Exposes ``latest_frame`` the same way :class:`UVCWebcamStream` and :class:`OakCameraStream` do, so the viewer's video card renders it - correctly without any mocking on the viewer side. Implements the - full 4-phase lifecycle: the gradient loop runs during ``CONNECTED`` - so preview is live *before* Record is pressed, and ``_recording`` - gates the ``SampleEvent`` emission so only frames captured while - the session is in ``RECORDING`` count toward the finalization. + correctly. Implements the full 4-phase lifecycle. """ def __init__( @@ -81,9 +72,6 @@ def __init__( self._hue_shift = hue_shift self._stop = threading.Event() self._thread: threading.Thread | None = None - # `_recording` toggles whether the capture loop counts frames - # and emits sample events. While False the loop still produces - # `latest_frame` so the viewer has something to show. self._recording = False self._frame_count = 0 self._first_at: int | None = None @@ -94,7 +82,6 @@ def __init__( # -- 4-phase lifecycle -------------------------------------------------- def connect(self) -> None: - """Spawn the capture loop so preview frames are available.""" if self._thread is not None and self._thread.is_alive(): return self._stop.clear() @@ -108,11 +95,9 @@ def connect(self) -> None: self._thread.start() def start_recording(self, session_clock) -> None: # type: ignore[override] - """Flip the recording flag — atomic and fast.""" self._recording = True def stop_recording(self) -> FinalizationReport: - """Stop emitting samples but leave the capture loop running.""" self._recording = False return FinalizationReport( stream_id=self.id, @@ -126,7 +111,6 @@ def stop_recording(self) -> FinalizationReport: ) def disconnect(self) -> None: - """Tear down the capture loop.""" self._stop.set() if self._thread is not None: self._thread.join(timeout=2.0) @@ -138,8 +122,6 @@ def prepare(self) -> None: pass def start(self, session_clock) -> None: # type: ignore[override] - # Compatibility path for any caller still using the legacy - # one-shot lifecycle: connect + start_recording in one call. self.connect() self.start_recording(session_clock) @@ -156,14 +138,6 @@ def latest_frame(self) -> Any: return self._latest_frame def _generate_loop(self) -> None: - """Procedurally generate a colorful moving gradient. - - The frame is a smooth sinusoidal pattern that drifts across the - image — visually distinctive enough that screenshots show real - motion but cheap enough to compute at 30 fps. Runs continuously - while connected; sample events are only emitted during - ``_recording`` so the frame count reflects recorded frames only. - """ xs = np.linspace(0, 2 * math.pi, self._width, dtype=np.float32) ys = np.linspace(0, 2 * math.pi, self._height, dtype=np.float32) xx, yy = np.meshgrid(xs, ys) @@ -171,7 +145,6 @@ def _generate_loop(self) -> None: frame_number = 0 while not self._stop.is_set(): t = time.monotonic() - t0 - # Smooth blue/indigo gradient with a subtle wave for motion r = 0.55 + 0.30 * np.sin(xx + t * 1.2 + self._hue_shift) g = 0.55 + 0.30 * np.sin(yy + t * 0.9 + self._hue_shift + 2.0) b = 0.75 + 0.20 * np.sin(xx + yy + t * 1.5 + self._hue_shift + 4.0) @@ -183,9 +156,6 @@ def _generate_loop(self) -> None: with self._frame_lock: self._latest_frame = bgr - # Preview-only frames never touch the counters or the sample - # stream — they just update `latest_frame`. Recording frames - # do both. if self._recording: if self._first_at is None: self._first_at = capture_ns @@ -208,14 +178,7 @@ def _generate_loop(self) -> None: class SyntheticImuStream(StreamBase): - """Fake 9-DOF IMU that produces smooth sinusoidal channels at 100 Hz. - - Like :class:`SyntheticVideoStream`, the sample loop runs during - ``CONNECTED`` so the viewer can plot live values before Record is - pressed. Only frames captured while ``_recording`` is ``True`` are - counted into the finalization report and emitted as - :class:`SampleEvent`. - """ + """Fake 9-DOF IMU that produces smooth sinusoidal channels at 100 Hz.""" def __init__(self, id: str) -> None: super().__init__( @@ -235,8 +198,6 @@ def __init__(self, id: str) -> None: self._first_at: int | None = None self._last_at: int | None = None - # -- 4-phase lifecycle ------------------------------------------------- - def connect(self) -> None: if self._thread is not None and self._thread.is_alive(): return @@ -272,8 +233,6 @@ def disconnect(self) -> None: self._thread.join(timeout=2.0) self._thread = None - # -- Legacy one-shot compatibility ------------------------------------- - def prepare(self) -> None: pass @@ -286,8 +245,6 @@ def stop(self) -> FinalizationReport: self.disconnect() return report - # -- Capture loop ------------------------------------------------------ - def _loop(self) -> None: period = 0.01 # 100 Hz t0 = time.monotonic() @@ -317,8 +274,6 @@ def _loop(self) -> None: ) ) - # Sprinkle in a health event occasionally so the health - # table actually has content in screenshots. if self._frame_count == 150: self._emit_health( HealthEvent( @@ -347,25 +302,15 @@ def _loop(self) -> None: def build_demo_session(output_dir: Path) -> sf.SessionOrchestrator: - """Construct a realistic multi-stream session for the viewer demo. - - Chirp is **enabled** with the egonaut production defaults so the viewer - shows the real "sync tone active" UI state. A :class:`SilentChirpPlayer` - is injected so the demo never actually emits audio — great for running - the demo on a laptop or for capturing docs screenshots without beeping. - """ + """Construct a realistic multi-stream session for the viewer demo.""" from syncfield.tone import SilentChirpPlayer session = sf.SessionOrchestrator( host_id="demo_rig", output_dir=output_dir, - sync_tone=sf.SyncToneConfig.default(), # chirp enabled by default - chirp_player=SilentChirpPlayer(), # ...but don't actually beep + sync_tone=sf.SyncToneConfig.default(), + chirp_player=SilentChirpPlayer(), ) - # Mark at least one stream as audio-capable so the orchestrator decides - # chirp is eligible and fills in chirp_start_ns / chirp_stop_ns in the - # sync point — without that, the viewer's "chirp" line would read - # "pending" forever. session.add( SyntheticVideoStream( "cam_ego", width=640, height=360, hue_shift=0.0, @@ -401,54 +346,34 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Automatically click Record on startup.", ) + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Bind address for the web server.", + ) + parser.add_argument( + "--port", + type=int, + default=8420, + help="Bind port for the web server.", + ) parser.add_argument( "--duration", type=float, default=0.0, - help=( - "If > 0, run for this many seconds then auto-close. " - "Useful for screenshotting." - ), + help="If > 0, run for this many seconds then auto-close.", ) parser.add_argument( "--empty-session", action="store_true", - help=( - "Skip the synthetic streams and open with an empty session — " - "useful for capturing the 'click Discover to begin' state." - ), - ) - parser.add_argument( - "--open-discovery", - action="store_true", - help=( - "After startup, automatically click the 'Discover devices' " - "header button so screenshots capture the discovery modal." - ), - ) - parser.add_argument( - "--screenshot", - type=Path, - default=None, - help=( - "Path to save a PNG screenshot of the viewer. Implies " - "--auto-record. The viewer runs for --duration seconds, waits " - "until the streams have warmed up, then captures the viewport " - "via dpg.output_frame_buffer() and exits." - ), + help="Skip synthetic streams and open with an empty session.", ) args = parser.parse_args(argv) - if args.screenshot is not None and args.duration <= 0: - # A screenshot run needs a bounded duration; default to 3s. - args.duration = 3.0 - args.output_dir.mkdir(parents=True, exist_ok=True) - if args.empty_session: - # Bare session with no pre-populated streams — used to capture - # the "click Discover to begin" screenshot. - import syncfield.adapters # noqa: F401 (register discoverers) + if args.empty_session: from syncfield.tone import SilentChirpPlayer session = sf.SessionOrchestrator( @@ -461,9 +386,8 @@ def main(argv: Optional[List[str]] = None) -> int: session = build_demo_session(args.output_dir) if args.auto_record: - # Start the session immediately so screenshots look populated. def _auto_record() -> None: - time.sleep(0.5) + time.sleep(2.0) try: session.start() except Exception as exc: @@ -471,157 +395,29 @@ def _auto_record() -> None: threading.Thread(target=_auto_record, daemon=True).start() - if args.duration > 0 or args.screenshot is not None: - import dearpygui.dearpygui as dpg - import subprocess - - def _capture_window_screenshot() -> None: - """Capture the viewer window to a PNG via ``screencapture``. - - The demo is launched from a terminal, so by the time the - timer fires the frontmost app may still be the shell or the - editor that kicked off the run — not the DPG window. Before - capturing we ask the current Python process (which owns the - viewer window) to activate itself via AppleScript. That - guarantees the window is on top of whatever was previously - frontmost, so the region capture at ``(60, 60)`` lines up - with the pinned viewport. - """ - args.screenshot.parent.mkdir(parents=True, exist_ok=True) - - # Step 1: force the SyncField window to the front. We look - # up "python" (which owns this process) and send an 'activate' - # event. Falls through silently if System Events is - # unreachable — the capture will still run, it just may - # grab whatever is on top of the viewer. - activate_script = """ - tell application "System Events" - set pyProcs to every application process whose unix id is %d - if (count of pyProcs) > 0 then - set frontmost of (item 1 of pyProcs) to true - end if - end tell - """ % os.getpid() - try: - subprocess.run( - ["osascript", "-e", activate_script], - capture_output=True, - text=True, - timeout=5, - ) - except Exception as exc: - print(f"viewer activate failed: {exc}", file=sys.stderr) - - # Small settle so the window manager finishes raising the - # viewer above whatever was in front of it before. - time.sleep(0.3) - - # Step 2: log the frontmost window title for debugging. - probe = """ - tell application "System Events" - set frontApp to first application process whose frontmost is true - set frontWin to window 1 of frontApp - return value of attribute "AXTitle" of frontWin - end tell - """ - try: - result = subprocess.run( - ["osascript", "-e", probe], - capture_output=True, - text=True, - check=True, - timeout=5, - ) - print( - f"frontmost window title: {result.stdout.strip()!r}", - file=sys.stderr, - ) - except Exception as exc: - print(f"title probe failed: {exc}", file=sys.stderr) - - # Step 2: capture the full screen then we can inspect it. If the - # full-screen dump looks right we'll refine to a window capture. - full_path = args.screenshot.with_suffix(".full.png") - try: - subprocess.run( - ["screencapture", "-x", "-t", "png", str(full_path)], - check=True, - timeout=5, - ) - print(f"full-screen dump → {full_path}", file=sys.stderr) - except Exception as exc: - print(f"full-screen capture failed: {exc}", file=sys.stderr) + if args.duration > 0: + from syncfield.viewer.app import ViewerApp - # Step 3: also try the interactive window capture by sending a - # key-like instruction to screencapture's -W mode. That's not - # scriptable; fall back to capturing a point-sized region. - try: - from syncfield.viewer import theme - - # The viewer is pinned at (60, 60) by the screenshot - # harness. macOS window chrome adds a ~28 px title bar - # above the DPG content — we pad the capture region by - # the same amount at the bottom to guarantee the full - # content area is visible even after the viewport grows. - _TITLE_BAR_PX = 28 - x, y = 60, 60 - w = theme.VIEWPORT_WIDTH - h = theme.VIEWPORT_HEIGHT + _TITLE_BAR_PX - subprocess.run( - [ - "screencapture", - "-x", - "-t", - "png", - "-R", - f"{x},{y},{w},{h}", - str(args.screenshot), - ], - check=True, - timeout=5, - ) - print(f"region dump → {args.screenshot}", file=sys.stderr) - except Exception as exc: - print(f"region capture failed: {exc}", file=sys.stderr) + app = ViewerApp( + session, host=args.host, port=args.port, title="SyncField Demo" + ) def _timer() -> None: - # Extra settling time — DPG viewport move is async and the - # first few frames can show a flash of the default dark theme. time.sleep(args.duration) - if args.screenshot is not None: - _capture_window_screenshot() - try: - dpg.stop_dearpygui() - except Exception: - pass + app.close() threading.Thread(target=_timer, daemon=True).start() - # Pin the viewport so the screenshot helper knows where to look. - pin_pos = (60, 60) if args.screenshot is not None else None - - from syncfield.viewer.app import ViewerApp - - app = ViewerApp(session, title="SyncField", viewport_pos=pin_pos) - - # Optional: programmatically open the discovery modal a moment after - # startup so screenshots can capture it without the user clicking. - if args.open_discovery: - def _auto_open_modal() -> None: - time.sleep(0.8) - try: - if app._layout and app._layout._discovery_modal is not None: # noqa: SLF001 - app._layout._discovery_modal.open() # noqa: SLF001 - except Exception as exc: - print(f"auto-open-discovery failed: {exc}", file=sys.stderr) + try: + app.setup() + app.run() + finally: + app.close() + else: + from syncfield.viewer import launch - threading.Thread(target=_auto_open_modal, daemon=True).start() + launch(session, host=args.host, port=args.port, title="SyncField Demo") - try: - app.setup() - app.run() - finally: - app.close() return 0 diff --git a/src/syncfield/viewer/fonts.py b/src/syncfield/viewer/fonts.py deleted file mode 100644 index ad3bef6..0000000 --- a/src/syncfield/viewer/fonts.py +++ /dev/null @@ -1,200 +0,0 @@ -"""System font loader for the SyncField desktop viewer. - -DearPyGui's built-in font (ProggyClean) is a 13 px ASCII bitmap — it -looks dated and, more importantly, it renders every non-ASCII glyph as -``?``. The viewer uses a handful of geometric symbols (``●``, ``■``, -``→``, ``—``) plus the occasional warning glyph, so a font with the -extended Latin ranges is a correctness requirement, not just polish. - -This module walks a small list of platform-appropriate system font paths -and loads the first one it finds into the DearPyGui font registry at the -typographic scale the layout expects. The returned :class:`FontRegistry` -hands out tags per display role — body text, muted text, display sizes, -monospace for timestamps and device ids. - -If no system font is found the module falls back gracefully: the -returned registry's fields are all ``None`` and the caller keeps the -DearPyGui default font. The UI will still work; exotic glyphs will just -render as placeholders. -""" - -from __future__ import annotations - -import logging -import os -import platform -from dataclasses import dataclass -from typing import List, Optional - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Registry returned to the viewer app -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class FontRegistry: - """Bundle of DearPyGui font tags keyed by display role. - - All fields default to ``None`` so callers can treat "font missing" - the same way as "don't override this element's font" — no special - casing in widget code. - """ - - ui_sm: Optional[int] = None # 12 px — small labels, muted captions - ui: Optional[int] = None # 14 px — body text (bound globally) - ui_md: Optional[int] = None # 16 px — section titles, card headers - ui_lg: Optional[int] = None # 22 px — app title, prominent timers - mono: Optional[int] = None # 14 px — device ids, timestamps - - -# --------------------------------------------------------------------------- -# Platform font catalog -# --------------------------------------------------------------------------- - -# Ordered by preference. First existing file wins. -_SANS_CANDIDATES = { - "Darwin": [ - # San Francisco Pro — Apple's system UI font. Single-file .ttf - # so DearPyGui can load it without .ttc collection handling. - "/System/Library/Fonts/SFNS.ttf", - "/System/Library/Fonts/SFCompact.ttf", - "/System/Library/Fonts/Helvetica.ttc", - "/Library/Fonts/Arial.ttf", - ], - "Linux": [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf", - ], - "Windows": [ - r"C:\Windows\Fonts\segoeui.ttf", - r"C:\Windows\Fonts\arial.ttf", - ], -} - -_MONO_CANDIDATES = { - "Darwin": [ - "/System/Library/Fonts/SFNSMono.ttf", - "/System/Library/Fonts/Menlo.ttc", - ], - "Linux": [ - "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - "/usr/share/fonts/TTF/DejaVuSansMono.ttf", - "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", - ], - "Windows": [ - r"C:\Windows\Fonts\consola.ttf", - r"C:\Windows\Fonts\lucon.ttf", - ], -} - - -def _find_first_existing(candidates: List[str]) -> Optional[str]: - for path in candidates: - if os.path.isfile(path): - return path - return None - - -# --------------------------------------------------------------------------- -# Typography scale -# --------------------------------------------------------------------------- - -# Keyed by the FontRegistry field name so ``load_fonts`` can splat it in. -_SIZE_SCALE = { - "ui_sm": 13, # captions, muted labels - "ui": 15, # body text (bound globally) - "ui_md": 17, # card titles, controls - "ui_lg": 24, # app title, prominent display -} - - -# --------------------------------------------------------------------------- -# Unicode ranges needed by viewer glyphs -# --------------------------------------------------------------------------- - -# DearPyGui's default range hint covers Basic Latin + Latin-1 Supplement. -# Everything else the viewer uses lives in these blocks — we rasterize -# only what we need so the font atlas stays small. -_EXTRA_RANGES = ( - (0x2000, 0x206F), # General Punctuation — em dash, ellipsis, bullet - (0x2190, 0x21FF), # Arrows — → - (0x25A0, 0x25FF), # Geometric Shapes — ● ■ ◐ □ - (0x2600, 0x26FF), # Miscellaneous Symbols — ⚠ -) - - -# --------------------------------------------------------------------------- -# Public entry point -# --------------------------------------------------------------------------- - - -def load_fonts() -> FontRegistry: - """Load viewer fonts into the DearPyGui font registry. - - Must be called **after** ``dpg.create_context()`` and **before** the - first widget is built. Returns a :class:`FontRegistry` of font tags - the caller can pass to ``dpg.bind_font`` (for the global default) - and ``dpg.bind_item_font`` (for per-widget overrides like the app - title or monospace device ids). - - When no system font is available the returned registry is empty and - the viewer falls back to the DearPyGui built-in font. - """ - import dearpygui.dearpygui as dpg - - system = platform.system() - sans_path = _find_first_existing(_SANS_CANDIDATES.get(system, [])) - mono_path = _find_first_existing(_MONO_CANDIDATES.get(system, [])) - - if sans_path is None: - logger.warning( - "viewer: no system UI font found on %s; exotic glyphs will " - "render as '?' placeholders. Install a TrueType font such as " - "DejaVu Sans or Noto Sans to fix.", - system, - ) - return FontRegistry() - - logger.debug( - "viewer: loading UI font %s (mono=%s)", sans_path, mono_path or "none" - ) - - tags: dict = {} - with dpg.font_registry(): - for role, size in _SIZE_SCALE.items(): - tag = _load_sized_font(dpg, sans_path, size) - if tag is not None: - tags[role] = tag - - if mono_path is not None: - mono_tag = _load_sized_font(dpg, mono_path, 15) - if mono_tag is not None: - tags["mono"] = mono_tag - - return FontRegistry(**tags) - - -def _load_sized_font(dpg_module, font_path: str, size: int) -> Optional[int]: - """Load one ``font_path`` at ``size`` px and register the extra ranges. - - Returns the font tag on success or ``None`` if DearPyGui rejects the - file (corrupt file, unreadable collection index, etc.). Never raises - — a bad font file should never crash the viewer. - """ - try: - with dpg_module.font(font_path, size) as font_tag: - dpg_module.add_font_range_hint(dpg_module.mvFontRangeHint_Default) - for start, end in _EXTRA_RANGES: - dpg_module.add_font_range(start, end) - return font_tag - except Exception as exc: - logger.debug("viewer: failed to load %s @ %dpx: %s", font_path, size, exc) - return None diff --git a/src/syncfield/viewer/widgets/formatting.py b/src/syncfield/viewer/formatting.py similarity index 100% rename from src/syncfield/viewer/widgets/formatting.py rename to src/syncfield/viewer/formatting.py diff --git a/src/syncfield/viewer/frontend/index.html b/src/syncfield/viewer/frontend/index.html new file mode 100644 index 0000000..48e6ca7 --- /dev/null +++ b/src/syncfield/viewer/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + SyncField + + + + + +
+ + + diff --git a/src/syncfield/viewer/frontend/package.json b/src/syncfield/viewer/frontend/package.json new file mode 100644 index 0000000..2a6c462 --- /dev/null +++ b/src/syncfield/viewer/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "syncfield-viewer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.3.0" + }, + "devDependencies": { + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vitest": "^3.2.1", + "tailwindcss": "^4.1.8", + "@tailwindcss/vite": "^4.1.8" + } +} diff --git a/src/syncfield/viewer/frontend/src/App.tsx b/src/syncfield/viewer/frontend/src/App.tsx new file mode 100644 index 0000000..7381e19 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/App.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSession } from "@/hooks/use-session"; +import { useDiscovery } from "@/hooks/use-discovery"; +import { Header } from "@/components/header"; +import { ControlPanel } from "@/components/control-panel"; +import { SessionClock } from "@/components/session-clock"; +import { StreamCard } from "@/components/stream-card"; +import { HealthTable } from "@/components/health-table"; +import { CountdownOverlay } from "@/components/countdown-overlay"; +import { DiscoveryModal } from "@/components/discovery-modal"; +import { Footer } from "@/components/footer"; + +// --------------------------------------------------------------------------- +// Audio feedback — countdown tick (C6, 1047 Hz, 100 ms) +// --------------------------------------------------------------------------- + +function playCountdownTick() { + try { + const ctx = new AudioContext(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.value = 1047; // C6 + gain.gain.value = 0.3; + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1); + osc.stop(ctx.currentTime + 0.1); + // Clean up after playback + setTimeout(() => ctx.close(), 200); + } catch { + // Audio not available — silent fallback + } +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +export function App() { + const { snapshot, countdown, sendCommand, connectionStatus } = useSession(); + const discovery = useDiscovery(); + const [discoveryOpen, setDiscoveryOpen] = useState(false); + + // Play tick sound on countdown events + const lastCountdown = useRef(null); + useEffect(() => { + if (countdown !== null && countdown !== lastCountdown.current) { + playCountdownTick(); + } + lastCountdown.current = countdown; + }, [countdown]); + + // Update page title with session state + useEffect(() => { + const state = snapshot?.state ?? "idle"; + document.title = state === "recording" ? "● SyncField" : "SyncField"; + }, [snapshot?.state]); + + const handleRemoveStream = useCallback( + (streamId: string) => { + discovery.removeStream(streamId); + }, + [discovery], + ); + + const state = snapshot?.state ?? "idle"; + const streams = snapshot?.streams ?? {}; + const streamList = Object.values(streams); + const canRemove = state === "idle" || state === "connected" || state === "stopped"; + + return ( +
+ {/* Header */} +
setDiscoveryOpen(true)} + /> + + {/* Control + Session clock */} + + + + {/* Streams section */} +
+ {streamList.length > 0 ? ( +
+ {/* Stream cards — horizontal scroll */} +
+
+ {streamList.map((stream) => ( + + ))} +
+
+ + {/* Health table */} +
+

+ Health Events +

+
+ +
+
+
+ ) : ( +
+

No streams registered

+ +
+ )} +
+ + {/* Footer */} +
+ + {/* Connection status indicator */} + {connectionStatus === "disconnected" && ( +
+ Reconnecting… +
+ )} + + {/* Countdown overlay */} + {countdown !== null && } + + {/* Discovery modal */} + setDiscoveryOpen(false)} + devices={discovery.devices} + isScanning={discovery.isScanning} + error={discovery.error} + onScan={discovery.scan} + onAdd={discovery.addDevice} + /> +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/control-panel.tsx b/src/syncfield/viewer/frontend/src/components/control-panel.tsx new file mode 100644 index 0000000..8501ecc --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/control-panel.tsx @@ -0,0 +1,103 @@ +import type { ControlAction, SessionState } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface ControlPanelProps { + state: SessionState; + onCommand: (action: ControlAction, data?: Record) => void; +} + +/** + * Session control buttons — Connect, Disconnect, Record, Stop, Cancel. + * + * Button enable/disable logic mirrors the DearPyGui viewer exactly: + * each action is only available in the states where it makes sense. + */ +export function ControlPanel({ state, onCommand }: ControlPanelProps) { + const canConnect = state === "idle" || state === "stopped"; + const canDisconnect = state === "connected" || state === "stopped"; + const canRecord = state === "connected"; + const canStop = state === "recording"; + const canCancel = state === "recording" || state === "stopping"; + + return ( +
+ {/* Connection group */} + + + +
+ + {/* Recording group */} + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Internal button component (thin wrapper, not a shared UI primitive) +// --------------------------------------------------------------------------- + +type ButtonVariant = "default" | "primary" | "destructive" | "ghost"; + +function Button({ + children, + onClick, + disabled, + variant = "default", +}: { + children: React.ReactNode; + onClick: () => void; + disabled: boolean; + variant?: ButtonVariant; +}) { + return ( + + ); +} diff --git a/src/syncfield/viewer/frontend/src/components/countdown-overlay.tsx b/src/syncfield/viewer/frontend/src/components/countdown-overlay.tsx new file mode 100644 index 0000000..a8f0b14 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/countdown-overlay.tsx @@ -0,0 +1,23 @@ +interface CountdownOverlayProps { + count: number; +} + +/** + * Full-screen countdown overlay — shows 3, 2, 1 before recording starts. + * + * Triggered by WebSocket `countdown` events. The pop animation gives + * clear visual feedback for each tick. Browser audio feedback (C6 tick) + * is played by the App component, not here. + */ +export function CountdownOverlay({ count }: CountdownOverlayProps) { + return ( +
+ + {count} + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx b/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx new file mode 100644 index 0000000..a06e676 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/discovery-modal.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import type { DiscoveredDevice } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +interface DiscoveryModalProps { + isOpen: boolean; + onClose: () => void; + devices: DiscoveredDevice[]; + isScanning: boolean; + error: string | null; + onScan: () => void; + onAdd: (deviceId: string) => Promise; +} + +/** + * Device discovery modal — scan for devices, select, and add to session. + */ +export function DiscoveryModal({ + isOpen, + onClose, + devices, + isScanning, + error, + onScan, + onAdd, +}: DiscoveryModalProps) { + const [selected, setSelected] = useState>(new Set()); + const [adding, setAdding] = useState(false); + + // Auto-scan on open + useEffect(() => { + if (isOpen) { + onScan(); + setSelected(new Set()); + } + }, [isOpen, onScan]); + + if (!isOpen) return null; + + function toggleDevice(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleAdd() { + setAdding(true); + for (const id of selected) { + await onAdd(id); + } + setAdding(false); + setSelected(new Set()); + onClose(); + } + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+

Discover Devices

+ +
+ + {/* Scan status */} +
+ {isScanning ? ( + <> + + Scanning… + + ) : ( + <> + + {devices.length} device{devices.length !== 1 ? "s" : ""} found + + )} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Device list */} +
+ {devices.length === 0 && !isScanning ? ( +

+ No devices found +

+ ) : ( +
    + {devices.map((device) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Actions */} +
+ + + +
+
+
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/footer.tsx b/src/syncfield/viewer/frontend/src/components/footer.tsx new file mode 100644 index 0000000..288f558 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/footer.tsx @@ -0,0 +1,19 @@ +import { formatPathTail } from "@/lib/format"; + +interface FooterProps { + outputDir: string; +} + +/** + * Footer bar — shows output path and wall clock. + */ +export function Footer({ outputDir }: FooterProps) { + const now = new Date().toISOString().replace("T", " ").slice(0, 19); + + return ( +
+ {formatPathTail(outputDir)} + {now} +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/header.tsx b/src/syncfield/viewer/frontend/src/components/header.tsx new file mode 100644 index 0000000..8159b77 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/header.tsx @@ -0,0 +1,79 @@ +import type { SessionSnapshot } from "@/lib/types"; +import { formatElapsed, stateLabel } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface HeaderProps { + snapshot: SessionSnapshot | null; + onDiscoverClick: () => void; +} + +const STATE_COLORS: Record = { + idle: "bg-muted", + connecting: "bg-warning", + connected: "bg-success", + starting: "bg-warning", + recording: "bg-recording animate-pulse-recording", + stopping: "bg-warning", + stopped: "bg-muted", + disconnecting: "bg-warning", +}; + +export function Header({ snapshot, onDiscoverClick }: HeaderProps) { + const state = snapshot?.state ?? "idle"; + const hostId = snapshot?.host_id ?? "—"; + const elapsed = snapshot?.elapsed_s ?? 0; + + return ( +
+ {/* Logo */} +

SyncField

+ +
+ + {/* Host ID */} + {hostId} + +
+ + {/* State indicator */} +
+ + + {stateLabel(state)} + +
+ + {/* Elapsed timer */} + {state === "recording" && ( + <> +
+ + {formatElapsed(elapsed)} + + + )} + +
+ + {/* Discover devices button */} + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/health-table.tsx b/src/syncfield/viewer/frontend/src/components/health-table.tsx new file mode 100644 index 0000000..18751ea --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/health-table.tsx @@ -0,0 +1,64 @@ +import type { HealthEntry } from "@/lib/types"; + +interface HealthTableProps { + entries: HealthEntry[]; +} + +const KIND_COLORS: Record = { + error: "text-destructive", + warning: "text-warning", + drop: "text-destructive", + reconnect: "text-success", + heartbeat: "text-muted", +}; + +/** + * Health event timeline — newest-first table of stream health events. + */ +export function HealthTable({ entries }: HealthTableProps) { + if (entries.length === 0) { + return ( +
+ No health events +
+ ); + } + + // Display newest first + const sorted = [...entries].reverse(); + + return ( +
+ + + + + + + + + + + {sorted.map((entry, i) => ( + + + + + + + ))} + +
TimeStreamKindDetail
+ {entry.at_s.toFixed(3)}s + + {entry.stream_id} + + + {entry.kind} + + + {entry.detail ?? "—"} +
+
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx b/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx new file mode 100644 index 0000000..cfb3492 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx @@ -0,0 +1,161 @@ +import { useSensorStream } from "@/hooks/use-sensor-stream"; + +interface SensorChartProps { + streamId: string; +} + +/** + * Real-time sensor chart rendered as inline SVG. + * + * Connects to the SSE endpoint via `useSensorStream`, maintains a + * rolling buffer of 300 points per channel, and draws each channel + * as a polyline on a shared coordinate system. No chart library + * dependency — just raw SVG paths. + */ + +const CHART_W = 228; +const CHART_H = 120; +const PADDING = { top: 8, right: 8, bottom: 16, left: 32 }; + +const SERIES_COLORS = [ + "#4F46E5", // indigo + "#059669", // emerald + "#D97706", // amber + "#DC2626", // red + "#7C3AED", // violet + "#0891B2", // cyan +]; + +export function SensorChart({ streamId }: SensorChartProps) { + const { channels, labels, isConnected } = useSensorStream(streamId); + const channelNames = Object.keys(channels); + + if (channelNames.length === 0) { + return ( +
+ {isConnected ? "Waiting for data…" : "Connecting…"} +
+ ); + } + + // Compute axis bounds from all channels + const allValues = channelNames.flatMap((name) => channels[name] ?? []); + const yMin = Math.min(...allValues); + const yMax = Math.max(...allValues); + const yRange = yMax - yMin || 1; // Avoid division by zero + + const plotW = CHART_W - PADDING.left - PADDING.right; + const plotH = CHART_H - PADDING.top - PADDING.bottom; + + const xScale = (i: number) => + PADDING.left + (i / Math.max(labels.length - 1, 1)) * plotW; + const yScale = (v: number) => + PADDING.top + plotH - ((v - yMin) / yRange) * plotH; + + return ( +
+ + {/* Y-axis labels */} + + {yMax.toFixed(1)} + + + {yMin.toFixed(1)} + + + {/* Grid lines */} + + + + + {/* Data lines */} + {channelNames.map((name, ci) => { + const values = channels[name]; + if (!values || values.length < 2) return null; + + const points = values + .map((v, i) => { + if (Number.isNaN(v)) return null; + return `${xScale(i)},${yScale(v)}`; + }) + .filter(Boolean) + .join(" L "); + + if (!points) return null; + + return ( + + ); + })} + + {/* Channel legend */} + {channelNames.slice(0, 6).map((name, ci) => ( + + + + {name} + + + ))} + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/session-clock.tsx b/src/syncfield/viewer/frontend/src/components/session-clock.tsx new file mode 100644 index 0000000..baf1565 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/session-clock.tsx @@ -0,0 +1,48 @@ +import type { SessionSnapshot } from "@/lib/types"; +import { formatChirpPair } from "@/lib/format"; + +interface SessionClockProps { + snapshot: SessionSnapshot | null; +} + +/** + * Session clock panel — shows sync point, chirp status, and tone config. + */ +export function SessionClock({ snapshot }: SessionClockProps) { + if (!snapshot) return null; + + const { chirp } = snapshot; + const chirpLabel = chirp.enabled + ? formatChirpPair(chirp.start_ns, chirp.stop_ns) + : "disabled"; + + return ( +
+ {/* Chirp status */} +
+ Chirp + + {chirp.enabled ? ( + {chirpLabel} + ) : ( + disabled + )} + +
+ + {/* Stream count */} +
+ Streams + + {Object.keys(snapshot.streams).length} + +
+ + {/* Session state detail */} +
+ State + {snapshot.state} +
+
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/stream-card.tsx b/src/syncfield/viewer/frontend/src/components/stream-card.tsx new file mode 100644 index 0000000..d00692e --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/stream-card.tsx @@ -0,0 +1,99 @@ +import type { StreamSnapshot } from "@/lib/types"; +import { formatCount, formatHz, formatMsAgo } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import { VideoPreview } from "./video-preview"; +import { SensorChart } from "./sensor-chart"; + +interface StreamCardProps { + stream: StreamSnapshot; + canRemove: boolean; + onRemove: (streamId: string) => void; +} + +/** + * Per-stream card with variant body by kind. + * + * - **video** — MJPEG preview via `` + * - **sensor** — Real-time SVG line chart via SSE + * - **audio / custom** — Minimal stats placeholder + */ +export function StreamCard({ stream, canRemove, onRemove }: StreamCardProps) { + return ( +
+ {/* Card header */} +
+ 0 ? "bg-success" : "bg-muted", + )} + /> + + {stream.id} + +
+ {canRemove && ( + + )} +
+ + {/* Tags */} +
+ {stream.kind} + {stream.provides_audio_track && audio} + {stream.produces_file && file} +
+ + {/* Body — varies by stream kind */} +
+ {stream.kind === "video" ? ( + + ) : stream.kind === "sensor" ? ( + + ) : ( +
+ No preview +
+ )} +
+ + {/* Footer stats */} +
+ {formatCount(stream.frame_count)} + + {formatHz(stream.effective_hz)} + + {formatMsAgo(stream.last_sample_ms_ago)} + {stream.health_count > 0 && ( + <> + + + {stream.health_count} event{stream.health_count > 1 ? "s" : ""} + + + )} +
+
+ ); +} + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/syncfield/viewer/frontend/src/components/video-preview.tsx b/src/syncfield/viewer/frontend/src/components/video-preview.tsx new file mode 100644 index 0000000..9805a1c --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/video-preview.tsx @@ -0,0 +1,21 @@ +interface VideoPreviewProps { + streamId: string; +} + +/** + * MJPEG video preview — renders as a plain `` tag pointed at + * the server's MJPEG endpoint. The browser handles frame decoding + * natively with zero JavaScript overhead. + */ +export function VideoPreview({ streamId }: VideoPreviewProps) { + return ( +
+ {`${streamId} +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-discovery.ts b/src/syncfield/viewer/frontend/src/hooks/use-discovery.ts new file mode 100644 index 0000000..da5bb7d --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-discovery.ts @@ -0,0 +1,72 @@ +import { useCallback, useState } from "react"; +import type { DiscoveredDevice } from "@/lib/types"; + +interface UseDiscoveryReturn { + /** List of discovered devices from the most recent scan. */ + devices: DiscoveredDevice[]; + /** Whether a scan is currently in progress. */ + isScanning: boolean; + /** Error message from the last failed scan, if any. */ + error: string | null; + /** Trigger a new device scan. */ + scan: () => Promise; + /** Add a discovered device to the session by ID. */ + addDevice: (deviceId: string) => Promise; + /** Remove a stream from the session by ID. */ + removeStream: (streamId: string) => Promise; +} + +/** + * REST hook for device discovery and stream management. + * + * Provides `scan()` to trigger `/api/discover`, `addDevice()` to + * POST to `/api/streams/{id}`, and `removeStream()` to DELETE. + */ +export function useDiscovery(): UseDiscoveryReturn { + const [devices, setDevices] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [error, setError] = useState(null); + + const scan = useCallback(async () => { + setIsScanning(true); + setError(null); + try { + const res = await fetch("/api/discover", { method: "POST" }); + const data = await res.json(); + if (data.error) { + setError(data.error); + } + setDevices(data.devices ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Scan failed"); + setDevices([]); + } finally { + setIsScanning(false); + } + }, []); + + const addDevice = useCallback(async (deviceId: string): Promise => { + try { + const res = await fetch(`/api/streams/${deviceId}`, { method: "POST" }); + return res.ok; + } catch { + return false; + } + }, []); + + const removeStream = useCallback( + async (streamId: string): Promise => { + try { + const res = await fetch(`/api/streams/${streamId}`, { + method: "DELETE", + }); + return res.ok; + } catch { + return false; + } + }, + [], + ); + + return { devices, isScanning, error, scan, addDevice, removeStream }; +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-sensor-stream.ts b/src/syncfield/viewer/frontend/src/hooks/use-sensor-stream.ts new file mode 100644 index 0000000..3c705f8 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-sensor-stream.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import type { SensorEvent } from "@/lib/types"; + +const MAX_POINTS = 300; +const RECONNECT_DELAY_MS = 2000; + +interface UseSensorStreamReturn { + /** Per-channel rolling buffer of values. */ + channels: Record; + /** Rolling buffer of x-axis labels (timestamps). */ + labels: number[]; + /** Whether the SSE connection is alive. */ + isConnected: boolean; +} + +/** + * SSE hook for real-time sensor channel data. + * + * Connects to `/stream/sensor/{streamId}` and maintains a rolling + * buffer (max 300 points) per channel. Automatically reconnects on + * disconnect. + */ +export function useSensorStream(streamId: string): UseSensorStreamReturn { + const [channels, setChannels] = useState>({}); + const [labels, setLabels] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + // Mutable buffers for performance — we only push state on ticks + const channelBuf = useRef>({}); + const labelBuf = useRef([]); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + let es: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + + function connect() { + if (!mountedRef.current) return; + + es = new EventSource(`/stream/sensor/${streamId}`); + + es.onopen = () => { + if (!mountedRef.current) return; + setIsConnected(true); + }; + + es.onmessage = (event) => { + if (!mountedRef.current) return; + try { + const data: SensorEvent = JSON.parse(event.data); + + // Append to label buffer + if (data.label !== null) { + labelBuf.current.push(data.label); + if (labelBuf.current.length > MAX_POINTS) { + labelBuf.current = labelBuf.current.slice(-MAX_POINTS); + } + } + + // Append to each channel buffer + for (const [name, value] of Object.entries(data.channels)) { + if (!channelBuf.current[name]) { + channelBuf.current[name] = []; + } + channelBuf.current[name].push(value); + if (channelBuf.current[name].length > MAX_POINTS) { + channelBuf.current[name] = channelBuf.current[name].slice( + -MAX_POINTS, + ); + } + } + + // Push a snapshot to React state + setChannels({ ...channelBuf.current }); + setLabels([...labelBuf.current]); + } catch { + // Ignore malformed events + } + }; + + es.onerror = () => { + if (!mountedRef.current) return; + setIsConnected(false); + es?.close(); + es = null; + reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS); + }; + } + + connect(); + + return () => { + mountedRef.current = false; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (es) es.close(); + channelBuf.current = {}; + labelBuf.current = []; + }; + }, [streamId]); + + return { channels, labels, isConnected }; +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-session.ts b/src/syncfield/viewer/frontend/src/hooks/use-session.ts new file mode 100644 index 0000000..63765b5 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-session.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ControlAction, + ServerMessage, + SessionSnapshot, +} from "@/lib/types"; +import { isCountdown, isSnapshot } from "@/lib/types"; + +export type ConnectionStatus = "connecting" | "connected" | "disconnected"; + +interface UseSessionReturn { + snapshot: SessionSnapshot | null; + countdown: number | null; + sendCommand: (action: ControlAction, data?: Record) => void; + connectionStatus: ConnectionStatus; +} + +const RECONNECT_DELAY_MS = 2000; + +/** + * WebSocket hook for session state and control commands. + * + * Connects to `/ws/control`, receives 10 Hz snapshot broadcasts and + * countdown events, and sends control commands back to the server. + * Automatically reconnects on disconnect. + */ +export function useSession(): UseSessionReturn { + const [snapshot, setSnapshot] = useState(null); + const [countdown, setCountdown] = useState(null); + const [connectionStatus, setConnectionStatus] = + useState("connecting"); + + const wsRef = useRef(null); + const reconnectTimer = useRef | null>(null); + const mountedRef = useRef(true); + + // Clear countdown after it reaches 0 (recording has started) + const countdownTimeout = useRef | null>(null); + + const connect = useCallback(() => { + if (!mountedRef.current) return; + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws/control`; + + setConnectionStatus("connecting"); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mountedRef.current) return; + setConnectionStatus("connected"); + }; + + ws.onmessage = (event) => { + if (!mountedRef.current) return; + try { + const msg: ServerMessage = JSON.parse(event.data); + if (isSnapshot(msg)) { + setSnapshot(msg); + } else if (isCountdown(msg)) { + setCountdown(msg.count); + // Clear countdown display after the last tick + if (countdownTimeout.current) clearTimeout(countdownTimeout.current); + if (msg.count === 1) { + countdownTimeout.current = setTimeout(() => { + if (mountedRef.current) setCountdown(null); + }, 1000); + } + } + } catch { + // Ignore malformed messages + } + }; + + ws.onclose = () => { + if (!mountedRef.current) return; + setConnectionStatus("disconnected"); + wsRef.current = null; + // Auto-reconnect + reconnectTimer.current = setTimeout(connect, RECONNECT_DELAY_MS); + }; + + ws.onerror = () => { + // onclose will fire after onerror — reconnect is handled there + ws.close(); + }; + }, []); + + useEffect(() => { + mountedRef.current = true; + connect(); + + return () => { + mountedRef.current = false; + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + if (countdownTimeout.current) clearTimeout(countdownTimeout.current); + if (wsRef.current) { + wsRef.current.onclose = null; // Prevent reconnect on unmount + wsRef.current.close(); + } + }; + }, [connect]); + + const sendCommand = useCallback( + (action: ControlAction, data?: Record) => { + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action, ...data })); + } + }, + [], + ); + + return { snapshot, countdown, sendCommand, connectionStatus }; +} diff --git a/src/syncfield/viewer/frontend/src/lib/format.ts b/src/syncfield/viewer/frontend/src/lib/format.ts new file mode 100644 index 0000000..1b5196f --- /dev/null +++ b/src/syncfield/viewer/frontend/src/lib/format.ts @@ -0,0 +1,75 @@ +/** + * Display formatting helpers — ported from Python viewer/widgets/formatting.py. + * + * Single source of truth for how numbers, timestamps, and labels are + * rendered across the entire web viewer. + */ + +/** Format a duration as `MM:SS.mmm`. */ +export function formatElapsed(seconds: number): string { + if (seconds < 0) seconds = 0; + const minutes = Math.floor(seconds / 60); + const remainder = seconds - minutes * 60; + const whole = Math.floor(remainder); + let millis = Math.round((remainder - whole) * 1000); + if (millis >= 1000) millis = 999; + return `${pad2(minutes)}:${pad2(whole)}.${pad3(millis)}`; +} + +/** Format a frequency for display as `29.9 Hz`. */ +export function formatHz(hz: number): string { + if (hz <= 0) return "—"; + if (hz >= 100) return `${Math.round(hz)} Hz`; + return `${hz.toFixed(1)} Hz`; +} + +/** Format a frame/sample count with thousands separators. */ +export function formatCount(count: number): string { + return count.toLocaleString("en-US"); +} + +/** Format `ms_ago` as a human-readable string. */ +export function formatMsAgo(msAgo: number | null): string { + if (msAgo === null) return "—"; + if (msAgo < 0) return "0 ms ago"; + if (msAgo < 1000) return `${Math.round(msAgo)} ms ago`; + if (msAgo < 60_000) return `${(msAgo / 1000).toFixed(1)} s ago`; + return `${(msAgo / 60_000).toFixed(1)} min ago`; +} + +/** Truncate a long path from the left so the tail (episode id) stays visible. */ +export function formatPathTail(path: string, maxChars = 60): string { + if (path.length <= maxChars) return path; + return "…" + path.slice(-(maxChars - 1)); +} + +/** Format chirp start/stop pair for the session clock panel. */ +export function formatChirpPair( + startNs: number | null, + stopNs: number | null, +): string { + if (startNs === null) return "pending"; + const startS = startNs / 1e9; + if (stopNs === null) return `start @ ${startS.toFixed(3)}s`; + const spanMs = (stopNs - startNs) / 1e6; + return `start + ${Math.round(spanMs)} ms span`; +} + +/** Uppercase a session state for the header chip. */ +export function stateLabel(state: string): string { + return state ? state.toUpperCase() : ""; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +function pad3(n: number): string { + if (n < 10) return `00${n}`; + if (n < 100) return `0${n}`; + return `${n}`; +} diff --git a/src/syncfield/viewer/frontend/src/lib/types.ts b/src/syncfield/viewer/frontend/src/lib/types.ts new file mode 100644 index 0000000..b4ea3c6 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/lib/types.ts @@ -0,0 +1,103 @@ +// --------------------------------------------------------------------------- +// Snapshot types — mirrors the Python SessionSnapshot / StreamSnapshot +// --------------------------------------------------------------------------- + +export interface StreamSnapshot { + id: string; + kind: "video" | "audio" | "sensor" | "custom"; + frame_count: number; + effective_hz: number; + last_sample_ms_ago: number | null; + provides_audio_track: boolean; + produces_file: boolean; + health_count: number; +} + +export interface ChirpInfo { + enabled: boolean; + start_ns: number | null; + stop_ns: number | null; +} + +export interface HealthEntry { + stream_id: string; + kind: string; + at_s: number; + detail: string | null; +} + +export interface SessionSnapshot { + type: "snapshot"; + state: SessionState; + host_id: string; + elapsed_s: number; + chirp: ChirpInfo; + streams: Record; + health_log: HealthEntry[]; + output_dir: string; +} + +export type SessionState = + | "idle" + | "connecting" + | "connected" + | "starting" + | "recording" + | "stopping" + | "stopped" + | "disconnecting"; + +export interface CountdownEvent { + type: "countdown"; + count: number; +} + +export type ServerMessage = SessionSnapshot | CountdownEvent; + +// --------------------------------------------------------------------------- +// Control commands (client → server) +// --------------------------------------------------------------------------- + +export type ControlAction = + | "connect" + | "disconnect" + | "record" + | "stop" + | "cancel"; + +export interface ControlCommand { + action: ControlAction; + countdown_s?: number; +} + +// --------------------------------------------------------------------------- +// Discovery types +// --------------------------------------------------------------------------- + +export interface DiscoveredDevice { + id: string; + name: string; + adapter: string; + kind: string; +} + +// --------------------------------------------------------------------------- +// Sensor SSE data +// --------------------------------------------------------------------------- + +export interface SensorEvent { + channels: Record; + label: number | null; +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +export function isSnapshot(msg: ServerMessage): msg is SessionSnapshot { + return msg.type === "snapshot"; +} + +export function isCountdown(msg: ServerMessage): msg is CountdownEvent { + return msg.type === "countdown"; +} diff --git a/src/syncfield/viewer/frontend/src/lib/utils.ts b/src/syncfield/viewer/frontend/src/lib/utils.ts new file mode 100644 index 0000000..30f473f --- /dev/null +++ b/src/syncfield/viewer/frontend/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** Merge Tailwind classes with conflict resolution (shadcn pattern). */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/syncfield/viewer/frontend/src/main.tsx b/src/syncfield/viewer/frontend/src/main.tsx new file mode 100644 index 0000000..e95ed1b --- /dev/null +++ b/src/syncfield/viewer/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles/globals.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/syncfield/viewer/frontend/src/styles/globals.css b/src/syncfield/viewer/frontend/src/styles/globals.css new file mode 100644 index 0000000..80c0cf3 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/styles/globals.css @@ -0,0 +1,104 @@ +@import "tailwindcss"; + +/* ── Design Tokens (Recorder palette) ────────────────────── */ + +@theme { + --color-background: hsl(60 7% 95%); + --color-background-subtle: hsl(60 7% 98%); + --color-foreground: hsl(0 0% 13%); + --color-muted: hsl(0 0% 42%); + --color-muted-foreground: hsl(50 5% 78%); + --color-border: hsl(50 5% 85%); + --color-border-strong: hsl(0 0% 13%); + + --color-primary: hsl(153 35% 38%); + --color-primary-foreground: hsl(0 0% 98%); + + --color-destructive: hsl(0 65% 48%); + --color-destructive-foreground: hsl(0 0% 98%); + + --color-recording: hsl(0 65% 48%); + --color-warning: hsl(45 93% 47%); + --color-success: hsl(153 35% 38%); + + --color-card: hsl(0 0% 100%); + --color-card-foreground: hsl(0 0% 13%); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "SF Mono", "Menlo", "Consolas", monospace; +} + +/* ── Base ─────────────────────────────────────────────────── */ + +* { + border-color: var(--color-border); +} + +body { + font-family: var(--font-sans); + background: var(--color-background); + color: var(--color-foreground); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Scrollbar ────────────────────────────────────────────── */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-muted); +} + +/* ── Recording pulse animation ────────────────────────────── */ + +@keyframes pulse-recording { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.animate-pulse-recording { + animation: pulse-recording 1.5s ease-in-out infinite; +} + +/* ── Countdown animation ──────────────────────────────────── */ + +@keyframes countdown-pop { + 0% { + transform: scale(0.5); + opacity: 0; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-countdown-pop { + animation: countdown-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} diff --git a/src/syncfield/viewer/frontend/tsconfig.json b/src/syncfield/viewer/frontend/tsconfig.json new file mode 100644 index 0000000..5a3af71 --- /dev/null +++ b/src/syncfield/viewer/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/src/syncfield/viewer/frontend/vite.config.ts b/src/syncfield/viewer/frontend/vite.config.ts new file mode 100644 index 0000000..5f022e1 --- /dev/null +++ b/src/syncfield/viewer/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + proxy: { + "/ws": { target: "ws://localhost:8420", ws: true }, + "/api": "http://localhost:8420", + "/stream": "http://localhost:8420", + }, + }, + build: { + outDir: "../static", + emptyOutDir: true, + }, +}); diff --git a/src/syncfield/viewer/frontend/yarn.lock b/src/syncfield/viewer/frontend/yarn.lock new file mode 100644 index 0000000..df5ba2f --- /dev/null +++ b/src/syncfield/viewer/frontend/yarn.lock @@ -0,0 +1,1459 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + +"@babel/core@^7.28.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" + integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + +"@babel/helper-plugin-utils@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.6": + version "7.29.2" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.2.tgz#9cfbccb02b8e229892c0b07038052cc1a8709c49" + integrity sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== + dependencies: + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + dependencies: + "@babel/types" "^7.29.0" + +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@emnapi/core@^1.8.1": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + +"@emnapi/runtime@^1.8.1": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/aix-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" + integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" + integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" + integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/android-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" + integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" + integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/darwin-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" + integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" + integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/freebsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" + integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" + integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-arm@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" + integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" + integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-loong64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" + integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-mips64el@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" + integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" + integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-riscv64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" + integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-s390x@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" + integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/linux-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" + integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" + integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/netbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" + integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" + integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" + integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/openharmony-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" + integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/sunos-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" + integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" + integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" + integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + +"@esbuild/win32-x64@0.27.7": + version "0.27.7" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" + integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@napi-rs/wasm-runtime@^1.1.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz#1eeb8699770481306e5fcd84471f20fcb6177336" + integrity sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ== + dependencies: + "@tybys/wasm-util" "^0.10.1" + +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + +"@rollup/rollup-android-arm-eabi@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz#043f145716234529052ef9e1ce1d847ffbe9e674" + integrity sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA== + +"@rollup/rollup-android-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz#023e1bd146e7519087dfd9e8b29e4cf9f8ecd35c" + integrity sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA== + +"@rollup/rollup-darwin-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz#55ccb5487c02419954c57a7a80602885d616e1ee" + integrity sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw== + +"@rollup/rollup-darwin-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz#254b65404b14488c83225e88b8819376ad71a784" + integrity sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew== + +"@rollup/rollup-freebsd-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz#6377ff38c052c76fcaffb7b2728d3172fe676fe6" + integrity sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w== + +"@rollup/rollup-freebsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz#ba3902309d088eaf7139b916f09b7140b28b406d" + integrity sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g== + +"@rollup/rollup-linux-arm-gnueabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz#e011b9a14638267e53b446286e838dbdaf53f167" + integrity sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g== + +"@rollup/rollup-linux-arm-musleabihf@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz#0bce9ce9a009490abd28fd922dd97ed521311afe" + integrity sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg== + +"@rollup/rollup-linux-arm64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz#6f6cfbbf324fbb4ceff213abdf7f322fd45d25ff" + integrity sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ== + +"@rollup/rollup-linux-arm64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz#f7cb3eecaea9c151ef77342af05f38ae924bf795" + integrity sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA== + +"@rollup/rollup-linux-loong64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz#499bfac6bb669fd88bb664357bf6be996a28b92f" + integrity sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ== + +"@rollup/rollup-linux-loong64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz#127dfac08764764396bbe04453c545d38a3ab518" + integrity sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw== + +"@rollup/rollup-linux-ppc64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz#6a72f4d95852aac18326c5bf708393e8f3a41b70" + integrity sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw== + +"@rollup/rollup-linux-ppc64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz#ba8674666b00d6f9066cb9a5771a8430c34d2de6" + integrity sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg== + +"@rollup/rollup-linux-riscv64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz#17cc38b2a71e302547cad29bcf78d0db2618c922" + integrity sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg== + +"@rollup/rollup-linux-riscv64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz#e36a41e2d8bd247331bd5cfc13b8c951d33454a2" + integrity sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg== + +"@rollup/rollup-linux-s390x-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz#1687265f1f4bdea0726c761a58c2db9933609d68" + integrity sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ== + +"@rollup/rollup-linux-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz#56a6a0d9076f2a05a976031493b24a20ddcc0e77" + integrity sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg== + +"@rollup/rollup-linux-x64-musl@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz#bc240ebb5b9fd8d41ca8a80cb458452e8c187e0f" + integrity sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w== + +"@rollup/rollup-openbsd-x64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz#6f80d48a006c4b2ffa7724e95a3e33f6975872af" + integrity sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw== + +"@rollup/rollup-openharmony-arm64@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz#8f6db6f70d0a48abd833b263cd6dd3e7199c4c0e" + integrity sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA== + +"@rollup/rollup-win32-arm64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz#b68989bfa815d0b3d4e302ecd90bda744438b177" + integrity sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g== + +"@rollup/rollup-win32-ia32-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz#c098e45338c50f22f1b288476354f025b746285b" + integrity sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg== + +"@rollup/rollup-win32-x64-gnu@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz#2c9e15be155b79d05999953b1737b2903842e903" + integrity sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg== + +"@rollup/rollup-win32-x64-msvc@4.60.1": + version "4.60.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz#23b860113e9f87eea015d1fa3a4240a52b42fcd4" + integrity sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ== + +"@tailwindcss/node@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.2.2.tgz#840e904226dc1b379609de8a72323fc211568993" + integrity sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA== + dependencies: + "@jridgewell/remapping" "^2.3.5" + enhanced-resolve "^5.19.0" + jiti "^2.6.1" + lightningcss "1.32.0" + magic-string "^0.30.21" + source-map-js "^1.2.1" + tailwindcss "4.2.2" + +"@tailwindcss/oxide-android-arm64@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz#61d9ec5c18394fe7a972e99e19e6065e833da77c" + integrity sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg== + +"@tailwindcss/oxide-darwin-arm64@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz#9ad7b141789dae235c85d2f7874592bf869f636e" + integrity sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg== + +"@tailwindcss/oxide-darwin-x64@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz#a5899f1fbe55c4eddcbc871b835d5183ba34658c" + integrity sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw== + +"@tailwindcss/oxide-freebsd-x64@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz#76185bb1bea9af915a5b9f465323861646587e21" + integrity sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ== + +"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz#74c17c69b2015f7600d566ab0990aaac8701128e" + integrity sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ== + +"@tailwindcss/oxide-linux-arm64-gnu@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz#38a846d9d5795bc3b57951172044d8dbb3c79aa6" + integrity sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw== + +"@tailwindcss/oxide-linux-arm64-musl@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz#f4cc4129c17d3f2bcb01efef4d7a2f381e5e3f53" + integrity sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag== + +"@tailwindcss/oxide-linux-x64-gnu@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz#7c4a00b0829e12736bd72ec74e1c08205448cc2e" + integrity sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg== + +"@tailwindcss/oxide-linux-x64-musl@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz#711756d7bbe97e221fc041b63a4f385b85ba4321" + integrity sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ== + +"@tailwindcss/oxide-wasm32-wasi@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz#ed6d28567b7abb8505f824457c236d2cd07ee18e" + integrity sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q== + dependencies: + "@emnapi/core" "^1.8.1" + "@emnapi/runtime" "^1.8.1" + "@emnapi/wasi-threads" "^1.1.0" + "@napi-rs/wasm-runtime" "^1.1.1" + "@tybys/wasm-util" "^0.10.1" + tslib "^2.8.1" + +"@tailwindcss/oxide-win32-arm64-msvc@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz#f2d0360e5bc06fe201537fb08193d3780e7dd24f" + integrity sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ== + +"@tailwindcss/oxide-win32-x64-msvc@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz#10fc71b73883f9c3999b5b8c338fd96a45240dcb" + integrity sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA== + +"@tailwindcss/oxide@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz#c6534cb4b22650df605a58258235523a6abd7de8" + integrity sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg== + optionalDependencies: + "@tailwindcss/oxide-android-arm64" "4.2.2" + "@tailwindcss/oxide-darwin-arm64" "4.2.2" + "@tailwindcss/oxide-darwin-x64" "4.2.2" + "@tailwindcss/oxide-freebsd-x64" "4.2.2" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.2" + "@tailwindcss/oxide-linux-arm64-gnu" "4.2.2" + "@tailwindcss/oxide-linux-arm64-musl" "4.2.2" + "@tailwindcss/oxide-linux-x64-gnu" "4.2.2" + "@tailwindcss/oxide-linux-x64-musl" "4.2.2" + "@tailwindcss/oxide-wasm32-wasi" "4.2.2" + "@tailwindcss/oxide-win32-arm64-msvc" "4.2.2" + "@tailwindcss/oxide-win32-x64-msvc" "4.2.2" + +"@tailwindcss/vite@^4.1.8": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.2.2.tgz#49240a41691c34b78ed4a80d07a39301f1a5129f" + integrity sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w== + dependencies: + "@tailwindcss/node" "4.2.2" + "@tailwindcss/oxide" "4.2.2" + tailwindcss "4.2.2" + +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/react-dom@^19.1.6": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + +"@types/react@^19.1.6": + version "19.2.14" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" + integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== + dependencies: + csstype "^3.2.2" + +"@vitejs/plugin-react@^4.5.2": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +baseline-browser-mapping@^2.10.12: + version "2.10.17" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz#435c101835c314c2d89d768795e1ea79941fafd3" + integrity sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA== + +browserslist@^4.24.0: + version "4.28.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" + integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== + dependencies: + baseline-browser-mapping "^2.10.12" + caniuse-lite "^1.0.30001782" + electron-to-chromium "^1.5.328" + node-releases "^2.0.36" + update-browserslist-db "^1.2.3" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +caniuse-lite@^1.0.30001782: + version "1.0.30001787" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz#fd25c5e42e2d35df5c75eddda00d15d9c0c68f81" + integrity sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +debug@^4.1.0, debug@^4.3.1, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +electron-to-chromium@^1.5.328: + version "1.5.335" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz#0b957cea44ef86795c227c616d16b4803d119daa" + integrity sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q== + +enhanced-resolve@^5.19.0: + version "5.20.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz#eeeb3966bea62c348c40a0cc9e7912e2557d0be0" + integrity sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.0" + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +esbuild@^0.25.0: + version "0.25.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" + integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.12" + "@esbuild/android-arm" "0.25.12" + "@esbuild/android-arm64" "0.25.12" + "@esbuild/android-x64" "0.25.12" + "@esbuild/darwin-arm64" "0.25.12" + "@esbuild/darwin-x64" "0.25.12" + "@esbuild/freebsd-arm64" "0.25.12" + "@esbuild/freebsd-x64" "0.25.12" + "@esbuild/linux-arm" "0.25.12" + "@esbuild/linux-arm64" "0.25.12" + "@esbuild/linux-ia32" "0.25.12" + "@esbuild/linux-loong64" "0.25.12" + "@esbuild/linux-mips64el" "0.25.12" + "@esbuild/linux-ppc64" "0.25.12" + "@esbuild/linux-riscv64" "0.25.12" + "@esbuild/linux-s390x" "0.25.12" + "@esbuild/linux-x64" "0.25.12" + "@esbuild/netbsd-arm64" "0.25.12" + "@esbuild/netbsd-x64" "0.25.12" + "@esbuild/openbsd-arm64" "0.25.12" + "@esbuild/openbsd-x64" "0.25.12" + "@esbuild/openharmony-arm64" "0.25.12" + "@esbuild/sunos-x64" "0.25.12" + "@esbuild/win32-arm64" "0.25.12" + "@esbuild/win32-ia32" "0.25.12" + "@esbuild/win32-x64" "0.25.12" + +esbuild@^0.27.0: + version "0.27.7" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" + integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.7" + "@esbuild/android-arm" "0.27.7" + "@esbuild/android-arm64" "0.27.7" + "@esbuild/android-x64" "0.27.7" + "@esbuild/darwin-arm64" "0.27.7" + "@esbuild/darwin-x64" "0.27.7" + "@esbuild/freebsd-arm64" "0.27.7" + "@esbuild/freebsd-x64" "0.27.7" + "@esbuild/linux-arm" "0.27.7" + "@esbuild/linux-arm64" "0.27.7" + "@esbuild/linux-ia32" "0.27.7" + "@esbuild/linux-loong64" "0.27.7" + "@esbuild/linux-mips64el" "0.27.7" + "@esbuild/linux-ppc64" "0.27.7" + "@esbuild/linux-riscv64" "0.27.7" + "@esbuild/linux-s390x" "0.27.7" + "@esbuild/linux-x64" "0.27.7" + "@esbuild/netbsd-arm64" "0.27.7" + "@esbuild/netbsd-x64" "0.27.7" + "@esbuild/openbsd-arm64" "0.27.7" + "@esbuild/openbsd-x64" "0.27.7" + "@esbuild/openharmony-arm64" "0.27.7" + "@esbuild/sunos-x64" "0.27.7" + "@esbuild/win32-arm64" "0.27.7" + "@esbuild/win32-ia32" "0.27.7" + "@esbuild/win32-x64" "0.27.7" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +expect-type@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" + integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + +fdir@^6.4.4, fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +jiti@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + +lightningcss@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +magic-string@^0.30.17, magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-releases@^2.0.36: + version "2.0.37" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.37.tgz#9bd4f10b77ba39c2b9402d4e8399c482a797f671" + integrity sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg== + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +postcss@^8.5.3, postcss@^8.5.6: + version "8.5.9" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.9.tgz#f6ee9e0b94f0f19c97d2f172bfbd7fc71fe1cca4" + integrity sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +react-dom@^19.1.0: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e" + integrity sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag== + dependencies: + scheduler "^0.27.0" + +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + +react@^19.1.0: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#c888ab8b8ef33e2597fae8bdb2d77edbdb42858b" + integrity sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA== + +rollup@^4.34.9, rollup@^4.43.0: + version "4.60.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.1.tgz#b4aa2bcb3a5e1437b5fad40d43fe42d4bde7a42d" + integrity sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.60.1" + "@rollup/rollup-android-arm64" "4.60.1" + "@rollup/rollup-darwin-arm64" "4.60.1" + "@rollup/rollup-darwin-x64" "4.60.1" + "@rollup/rollup-freebsd-arm64" "4.60.1" + "@rollup/rollup-freebsd-x64" "4.60.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.60.1" + "@rollup/rollup-linux-arm-musleabihf" "4.60.1" + "@rollup/rollup-linux-arm64-gnu" "4.60.1" + "@rollup/rollup-linux-arm64-musl" "4.60.1" + "@rollup/rollup-linux-loong64-gnu" "4.60.1" + "@rollup/rollup-linux-loong64-musl" "4.60.1" + "@rollup/rollup-linux-ppc64-gnu" "4.60.1" + "@rollup/rollup-linux-ppc64-musl" "4.60.1" + "@rollup/rollup-linux-riscv64-gnu" "4.60.1" + "@rollup/rollup-linux-riscv64-musl" "4.60.1" + "@rollup/rollup-linux-s390x-gnu" "4.60.1" + "@rollup/rollup-linux-x64-gnu" "4.60.1" + "@rollup/rollup-linux-x64-musl" "4.60.1" + "@rollup/rollup-openbsd-x64" "4.60.1" + "@rollup/rollup-openharmony-arm64" "4.60.1" + "@rollup/rollup-win32-arm64-msvc" "4.60.1" + "@rollup/rollup-win32-ia32-msvc" "4.60.1" + "@rollup/rollup-win32-x64-gnu" "4.60.1" + "@rollup/rollup-win32-x64-msvc" "4.60.1" + fsevents "~2.3.2" + +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +tailwind-merge@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca" + integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A== + +tailwindcss@4.2.2, tailwindcss@^4.1.8: + version "4.2.2" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.2.2.tgz#688fb0751c8ca9044e890546510a2ee817308e87" + integrity sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q== + +tapable@^2.3.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.2.tgz#86755feabad08d82a26b891db044808c6ad00f15" + integrity sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA== + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" + integrity sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q== + +tslib@^2.4.0, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +typescript@~5.8.3: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + +update-browserslist-db@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.3.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" + integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== + dependencies: + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vite@^6.3.5: + version "6.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2" + integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.4" + picomatch "^4.0.2" + postcss "^8.5.3" + rollup "^4.34.9" + tinyglobby "^0.2.13" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^3.2.1: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py new file mode 100644 index 0000000..5923416 --- /dev/null +++ b/src/syncfield/viewer/server.py @@ -0,0 +1,414 @@ +"""FastAPI server for the SyncField web viewer. + +Endpoints: + +- **WebSocket** ``/ws/control`` — 10 Hz snapshot broadcast + control commands +- **MJPEG** ``/stream/video/{stream_id}`` — continuous JPEG frames +- **SSE** ``/stream/sensor/{stream_id}`` — sensor channel push (~10 Hz) +- **REST** ``/api/status``, ``/api/discover``, ``/api/streams/{id}`` — one-shot queries +- **Static** ``/*`` — built React SPA (production) or proxied Vite dev server + +The server holds a direct reference to the :class:`SessionOrchestrator` and +its :class:`SessionPoller` — no IPC, no serialization overhead. Video frames +and sensor data flow through dedicated streaming channels so the WebSocket +payload stays lightweight. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +import cv2 +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from starlette.responses import StreamingResponse + +from syncfield.orchestrator import SessionOrchestrator +from syncfield.viewer.poller import SessionPoller +from syncfield.viewer.state import HealthEntry, SessionSnapshot, StreamSnapshot + +logger = logging.getLogger(__name__) + +# Directory containing the built React app (vite build output). +STATIC_DIR = Path(__file__).parent / "static" + + +# --------------------------------------------------------------------------- +# Snapshot serialization +# --------------------------------------------------------------------------- + + +def snapshot_to_dict(snapshot: SessionSnapshot) -> Dict[str, Any]: + """Convert a frozen SessionSnapshot to a JSON-serializable dict. + + Excludes ``latest_frame`` (numpy array) and ``plot_points`` (deque) — + those are streamed over dedicated MJPEG/SSE channels. + """ + now_ns = time.monotonic_ns() + + streams: Dict[str, Any] = {} + for sid, s in snapshot.streams.items(): + last_sample_ms_ago: Optional[float] = None + if s.last_sample_at_ns is not None: + last_sample_ms_ago = round((now_ns - s.last_sample_at_ns) / 1e6, 1) + + streams[sid] = { + "id": s.id, + "kind": s.kind, + "frame_count": s.frame_count, + "effective_hz": round(s.effective_hz, 1), + "last_sample_ms_ago": last_sample_ms_ago, + "provides_audio_track": s.provides_audio_track, + "produces_file": s.produces_file, + "health_count": s.health_count, + } + + health_log: List[Dict[str, Any]] = [] + for h in snapshot.health_log: + at_s = round(h.at_ns / 1e9, 3) if h.at_ns else 0 + health_log.append({ + "stream_id": h.stream_id, + "kind": h.kind, + "at_s": at_s, + "detail": h.detail, + }) + + return { + "type": "snapshot", + "state": snapshot.state, + "host_id": snapshot.host_id, + "elapsed_s": round(snapshot.elapsed_s, 3), + "chirp": { + "enabled": snapshot.chirp_enabled, + "start_ns": snapshot.chirp_start_ns, + "stop_ns": snapshot.chirp_stop_ns, + }, + "streams": streams, + "health_log": health_log, + "output_dir": snapshot.output_dir, + } + + +# --------------------------------------------------------------------------- +# Server class +# --------------------------------------------------------------------------- + + +class ViewerServer: + """FastAPI application for the SyncField web viewer. + + Owns the FastAPI app instance and wires all routes. The caller + (:class:`ViewerApp`) provides the session and poller references. + """ + + def __init__( + self, + session: SessionOrchestrator, + poller: SessionPoller, + *, + title: str = "SyncField", + ) -> None: + self._session = session + self._poller = poller + self._title = title + self._ws_clients: Set[WebSocket] = set() + + self.app = FastAPI(title=title, docs_url=None, redoc_url=None) + self._setup_middleware() + self._setup_routes() + self._setup_static() + + # ------------------------------------------------------------------ + # Middleware + # ------------------------------------------------------------------ + + def _setup_middleware(self) -> None: + self.app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + # ------------------------------------------------------------------ + # Routes + # ------------------------------------------------------------------ + + def _setup_routes(self) -> None: + app = self.app + + # -- WebSocket: snapshot broadcast + control commands --------------- + + @app.websocket("/ws/control") + async def ws_control(ws: WebSocket) -> None: + await ws.accept() + self._ws_clients.add(ws) + try: + # Start background task to broadcast snapshots + broadcast_task = asyncio.create_task( + self._broadcast_loop(ws) + ) + # Listen for control commands + while True: + data = await ws.receive_text() + await self._handle_command(data) + except WebSocketDisconnect: + pass + except Exception: + logger.debug("WebSocket connection closed") + finally: + self._ws_clients.discard(ws) + broadcast_task.cancel() + try: + await broadcast_task + except asyncio.CancelledError: + pass + + # -- MJPEG: continuous video frames --------------------------------- + + @app.get("/stream/video/{stream_id}") + async def stream_video(stream_id: str) -> StreamingResponse: + return StreamingResponse( + self._mjpeg_generator(stream_id), + media_type="multipart/x-mixed-replace; boundary=frame", + ) + + # -- SSE: sensor channel data push ---------------------------------- + + @app.get("/stream/sensor/{stream_id}") + async def stream_sensor(stream_id: str) -> StreamingResponse: + return StreamingResponse( + self._sse_generator(stream_id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # -- REST: one-shot queries ----------------------------------------- + + @app.get("/api/status") + async def api_status() -> JSONResponse: + snapshot = self._poller.get_snapshot() + if snapshot is None: + return JSONResponse({"state": "initializing"}) + return JSONResponse(snapshot_to_dict(snapshot)) + + @app.post("/api/discover") + async def api_discover() -> JSONResponse: + """Trigger device discovery scan.""" + try: + from syncfield.discovery import discover_devices + devices = await asyncio.to_thread(discover_devices) + result = [ + { + "id": d.id, + "name": d.name, + "adapter": d.adapter, + "kind": d.kind, + } + for d in devices + ] + return JSONResponse({"devices": result}) + except ImportError: + return JSONResponse({"devices": [], "error": "discovery not available"}) + except Exception as exc: + logger.exception("Discovery failed") + return JSONResponse( + {"devices": [], "error": str(exc)}, status_code=500 + ) + + @app.post("/api/streams/{stream_id}") + async def api_add_stream(stream_id: str) -> JSONResponse: + """Add a discovered device to the session.""" + try: + from syncfield.discovery import discover_devices, build_stream + devices = await asyncio.to_thread(discover_devices) + device = next((d for d in devices if d.id == stream_id), None) + if device is None: + return JSONResponse( + {"error": f"Device {stream_id!r} not found"}, + status_code=404, + ) + stream = build_stream(device) + self._session.add(stream) + return JSONResponse({"status": "added", "id": stream_id}) + except Exception as exc: + logger.exception("Failed to add stream") + return JSONResponse({"error": str(exc)}, status_code=500) + + @app.delete("/api/streams/{stream_id}") + async def api_remove_stream(stream_id: str) -> JSONResponse: + """Remove a stream from the session.""" + try: + self._session.remove(stream_id) + return JSONResponse({"status": "removed", "id": stream_id}) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + + # ------------------------------------------------------------------ + # Static files (built React app) + # ------------------------------------------------------------------ + + def _setup_static(self) -> None: + if STATIC_DIR.exists() and (STATIC_DIR / "index.html").exists(): + # Serve the SPA — catch-all returns index.html for client-side routing + @self.app.get("/{full_path:path}") + async def spa_fallback(full_path: str) -> HTMLResponse: + # Try to serve the exact file first + file_path = STATIC_DIR / full_path + if full_path and file_path.exists() and file_path.is_file(): + content = file_path.read_bytes() + media_type = _guess_media_type(full_path) + return HTMLResponse(content=content, media_type=media_type) + # Fallback to index.html for SPA routing + return HTMLResponse(content=(STATIC_DIR / "index.html").read_text()) + + # ------------------------------------------------------------------ + # WebSocket broadcast loop + # ------------------------------------------------------------------ + + async def _broadcast_loop(self, ws: WebSocket) -> None: + """Send snapshot JSON to a single WebSocket client at ~10 Hz.""" + while True: + snapshot = self._poller.get_snapshot() + if snapshot is not None: + try: + payload = snapshot_to_dict(snapshot) + await ws.send_text(json.dumps(payload)) + except Exception: + break + await asyncio.sleep(0.1) + + async def broadcast_countdown(self, count: int) -> None: + """Send a countdown event to all connected WebSocket clients.""" + message = json.dumps({"type": "countdown", "count": count}) + disconnected: List[WebSocket] = [] + for ws in self._ws_clients: + try: + await ws.send_text(message) + except Exception: + disconnected.append(ws) + for ws in disconnected: + self._ws_clients.discard(ws) + + # ------------------------------------------------------------------ + # Command handler + # ------------------------------------------------------------------ + + async def _handle_command(self, raw: str) -> None: + """Process a control command from a WebSocket client.""" + try: + msg = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Invalid WebSocket message: %s", raw) + return + + action = msg.get("action") + if action == "connect": + await asyncio.to_thread(self._session.connect) + elif action == "disconnect": + await asyncio.to_thread(self._session.disconnect) + elif action == "record": + countdown_s = msg.get("countdown_s", 3) + await self._start_with_countdown(countdown_s) + elif action == "stop": + await asyncio.to_thread(self._session.stop) + elif action == "cancel": + await asyncio.to_thread(self._session.cancel) + else: + logger.warning("Unknown action: %s", action) + + async def _start_with_countdown(self, countdown_s: int) -> None: + """Run countdown, then start recording.""" + for i in range(countdown_s, 0, -1): + await self.broadcast_countdown(i) + await asyncio.sleep(1.0) + await asyncio.to_thread(self._session.start) + + # ------------------------------------------------------------------ + # MJPEG generator + # ------------------------------------------------------------------ + + async def _mjpeg_generator(self, stream_id: str): + """Yield JPEG frames as a multipart stream.""" + while True: + snapshot = self._poller.get_snapshot() + if snapshot is not None: + stream = snapshot.streams.get(stream_id) + if stream is not None and stream.latest_frame is not None: + try: + _, jpeg = cv2.imencode( + ".jpg", stream.latest_frame, + [cv2.IMWRITE_JPEG_QUALITY, 80], + ) + frame_bytes = jpeg.tobytes() + yield ( + b"--frame\r\n" + b"Content-Type: image/jpeg\r\n" + b"Content-Length: " + str(len(frame_bytes)).encode() + b"\r\n" + b"\r\n" + frame_bytes + b"\r\n" + ) + except Exception: + pass + await asyncio.sleep(1 / 30) # ~30 fps cap + + # ------------------------------------------------------------------ + # SSE generator + # ------------------------------------------------------------------ + + async def _sse_generator(self, stream_id: str): + """Yield sensor data as Server-Sent Events.""" + while True: + snapshot = self._poller.get_snapshot() + if snapshot is not None: + stream = snapshot.streams.get(stream_id) + if stream is not None and stream.plot_points: + channels: Dict[str, float] = {} + label: Optional[float] = None + for ch_name, (xs, ys) in stream.plot_points.items(): + if ys: + channels[ch_name] = ys[-1] + if xs and label is None: + label = xs[-1] + + if channels: + event_data = json.dumps({ + "channels": channels, + "label": label, + }) + yield f"data: {event_data}\n\n" + await asyncio.sleep(0.1) # ~10 Hz + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _guess_media_type(path: str) -> str: + """Guess MIME type from file extension for static file serving.""" + ext = Path(path).suffix.lower() + types = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + } + return types.get(ext, "application/octet-stream") diff --git a/src/syncfield/viewer/theme.py b/src/syncfield/viewer/theme.py deleted file mode 100644 index a0b00f6..0000000 --- a/src/syncfield/viewer/theme.py +++ /dev/null @@ -1,358 +0,0 @@ -"""Visual theme for the SyncField desktop viewer. - -The viewer ships a **light theme only**, matching OpenGraph's minimal and -sophisticated design language. Everything here is in one place so a future -dark mode (or brand recolor) is a single-file change. - -Design tokens: - -- Backgrounds and surfaces use a near-white palette with subtle tonal - variation so panels pop without needing heavy borders. -- Primary text is near-black, secondary text is a muted gray. -- Accent color is a single calibrated indigo used for state indicators and - active buttons. Success/warning/danger round out the semantic palette. -- Border radii are consistently soft (6–10 px) so the GUI feels modern - without being cartoonish. -- Padding and spacing values are generous, matching OpenGraph's docs site - aesthetic of "plenty of whitespace, tight typography." - -All values are raw tuples so the module is importable without DearPyGui — -the ``build_theme`` function is the only thing that touches ``dpg``. -""" - -from __future__ import annotations - -from typing import Any, Tuple - -# --------------------------------------------------------------------------- -# Color palette (RGBA 0-255) -# --------------------------------------------------------------------------- - -# Surfaces — near-white tonal stack -BG_APP = (248, 249, 251, 255) # #F8F9FB — viewport background -BG_PANEL = (255, 255, 255, 255) # #FFFFFF — primary panels -BG_PANEL_SOFT = (243, 245, 248, 255) # #F3F5F8 — secondary / nested panels -BG_HOVER = (237, 240, 244, 255) # #EDF0F4 -BG_ACTIVE = (228, 232, 239, 255) # #E4E8EF - -# Borders -BORDER_SUBTLE = (228, 231, 236, 255) # #E4E7EC — panel hairlines -BORDER_STRONG = (209, 213, 219, 255) # #D1D5DB — emphasized borders - -# Text -TEXT_PRIMARY = (17, 24, 39, 255) # #111827 — gray-900 -TEXT_SECONDARY = (107, 114, 128, 255) # #6B7280 — gray-500 -TEXT_MUTED = (156, 163, 175, 255) # #9CA3AF — gray-400 -TEXT_ON_ACCENT = (255, 255, 255, 255) - -# Semantic colors -ACCENT = (79, 70, 229, 255) # #4F46E5 — indigo-600 (primary brand) -ACCENT_HOVER = (67, 56, 202, 255) # #4338CA -ACCENT_ACTIVE = (55, 48, 163, 255) # #3730A3 -ACCENT_SOFT = (238, 242, 255, 255) # #EEF2FF — indigo-50 tint - -SUCCESS = (16, 185, 129, 255) # #10B981 — emerald-500 -SUCCESS_SOFT = (209, 250, 229, 255) # #D1FAE5 -WARNING = (245, 158, 11, 255) # #F59E0B — amber-500 -WARNING_SOFT = (254, 243, 199, 255) # #FEF3C7 -DANGER = (220, 38, 38, 255) # #DC2626 — red-600 -DANGER_SOFT = (254, 226, 226, 255) # #FEE2E2 -INFO = (14, 165, 233, 255) # #0EA5E9 — sky-500 - -# Session state indicators — one color per value of SessionState so the -# header dot gives an at-a-glance read on what the session is doing. -STATE_IDLE = TEXT_MUTED -STATE_CONNECTING = WARNING -STATE_CONNECTED = INFO -STATE_PREPARING = WARNING -STATE_COUNTDOWN = WARNING -STATE_RECORDING = DANGER -STATE_STOPPING = WARNING -STATE_STOPPED = SUCCESS - -# Plot colors (palette matches Tailwind's calibrated hues for print/light bg) -PLOT_SERIES_COLORS: Tuple[Tuple[int, int, int, int], ...] = ( - (79, 70, 229, 255), # indigo-600 - (16, 185, 129, 255), # emerald-500 - (245, 158, 11, 255), # amber-500 - (220, 38, 38, 255), # red-600 - (14, 165, 233, 255), # sky-500 - (236, 72, 153, 255), # pink-500 - (20, 184, 166, 255), # teal-500 -) - - -# --------------------------------------------------------------------------- -# Spacing and typography scale -# --------------------------------------------------------------------------- - -# Style variables (DearPyGui ImGui-style) -FRAME_ROUNDING = 6 -WINDOW_ROUNDING = 10 -CHILD_ROUNDING = 8 -POPUP_ROUNDING = 8 -GRAB_ROUNDING = 4 -SCROLLBAR_ROUNDING = 6 -TAB_ROUNDING = 6 - -FRAME_PADDING = (12, 8) -WINDOW_PADDING = (20, 20) -ITEM_SPACING = (12, 10) -ITEM_INNER_SPACING = (8, 6) -CELL_PADDING = (8, 6) - -WINDOW_BORDER_SIZE = 0 -CHILD_BORDER_SIZE = 1 -FRAME_BORDER_SIZE = 1 -POPUP_BORDER_SIZE = 1 - -# Card dimensions -CARD_WIDTH = 260 -CARD_HEIGHT = 300 -VIDEO_THUMBNAIL_HEIGHT = 146 # 16:9 at 260 width -PLOT_HEIGHT = 146 - -# Layout sections -HEADER_HEIGHT = 76 -# 3-row control stack: Connect (34) + spacer (8) + Record/Stop row -# (34) + spacer (8) + Cancel (28) + label row (~28) + DPG padding. -CONTROL_PANEL_HEIGHT = 212 -STREAMS_SECTION_HEIGHT = 332 # 300 card + ~14 scrollbar + internal padding -HEALTH_SECTION_HEIGHT = 130 -FOOTER_HEIGHT = 44 - -# Viewport sized to fit on a 13" MacBook screen (1440x900 scaled, 1728x1117 -# native) with the window pinned at (60, 60) — the content rectangle -# stays above the Dock and below the menu bar. -VIEWPORT_WIDTH = 1240 -VIEWPORT_HEIGHT = 960 - - -# --------------------------------------------------------------------------- -# Theme builder — the only function that imports DearPyGui -# --------------------------------------------------------------------------- - - -def build_theme() -> int: - """Construct the OpenGraph light theme and return its DPG tag. - - Call this after ``dpg.create_context()`` and bind with - ``dpg.bind_theme(tag)``. - """ - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvAll): - # --- Window + child backgrounds ----------------------------- - dpg.add_theme_color(dpg.mvThemeCol_WindowBg, BG_APP) - dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_PopupBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_MenuBarBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_SUBTLE) - dpg.add_theme_color(dpg.mvThemeCol_BorderShadow, (0, 0, 0, 0)) - - # --- Text --------------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_PRIMARY) - dpg.add_theme_color(dpg.mvThemeCol_TextDisabled, TEXT_MUTED) - dpg.add_theme_color(dpg.mvThemeCol_TextSelectedBg, ACCENT_SOFT) - - # --- Titles ------------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_TitleBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_TitleBgActive, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_TitleBgCollapsed, BG_PANEL_SOFT) - - # --- Frames (input boxes, sliders) ------------------------- - dpg.add_theme_color(dpg.mvThemeCol_FrameBg, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_FrameBgHovered, BG_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_FrameBgActive, BG_ACTIVE) - - # --- Buttons ------------------------------------------------ - dpg.add_theme_color(dpg.mvThemeCol_Button, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, BG_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, BG_ACTIVE) - - # --- Headers (collapsing, tree, selectable) ---------------- - dpg.add_theme_color(dpg.mvThemeCol_Header, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_HeaderHovered, BG_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_HeaderActive, BG_ACTIVE) - - # --- Separators --------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_Separator, BORDER_SUBTLE) - dpg.add_theme_color(dpg.mvThemeCol_SeparatorHovered, BORDER_STRONG) - dpg.add_theme_color(dpg.mvThemeCol_SeparatorActive, ACCENT) - - # --- Scrollbars --------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_ScrollbarBg, (0, 0, 0, 0)) - dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrab, BORDER_STRONG) - dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrabHovered, TEXT_MUTED) - dpg.add_theme_color(dpg.mvThemeCol_ScrollbarGrabActive, TEXT_SECONDARY) - - # --- Checkbox / radio / slider grabs ----------------------- - dpg.add_theme_color(dpg.mvThemeCol_CheckMark, ACCENT) - dpg.add_theme_color(dpg.mvThemeCol_SliderGrab, ACCENT) - dpg.add_theme_color(dpg.mvThemeCol_SliderGrabActive, ACCENT_ACTIVE) - - # --- Tabs --------------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_Tab, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_TabHovered, BG_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_TabActive, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_TabUnfocused, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_TabUnfocusedActive, BG_PANEL) - - # --- Tables ------------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_TableHeaderBg, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_TableBorderStrong, BORDER_STRONG) - dpg.add_theme_color(dpg.mvThemeCol_TableBorderLight, BORDER_SUBTLE) - dpg.add_theme_color(dpg.mvThemeCol_TableRowBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_TableRowBgAlt, BG_PANEL_SOFT) - - # --- Plot lines --------------------------------------------- - dpg.add_theme_color(dpg.mvThemeCol_PlotLines, ACCENT) - dpg.add_theme_color(dpg.mvThemeCol_PlotLinesHovered, ACCENT_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_PlotHistogram, ACCENT) - dpg.add_theme_color(dpg.mvThemeCol_PlotHistogramHovered, ACCENT_HOVER) - - # --- Style variables ---------------------------------------- - dpg.add_theme_style(dpg.mvStyleVar_WindowRounding, WINDOW_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_PopupRounding, POPUP_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, FRAME_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_ScrollbarRounding, SCROLLBAR_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_GrabRounding, GRAB_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_TabRounding, TAB_ROUNDING) - - dpg.add_theme_style( - dpg.mvStyleVar_WindowPadding, WINDOW_PADDING[0], WINDOW_PADDING[1] - ) - dpg.add_theme_style( - dpg.mvStyleVar_FramePadding, FRAME_PADDING[0], FRAME_PADDING[1] - ) - dpg.add_theme_style( - dpg.mvStyleVar_ItemSpacing, ITEM_SPACING[0], ITEM_SPACING[1] - ) - dpg.add_theme_style( - dpg.mvStyleVar_ItemInnerSpacing, - ITEM_INNER_SPACING[0], - ITEM_INNER_SPACING[1], - ) - dpg.add_theme_style( - dpg.mvStyleVar_CellPadding, CELL_PADDING[0], CELL_PADDING[1] - ) - - dpg.add_theme_style(dpg.mvStyleVar_WindowBorderSize, WINDOW_BORDER_SIZE) - dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, CHILD_BORDER_SIZE) - dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, FRAME_BORDER_SIZE) - dpg.add_theme_style(dpg.mvStyleVar_PopupBorderSize, POPUP_BORDER_SIZE) - - return theme_tag - - -# --------------------------------------------------------------------------- -# Button variants — one theme per semantic role -# --------------------------------------------------------------------------- - - -def build_primary_button_theme() -> int: - """Filled indigo button theme for the primary action (Record).""" - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvButton): - dpg.add_theme_color(dpg.mvThemeCol_Button, ACCENT) - dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, ACCENT_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, ACCENT_ACTIVE) - dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_ON_ACCENT) - dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) - return theme_tag - - -def build_danger_button_theme() -> int: - """Filled red button theme for stop/danger actions.""" - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvButton): - dpg.add_theme_color(dpg.mvThemeCol_Button, DANGER) - dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, (185, 28, 28, 255)) - dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (153, 27, 27, 255)) - dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_ON_ACCENT) - dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 0) - return theme_tag - - -def build_ghost_button_theme() -> int: - """Outlined/subtle button theme for secondary actions (Cancel).""" - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvButton): - dpg.add_theme_color(dpg.mvThemeCol_Button, (0, 0, 0, 0)) - dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, BG_HOVER) - dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, BG_ACTIVE) - dpg.add_theme_color(dpg.mvThemeCol_Text, TEXT_SECONDARY) - dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_STRONG) - dpg.add_theme_style(dpg.mvStyleVar_FrameBorderSize, 1) - return theme_tag - - -def build_card_theme() -> int: - """Theme for stream cards — white panel with a subtle hairline border.""" - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvChildWindow): - dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL) - dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_SUBTLE) - dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 1) - dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 16, 14) - return theme_tag - - -def build_soft_panel_theme() -> int: - """Theme for secondary panels — light gray fill with a hairline border. - - The 1 px border lets control/clock panels read as distinct containers - against the same-tone app background without leaning on heavier - chrome. Matches the stream cards' visual weight so the whole UI - feels like one consistent family of cards. - """ - import dearpygui.dearpygui as dpg - - with dpg.theme() as theme_tag: - with dpg.theme_component(dpg.mvChildWindow): - dpg.add_theme_color(dpg.mvThemeCol_ChildBg, BG_PANEL_SOFT) - dpg.add_theme_color(dpg.mvThemeCol_Border, BORDER_SUBTLE) - dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, CHILD_ROUNDING) - dpg.add_theme_style(dpg.mvStyleVar_ChildBorderSize, 1) - dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 18, 16) - return theme_tag - - -# --------------------------------------------------------------------------- -# Semantic helpers -# --------------------------------------------------------------------------- - - -def state_color(state_value: str) -> Tuple[int, int, int, int]: - """Map a ``SessionState.value`` string to its indicator color.""" - return { - "idle": STATE_IDLE, - "connecting": STATE_CONNECTING, - "connected": STATE_CONNECTED, - "preparing": STATE_PREPARING, - "countdown": STATE_COUNTDOWN, - "recording": STATE_RECORDING, - "stopping": STATE_STOPPING, - "stopped": STATE_STOPPED, - }.get(state_value, TEXT_MUTED) - - -def series_color(index: int) -> Tuple[int, int, int, int]: - """Pick a plot series color that cycles through the calibrated palette.""" - return PLOT_SERIES_COLORS[index % len(PLOT_SERIES_COLORS)] - - -def rgba_to_tuple(rgba: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]: - """Identity helper used by widgets to stay type-safe.""" - return rgba diff --git a/src/syncfield/viewer/widgets/__init__.py b/src/syncfield/viewer/widgets/__init__.py deleted file mode 100644 index 112d228..0000000 --- a/src/syncfield/viewer/widgets/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Viewer widget modules. - -Each module owns one logical section of the viewer UI. The top-level -:class:`syncfield.viewer.widgets.layout.ViewerLayout` composes them into -the complete screen and drives per-frame updates from a -:class:`SessionSnapshot`. -""" diff --git a/src/syncfield/viewer/widgets/discovery_modal.py b/src/syncfield/viewer/widgets/discovery_modal.py deleted file mode 100644 index c29d72d..0000000 --- a/src/syncfield/viewer/widgets/discovery_modal.py +++ /dev/null @@ -1,595 +0,0 @@ -"""Desktop viewer modal for ``syncfield.discovery``. - -When the user clicks "Discover devices" in the viewer header, this -modal opens, runs :func:`syncfield.discovery.scan` on a worker thread, -and presents the results as an OpenGraph-styled card list. The user -checks the devices they want, clicks "Add", and the selected devices -are constructed and registered with the live session. - -Layout ------- -:: - - ┌── Discover devices ────────────────────────────┐ - │ Scan ready · last result 4.2 s ago │ - │ │ - │ Cameras │ - │ ───────── │ - │ ☑ FaceTime HD Camera uvc_webcam · idx 0 │ - │ ☑ OAK-D S2 oak_camera · 14… │ - │ │ - │ Sensors │ - │ ─────── │ - │ ☑ OGLO Right oglo_tactile · AA… │ - │ ⚠ BNO085 Dongle requires uuid │ - │ │ - │ [ Rescan ] [ Add 3 → ] │ - └─────────────────────────────────────────────────┘ - -Threading ---------- -All DearPyGui mutation runs on the main thread (via the viewer's -render loop, which calls ``update()`` every frame). The scan itself -runs in a daemon worker thread; when it completes, the worker updates -a small in-modal state object, and the next render-loop tick notices -the change and rebuilds the card list. -""" - -from __future__ import annotations - -import logging -import threading -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional - -import dearpygui.dearpygui as dpg - -from syncfield.viewer import theme -from syncfield.viewer.fonts import FontRegistry - -if TYPE_CHECKING: - from syncfield.discovery import DiscoveredDevice, DiscoveryReport - from syncfield.orchestrator import SessionOrchestrator - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# State shared between the worker thread and the render loop -# --------------------------------------------------------------------------- - - -@dataclass -class _ModalState: - """Mutable state the worker thread writes and the render loop reads. - - One instance per modal. The render loop polls ``needs_rebuild`` on - every tick — cheap boolean check — and rebuilds the card list only - when a scan has just completed or the selection set changed. This - keeps the per-frame cost of having the modal open effectively zero. - """ - - scanning: bool = False - scan_started_at: float = 0.0 - scan_completed_at: float = 0.0 - report: Optional["DiscoveryReport"] = None - selected: set = field(default_factory=set) # set[device_id] - needs_rebuild: bool = False - error_message: Optional[str] = None - - -# --------------------------------------------------------------------------- -# Modal -# --------------------------------------------------------------------------- - - -class DiscoveryModal: - """Modal window bound to a single :class:`SessionOrchestrator`. - - Constructed once by :class:`~syncfield.viewer.widgets.layout.ViewerLayout` - and reused across opens. All DPG tags are namespaced under - ``"discovery::"`` so the modal never collides with layout widgets. - """ - - _MODAL_WIDTH = 640 - _MODAL_HEIGHT = 620 - _SECTION_SPACING = 16 - - def __init__( - self, - session: "SessionOrchestrator", - *, - fonts: Optional[FontRegistry] = None, - on_added: Optional[Callable[[List["DiscoveredDevice"]], None]] = None, - ) -> None: - self._session = session - self._fonts = fonts or FontRegistry() - self._on_added = on_added - self._state = _ModalState() - self._lock = threading.Lock() - - # DPG tags — constant strings so ``configure_item`` / ``set_value`` - # calls don't need to look anything up. - self._window_tag = "discovery::window" - self._status_tag = "discovery::status" - self._content_tag = "discovery::content" - self._rescan_button_tag = "discovery::btn_rescan" - self._add_button_tag = "discovery::btn_add" - self._close_button_tag = "discovery::btn_close" - - self._built = False - - # ------------------------------------------------------------------ - # Build / open / close - # ------------------------------------------------------------------ - - def build(self) -> None: - """Create the modal window. Idempotent — safe to call twice.""" - if self._built: - return - - with dpg.window( - label="Discover devices", - tag=self._window_tag, - width=self._MODAL_WIDTH, - height=self._MODAL_HEIGHT, - modal=True, - show=False, - no_resize=False, - no_collapse=True, - on_close=self._on_window_close, - ): - # Intro text sits at the very top and explains what's about - # to happen in a single sentence. - dpg.add_text( - "Select cameras and sensors to register with this session.", - color=theme.TEXT_SECONDARY, - ) - dpg.add_spacer(height=8) - - # Status strip — shows "Ready", "Scanning…", or "Found N". - with dpg.group(horizontal=True): - dpg.add_text("●", tag="discovery::status_dot", color=theme.TEXT_MUTED) - dpg.add_spacer(width=6) - dpg.add_text( - "Ready", - tag=self._status_tag, - color=theme.TEXT_SECONDARY, - ) - - dpg.add_spacer(height=self._SECTION_SPACING) - - # Scrollable body where we draw device cards after a scan. - dpg.add_child_window( - tag=self._content_tag, - width=-1, - height=-60, # leave room for footer buttons - border=False, - horizontal_scrollbar=False, - ) - - # Footer row: Rescan on the left, Add and Close on the right. - with dpg.group(horizontal=True): - dpg.add_button( - label="Rescan", - tag=self._rescan_button_tag, - width=110, - height=32, - callback=self._on_rescan_click, - ) - # Spacer pushes the next two buttons to the far right edge. - dpg.add_spacer(width=self._MODAL_WIDTH - 110 - 200 - 80) - dpg.add_button( - label="Close", - tag=self._close_button_tag, - width=90, - height=32, - callback=self._on_close_click, - ) - dpg.add_button( - label="Add selected", - tag=self._add_button_tag, - width=140, - height=32, - callback=self._on_add_click, - ) - - # Bind button themes after the context has the tags in place. - dpg.bind_item_theme(self._rescan_button_tag, theme.build_ghost_button_theme()) - dpg.bind_item_theme(self._close_button_tag, theme.build_ghost_button_theme()) - dpg.bind_item_theme(self._add_button_tag, theme.build_primary_button_theme()) - - # Add button starts disabled — no selection until a scan finishes. - dpg.disable_item(self._add_button_tag) - - self._built = True - - def open(self) -> None: - """Show the modal and kick off a fresh scan on a worker thread.""" - if not self._built: - self.build() - dpg.show_item(self._window_tag) - self._start_scan() - - def is_open(self) -> bool: - return self._built and dpg.is_item_shown(self._window_tag) - - # ------------------------------------------------------------------ - # Render-loop integration - # ------------------------------------------------------------------ - - def tick(self) -> None: - """Called every render frame by :class:`ViewerLayout`. - - Checks the mutable state set by the worker thread and rebuilds - the content area when a scan has just finished. No-op when the - modal is closed or the scan hasn't produced new results. - """ - if not self._built: - return - with self._lock: - needs_rebuild = self._state.needs_rebuild - if needs_rebuild: - self._state.needs_rebuild = False - - if not needs_rebuild: - # Still update the elapsed timer while scanning so the user - # sees the progress tick forward. - if self._state.scanning: - elapsed = time.monotonic() - self._state.scan_started_at - dpg.set_value( - self._status_tag, f"Scanning devices… {elapsed:.1f}s" - ) - return - - # Snapshot the shared state under the lock, then render. - with self._lock: - report = self._state.report - scanning = self._state.scanning - error = self._state.error_message - - if scanning: - # Worker said it's scanning but needs_rebuild was also set — - # race where we clear content before showing the spinner. - self._render_scanning_state() - elif error: - self._render_error_state(error) - elif report is not None: - self._render_results(report) - - # ------------------------------------------------------------------ - # Scan driving - # ------------------------------------------------------------------ - - def _start_scan(self) -> None: - """Kick the background scan thread. Disables UI while it runs.""" - # Disable buttons so users can't double-click Rescan or Add while - # the worker is mid-flight. - dpg.disable_item(self._add_button_tag) - dpg.disable_item(self._rescan_button_tag) - dpg.configure_item("discovery::status_dot", color=theme.ACCENT) - dpg.set_value(self._status_tag, "Scanning devices…") - - with self._lock: - self._state.scanning = True - self._state.scan_started_at = time.monotonic() - self._state.report = None - self._state.error_message = None - self._state.selected.clear() - self._state.needs_rebuild = True - - threading.Thread( - target=self._run_scan_worker, - name="discovery-modal-scan", - daemon=True, - ).start() - - def _run_scan_worker(self) -> None: - """Background thread: call ``scan()`` and update shared state.""" - # Lazy import so importing the viewer package doesn't force the - # discovery module load chain. - from syncfield.discovery import scan - - try: - report = scan(timeout=10.0, use_cache=False) - error = None - except Exception as exc: # pragma: no cover — defensive - logger.exception("discovery scan failed") - report = None - error = f"{type(exc).__name__}: {exc}" - - with self._lock: - self._state.scanning = False - self._state.scan_completed_at = time.monotonic() - self._state.report = report - self._state.error_message = error - # Preselect every device that's ready to add (no warnings, - # not in use, AND not already registered on the session) - # so the common "everything looks good, just click Add" - # path is one click away. Devices whose physical hardware - # is already owned by a registered stream are rendered as - # "already added" and their checkbox stays off by default. - if report is not None: - self._state.selected = { - d.device_id - for d in report.devices - if not d.warnings - and not d.in_use - and self._already_registered_as(d) is None - } - self._state.needs_rebuild = True - - def _already_registered_as( - self, device: "DiscoveredDevice" - ) -> Optional[str]: - """Return the stream id that already owns *device*, or ``None``. - - A discovered device is "already registered" when the current - session contains a stream whose ``device_key`` equals - ``(device.adapter_type, device.device_id)``. The check is - defensive against adapters that predate the ``device_key`` - property — ``getattr(..., None)`` falls through to ``None``. - """ - target = (device.adapter_type, device.device_id) - for stream in self._session._streams.values(): # noqa: SLF001 - key = getattr(stream, "device_key", None) - if key == target: - return stream.id - return None - - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def _clear_content(self) -> None: - """Wipe the content area before redrawing.""" - for child in dpg.get_item_children(self._content_tag, 1) or []: - dpg.delete_item(child) - - def _render_scanning_state(self) -> None: - self._clear_content() - dpg.add_text( - "Enumerating cameras and sensors…", - parent=self._content_tag, - color=theme.TEXT_SECONDARY, - ) - dpg.add_text( - "BLE peripherals take up to 5 seconds.", - parent=self._content_tag, - color=theme.TEXT_MUTED, - ) - - def _render_error_state(self, message: str) -> None: - self._clear_content() - dpg.configure_item("discovery::status_dot", color=theme.DANGER) - dpg.set_value(self._status_tag, "Scan failed") - dpg.add_text( - "Discovery scan failed:", - parent=self._content_tag, - color=theme.DANGER, - ) - dpg.add_text(message, parent=self._content_tag, color=theme.TEXT_SECONDARY) - dpg.enable_item(self._rescan_button_tag) - - def _render_results(self, report: "DiscoveryReport") -> None: - self._clear_content() - - count = len(report.devices) - if count == 0: - dpg.configure_item("discovery::status_dot", color=theme.TEXT_MUTED) - dpg.set_value( - self._status_tag, - f"No devices found ({report.duration_s:.1f}s scan)", - ) - dpg.add_text( - "No cameras or sensors detected.", - parent=self._content_tag, - color=theme.TEXT_SECONDARY, - ) - dpg.add_text( - "Check cables, permissions, and make sure the SyncField " - "extras ([uvc], [oak], [ble]) are installed.", - parent=self._content_tag, - color=theme.TEXT_MUTED, - wrap=self._MODAL_WIDTH - 80, - ) - dpg.enable_item(self._rescan_button_tag) - return - - dpg.configure_item("discovery::status_dot", color=theme.SUCCESS) - dpg.set_value( - self._status_tag, - f"Found {count} device{'s' if count != 1 else ''} in {report.duration_s:.1f}s", - ) - - # Group by Stream kind — cameras first, sensors second, others - # last — so the eye reaches the most-relevant section first. - for kind_key, title in (("video", "Cameras"), ("sensor", "Sensors"), ("audio", "Audio"), ("custom", "Other")): - devices = report.by_kind(kind_key) - if not devices: - continue - dpg.add_text( - title.upper(), - parent=self._content_tag, - color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=4, parent=self._content_tag) - for device in devices: - self._render_device_row(device) - dpg.add_spacer(height=self._SECTION_SPACING, parent=self._content_tag) - - # Surface any scan errors at the bottom, muted. - if report.errors: - dpg.add_separator(parent=self._content_tag) - dpg.add_text( - "Scan errors (partial):", - parent=self._content_tag, - color=theme.TEXT_MUTED, - ) - for adapter_type, error in report.errors.items(): - dpg.add_text( - f"· {adapter_type}: {error}", - parent=self._content_tag, - color=theme.WARNING, - wrap=self._MODAL_WIDTH - 80, - ) - - dpg.enable_item(self._rescan_button_tag) - self._refresh_add_button_label() - - def _render_device_row(self, device: "DiscoveredDevice") -> None: - """One row per discovered device — checkbox + two-line label.""" - already_as = self._already_registered_as(device) - addable = ( - not device.warnings - and not device.in_use - and already_as is None - ) - checkbox_tag = f"discovery::check_{device.device_id}" - row_tag = f"discovery::row_{device.device_id}" - - with dpg.group(tag=row_tag, parent=self._content_tag): - with dpg.group(horizontal=True): - dpg.add_checkbox( - tag=checkbox_tag, - default_value=device.device_id in self._state.selected, - callback=self._on_checkbox_toggle, - user_data=device.device_id, - enabled=addable, - ) - dpg.add_spacer(width=4) - dpg.add_text( - device.display_name, - color=theme.TEXT_PRIMARY if addable else theme.TEXT_MUTED, - ) - - # Sub-line: adapter_type · device_id · description - sub_bits = [device.adapter_type] - if device.device_id and device.device_id != device.display_name: - sub_bits.append(device.device_id) - if device.description: - sub_bits.append(device.description) - with dpg.group(horizontal=True): - dpg.add_spacer(width=24) # align under the label - dpg.add_text(" · ".join(sub_bits), color=theme.TEXT_MUTED) - - # "Already added" row — physical device is already owned by - # a registered stream, so the checkbox is disabled above. - if already_as is not None: - with dpg.group(horizontal=True): - dpg.add_spacer(width=24) - dpg.add_text( - f"✓ Already added as '{already_as}'", - color=theme.TEXT_MUTED, - ) - - # Warning row if the device can't be auto-added. - if device.warnings: - with dpg.group(horizontal=True): - dpg.add_spacer(width=24) - dpg.add_text(f"⚠ {device.warnings[0]}", color=theme.WARNING) - - if device.in_use: - with dpg.group(horizontal=True): - dpg.add_spacer(width=24) - dpg.add_text( - "⚠ already in use by another process", - color=theme.WARNING, - ) - - dpg.add_spacer(height=6) - - def _refresh_add_button_label(self) -> None: - """Keep the Add button label in sync with the selection size.""" - n = len(self._state.selected) - if n == 0: - dpg.set_item_label(self._add_button_tag, "Add selected") - dpg.disable_item(self._add_button_tag) - else: - dpg.set_item_label( - self._add_button_tag, - f"Add {n} device{'s' if n != 1 else ''}", - ) - dpg.enable_item(self._add_button_tag) - - # ------------------------------------------------------------------ - # Button callbacks - # ------------------------------------------------------------------ - - def _on_checkbox_toggle(self, sender: Any, value: bool, user_data: Any) -> None: - device_id = str(user_data) - with self._lock: - if value: - self._state.selected.add(device_id) - else: - self._state.selected.discard(device_id) - self._refresh_add_button_label() - - def _on_rescan_click(self) -> None: - self._start_scan() - - def _on_add_click(self) -> None: - """Construct and register each selected device with the session. - - Runs on the UI thread (it's a button callback) but the - ``session.add()`` and stream construction calls are fast — no - real I/O, no BLE connect — so blocking briefly is fine. - """ - from syncfield.discovery import make_stream_id - - with self._lock: - report = self._state.report - selected = set(self._state.selected) - - if not report or not selected: - return - - existing_ids = set(self._session._streams.keys()) # noqa: SLF001 - added: List["DiscoveredDevice"] = [] - - for device in report.devices: - if device.device_id not in selected: - continue - # Defense in depth: even though the checkbox for already- - # registered devices is disabled in the UI, re-check here - # so a stale ``selected`` snapshot from before a previous - # Add-click cannot resurrect a device the session already - # owns. The orchestrator would reject it anyway with a - # ValueError, but skipping here keeps the error log clean. - if self._already_registered_as(device) is not None: - continue - try: - stream_id = make_stream_id(device.display_name, existing_ids) - kwargs: Dict[str, Any] = {"id": stream_id} - if device.accepts_output_dir: - kwargs["output_dir"] = self._session.output_dir - stream = device.construct(**kwargs) - self._session.add(stream) - existing_ids.add(stream_id) - added.append(device) - except Exception as exc: - logger.warning( - "failed to add %s: %s: %s", - device.display_name, - type(exc).__name__, - exc, - ) - - if added and self._on_added is not None: - try: - self._on_added(added) - except Exception: - logger.exception("on_added callback raised") - - dpg.hide_item(self._window_tag) - - def _on_close_click(self) -> None: - dpg.hide_item(self._window_tag) - - def _on_window_close(self, sender: Any) -> None: - """Called when the user clicks the native ``X`` on the modal.""" - # Nothing to clean up — the scan thread is daemonized and the - # DPG state is reused on the next open(). - pass diff --git a/src/syncfield/viewer/widgets/layout.py b/src/syncfield/viewer/widgets/layout.py deleted file mode 100644 index 9224cd9..0000000 --- a/src/syncfield/viewer/widgets/layout.py +++ /dev/null @@ -1,863 +0,0 @@ -"""Top-level viewer layout. - -One :class:`ViewerLayout` instance owns all the DearPyGui tags for the -viewer window. It builds the UI once in :meth:`build`, then every render -frame the app calls :meth:`update` with the latest -:class:`SessionSnapshot` and the layout fans the values out to each -widget. Stream cards are created lazily as new stream ids appear in -snapshots. - -Sections (top to bottom): - - ┌── Header ──────────────────────────────── state · timer ─┐ - ├── Control panel │ Session clock + chirp ──────────────┤ - ├── Streams (horizontal card row) ─────────────────────────┤ - ├── Health timeline ───────────────────────────────────────┤ - └── Footer: output dir · sync point wall clock ────────────┘ -""" - -from __future__ import annotations - -import threading -import time -from typing import Dict, Optional, TYPE_CHECKING - -import dearpygui.dearpygui as dpg - -from syncfield.orchestrator import SessionOrchestrator -from syncfield.types import SessionState -from syncfield.viewer import theme -from syncfield.viewer.fonts import FontRegistry -from syncfield.viewer.state import SessionSnapshot -from syncfield.viewer.widgets.discovery_modal import DiscoveryModal -from syncfield.viewer.widgets.formatting import ( - format_chirp_pair, - format_elapsed, - format_path_tail, - state_label, -) -from syncfield.viewer.widgets.stream_card import StreamCard - - -class ViewerLayout: - """Owns the DearPyGui nodes for every section of the viewer. - - The layout does **not** import :class:`~syncfield.viewer.app.ViewerApp` - directly — instead, callbacks capture the session and call its methods - from a worker thread so the render loop never blocks. - """ - - #: How long the countdown runs before recording actually begins. - #: The session clock panel overlays ``3 → 2 → 1`` in big display - #: numerals during this window. - COUNTDOWN_SECONDS: int = 3 - - def __init__( - self, - session: SessionOrchestrator, - *, - fonts: Optional[FontRegistry] = None, - ) -> None: - self._session = session - self._fonts = fonts or FontRegistry() - self._cards: Dict[str, StreamCard] = {} - self._streams_row_tag = "streams_row" - self._health_table_tag = "health_table" - self._last_health_keys: tuple = () - # Discovery modal — built lazily the first time the user clicks - # the header button. Holds its own DPG tags so the layout does - # not need to know about its internals. - self._discovery_modal: Optional[DiscoveryModal] = None - # Countdown state — populated by the Record callback, read by - # ``_update_clock_panel`` so the big overlay number stays in - # sync with ``SessionOrchestrator.start(on_countdown_tick=…)``. - self._countdown_value: Optional[int] = None - self._countdown_lock = threading.Lock() - - # ------------------------------------------------------------------ - # Build (called once at viewer startup) - # ------------------------------------------------------------------ - - def build(self) -> None: - """Construct the main window and all static chrome.""" - with dpg.window( - tag="main_window", - no_title_bar=True, - no_move=True, - no_resize=True, - no_collapse=True, - no_bring_to_front_on_focus=True, - no_scrollbar=True, - ): - self._build_header() - dpg.add_spacer(height=2) - dpg.add_separator() - dpg.add_spacer(height=10) - self._build_control_and_clock_row() - dpg.add_spacer(height=14) - self._build_streams_section() - dpg.add_spacer(height=14) - self._build_health_section() - dpg.add_spacer(height=10) - dpg.add_separator() - dpg.add_spacer(height=8) - self._build_footer() - - # Button themes need the context to exist, so build them now. - self._primary_theme = theme.build_primary_button_theme() - self._danger_theme = theme.build_danger_button_theme() - self._ghost_theme = theme.build_ghost_button_theme() - self._soft_panel_theme = theme.build_soft_panel_theme() - - dpg.bind_item_theme("control_panel", self._soft_panel_theme) - dpg.bind_item_theme("clock_panel", self._soft_panel_theme) - dpg.bind_item_theme("btn_connect", self._primary_theme) - dpg.bind_item_theme("btn_disconnect", self._ghost_theme) - dpg.bind_item_theme("btn_record", self._primary_theme) - dpg.bind_item_theme("btn_stop", self._danger_theme) - dpg.bind_item_theme("btn_cancel", self._ghost_theme) - dpg.bind_item_theme("btn_discover", self._ghost_theme) - - # Typography — bind prominent display fonts to the app title, - # timer, and host id so the header feels like a real app. - self._bind_fonts() - - # Construct (but don't yet show) the discovery modal. Building - # it here means the first click on the Discover button opens - # an already-ready window instead of waiting for DPG to build - # on demand. - self._discovery_modal = DiscoveryModal(self._session, fonts=self._fonts) - self._discovery_modal.build() - - # The viewer intentionally does NOT auto-connect. The user - # clicks the Connect button when they're ready, at which - # point the session transitions IDLE → CONNECTED and every - # adapter starts producing live preview frames for its card. - # Before that click the viewer sits in IDLE with empty - # stream cards, which gives the user a beat to review the - # registered streams and run Discovery if needed. - - def _bind_fonts(self) -> None: - """Assign per-widget fonts from the shared :class:`FontRegistry`. - - Safe to call with an empty registry — missing font tags become - no-ops, and the widget keeps whatever the global default font is. - """ - def bind(tag: str, font_tag: Optional[int]) -> None: - if font_tag is not None: - try: - dpg.bind_item_font(tag, font_tag) - except Exception: - pass - - # App title — display size - bind("app_title", self._fonts.ui_lg) - # Host id — monospace so varying-width ids don't jitter the header - bind("host_id_text", self._fonts.mono) - # State label — slightly larger than body for chip-like emphasis - bind("state_label", self._fonts.ui_md) - # Elapsed timer — monospace so digits don't shift sub-pixel - bind("elapsed_text", self._fonts.mono) - # Section titles - for tag in ( - "label_controls", - "label_clock", - "label_streams", - "label_health", - "label_output", - "label_wall_clock", - ): - bind(tag, self._fonts.ui_sm) - # Monospace clock values - bind("sync_point_text", self._fonts.mono) - bind("chirp_text", self._fonts.mono) - bind("wall_clock_text", self._fonts.mono) - bind("output_text", self._fonts.mono) - bind("tagline_text", self._fonts.ui_sm) - # Big countdown overlay — use the largest display font - bind("countdown_overlay", self._fonts.ui_lg) - - # ------------------------------------------------------------------ - # Sections - # ------------------------------------------------------------------ - - def _build_header(self) -> None: - """Top row: logo, host id, state chip, elapsed timer, discover button. - - Laid out as one horizontal group with a spring spacer that pushes - the Discover button to the far edge. Right-alignment is - approximate — DearPyGui doesn't have a real flexbox spacer, so we - compute the push width from :data:`theme.VIEWPORT_WIDTH`. The - primary window is pinned to the viewport so resize-driven drift - is acceptable for v1. - """ - with dpg.group(horizontal=True): - dpg.add_text("SyncField", tag="app_title") - dpg.add_spacer(width=18) - dpg.add_text( - self._session.host_id, - tag="host_id_text", - color=theme.TEXT_SECONDARY, - ) - dpg.add_spacer(width=24) - dpg.add_text("●", tag="state_dot", color=theme.STATE_IDLE) - dpg.add_spacer(width=6) - dpg.add_text( - "IDLE", - tag="state_label", - color=theme.TEXT_PRIMARY, - ) - dpg.add_spacer(width=18) - dpg.add_text( - "00:00.000", - tag="elapsed_text", - color=theme.TEXT_SECONDARY, - ) - # Spring spacer — sized to leave room for the button at the - # right edge of the window's content area. - dpg.add_spacer(width=_header_spring_width()) - dpg.add_button( - label="Discover devices", - tag="btn_discover", - width=180, - height=32, - callback=self._on_discover_click, - ) - - dpg.add_spacer(height=6) - dpg.add_text( - "Capture orchestration · live session view", - tag="tagline_text", - color=theme.TEXT_MUTED, - ) - - def _build_control_and_clock_row(self) -> None: - """Two side-by-side panels: controls + session clock. - - Control panel button stack (top to bottom): - - [ Connect ] [ Disconnect ] ← toggle pair; one active per state - [ Record ] [ Stop ] ← Record: CONNECTED, Stop: RECORDING - [ Cancel ] ← COUNTDOWN / RECORDING - - Connect and Disconnect are a toggle pair: only one is enabled - at a time. Connect opens devices (IDLE or STOPPED → CONNECTED); - Disconnect closes them (CONNECTED or STOPPED → IDLE). STOPPED - accepts both because the user may want to either tear the - session down for good or start a fresh recording on the same - rig without re-opening hardware. - """ - with dpg.group(horizontal=True): - # --- Control panel ---------------------------------------- - with dpg.child_window( - tag="control_panel", - width=260, - height=theme.CONTROL_PANEL_HEIGHT, - border=False, - no_scrollbar=True, - ): - dpg.add_text( - "CONTROLS", tag="label_controls", color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=10) - # Row 1 — Connect + Disconnect toggle pair (102 + 8 + 102 = 212) - with dpg.group(horizontal=True): - dpg.add_button( - label="Connect", - tag="btn_connect", - width=102, - height=34, - callback=self._on_connect_click, - ) - dpg.add_button( - label="Disconnect", - tag="btn_disconnect", - width=102, - height=34, - callback=self._on_disconnect_click, - ) - dpg.add_spacer(height=8) - # Row 2 — Record + Stop (112 + 8 + 92 = 212) - with dpg.group(horizontal=True): - dpg.add_button( - label="Record", - tag="btn_record", - width=112, - height=34, - callback=self._on_record_click, - ) - dpg.add_button( - label="Stop", - tag="btn_stop", - width=92, - height=34, - callback=self._on_stop_click, - ) - dpg.add_spacer(height=8) - # Row 3 — Cancel (aborts COUNTDOWN / best-effort Stop) - dpg.add_button( - label="Cancel", - tag="btn_cancel", - width=212, - height=28, - callback=self._on_cancel_click, - ) - - dpg.add_spacer(width=14) - - # --- Session clock + chirp panel -------------------------- - with dpg.child_window( - tag="clock_panel", - width=-1, - height=theme.CONTROL_PANEL_HEIGHT, - border=False, - no_scrollbar=True, - ): - # Header row — section label on the left, big countdown - # number on the right. The countdown is hidden by - # default and ``_update_clock_panel`` toggles it - # visible whenever the orchestrator is in the - # COUNTDOWN state. - with dpg.group(horizontal=True): - dpg.add_text( - "SESSION CLOCK", - tag="label_clock", - color=theme.TEXT_MUTED, - ) - dpg.add_spacer(width=12) - dpg.add_text( - "", - tag="countdown_overlay", - color=theme.ACCENT, - show=False, - ) - dpg.add_spacer(height=12) - - # Key / value strip — one row per field, fixed-width - # label column so values line up. A plain horizontal - # group with a single inline spacer avoids the extra - # vertical padding a nested fixed-width group would add. - def _kv_row(label_text: str, value_tag: str, default: str) -> None: - with dpg.group(horizontal=True): - dpg.add_text(label_text, color=theme.TEXT_SECONDARY) - # Trailing spacer width is computed from the - # longest label ("sync_point") so every value - # column aligns on the same x coordinate. - pad = _kv_label_pad(label_text) - if pad > 0: - dpg.add_spacer(width=pad) - dpg.add_text(default, tag=value_tag) - - _kv_row("sync_point", "sync_point_text", "—") - _kv_row("chirp", "chirp_text", "pending") - _kv_row("tone", "tone_text", "—") - - def _build_streams_section(self) -> None: - """Horizontal scrollable row of stream cards.""" - dpg.add_text( - "STREAMS", tag="label_streams", color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=8) - with dpg.child_window( - tag="streams_container", - width=-1, - height=theme.STREAMS_SECTION_HEIGHT, - border=False, - horizontal_scrollbar=True, - ): - with dpg.group(horizontal=True, tag=self._streams_row_tag): - pass # Cards added lazily in update() - - def _build_health_section(self) -> None: - """A table of recent health events.""" - dpg.add_text( - "HEALTH EVENTS", tag="label_health", color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=8) - with dpg.child_window( - width=-1, - height=theme.HEALTH_SECTION_HEIGHT, - border=False, - no_scrollbar=False, - ): - with dpg.table( - tag=self._health_table_tag, - header_row=True, - borders_innerH=True, - borders_outerH=False, - borders_innerV=False, - borders_outerV=False, - row_background=True, - scrollY=True, - height=theme.HEALTH_SECTION_HEIGHT - 24, - ): - dpg.add_table_column(label="Time", width_fixed=True, init_width_or_weight=90) - dpg.add_table_column(label="Stream", width_fixed=True, init_width_or_weight=140) - dpg.add_table_column(label="Kind", width_fixed=True, init_width_or_weight=120) - dpg.add_table_column(label="Detail") - - def _build_footer(self) -> None: - """Output path and wall clock, as a two-column key/value strip.""" - with dpg.group(horizontal=True): - dpg.add_text("output", tag="label_output", color=theme.TEXT_MUTED) - dpg.add_spacer(width=_kv_label_pad("output")) - dpg.add_text("—", tag="output_text", color=theme.TEXT_SECONDARY) - with dpg.group(horizontal=True): - dpg.add_text("wall clock", tag="label_wall_clock", color=theme.TEXT_MUTED) - dpg.add_spacer(width=_kv_label_pad("wall clock")) - dpg.add_text("—", tag="wall_clock_text", color=theme.TEXT_SECONDARY) - - # ------------------------------------------------------------------ - # Update (called every render frame) - # ------------------------------------------------------------------ - - def update(self, snapshot: SessionSnapshot) -> None: - """Sync every widget from the latest snapshot.""" - now_ns = time.monotonic_ns() - - self._update_header(snapshot) - self._update_clock_panel(snapshot) - self._update_controls(snapshot) - self._update_streams(snapshot, now_ns) - self._update_health(snapshot) - self._update_footer(snapshot) - - # Discovery modal has its own per-frame tick that only does work - # when the worker thread has produced new scan results or the - # elapsed-time display needs a bump. Cheap no-op when closed. - if self._discovery_modal is not None: - self._discovery_modal.tick() - - def _update_header(self, snapshot: SessionSnapshot) -> None: - dpg.configure_item("state_dot", color=theme.state_color(snapshot.state)) - dpg.set_value("state_label", state_label(snapshot.state)) - dpg.set_value("elapsed_text", format_elapsed(snapshot.elapsed_s)) - dpg.set_value("host_id_text", snapshot.host_id) - - def _update_clock_panel(self, snapshot: SessionSnapshot) -> None: - if snapshot.sync_point_monotonic_ns is not None: - sp_s = snapshot.sync_point_monotonic_ns / 1e9 - dpg.set_value("sync_point_text", f"{sp_s:,.3f}s (monotonic)") - else: - dpg.set_value("sync_point_text", "—") - - dpg.set_value( - "chirp_text", - format_chirp_pair(snapshot.chirp_start_ns, snapshot.chirp_stop_ns) - if snapshot.chirp_enabled - else "disabled (silent)", - ) - dpg.set_value( - "tone_text", - "400 → 2500 Hz, 500 ms" if snapshot.chirp_enabled else "—", - ) - - # Countdown overlay — visible only while the orchestrator is - # in the COUNTDOWN state. The big number is pulled from the - # worker-thread-populated ``_countdown_value`` under a lock. - if snapshot.state == "countdown": - with self._countdown_lock: - value = self._countdown_value - if value is not None: - dpg.set_value("countdown_overlay", f"· {value} ·") - dpg.configure_item("countdown_overlay", show=True) - else: - dpg.configure_item("countdown_overlay", show=False) - - def _update_controls(self, snapshot: SessionSnapshot) -> None: - """Enable/disable the session control buttons based on state. - - The 0.2 lifecycle has more states than the 0.1 one: - - * ``IDLE`` / ``CONNECTING`` — nothing is enabled. The viewer - is about to transition into CONNECTED via the auto-connect - worker thread; buttons stay greyed-out until it lands. - * ``CONNECTED`` — Record is primary-enabled. Discovery is - also allowed since no recording is in flight. - * ``COUNTDOWN`` / ``PREPARING`` — all buttons disabled except - Cancel (which aborts the countdown or the prepare phase). - * ``RECORDING`` — Stop is the primary action; Cancel also - triggers a stop (best-effort path). - * ``STOPPING`` / ``STOPPED`` — everything disabled while the - finalize path runs. After STOPPED, the auto-connect path - teardown has completed and the viewer is typically closing. - """ - state = snapshot.state - # Connect / Disconnect are a toggle pair — exactly one is - # enabled in any resting state (IDLE, CONNECTED, STOPPED), - # and both are disabled during transitions (CONNECTING, - # PREPARING, COUNTDOWN, RECORDING, STOPPING). STOPPED accepts - # BOTH: the user may want to tear the session down for good - # (Disconnect → IDLE) or start a fresh recording on the same - # rig without re-opening hardware (Connect → CONNECTED). - _set_enabled("btn_connect", state in ("idle", "stopped")) - _set_enabled("btn_disconnect", state in ("connected", "stopped")) - _set_enabled("btn_record", state == "connected") - _set_enabled("btn_stop", state == "recording") - _set_enabled( - "btn_cancel", - state in ("preparing", "countdown", "recording"), - ) - # Discovery is allowed before connecting (IDLE) so the user - # can add more streams to the session before live preview - # starts. ``scan_and_add`` requires IDLE anyway. - _set_enabled("btn_discover", state == "idle") - - def _update_streams(self, snapshot: SessionSnapshot, now_ns: int) -> None: - # Create cards for new streams. - for stream_id, stream_snap in snapshot.streams.items(): - if stream_id not in self._cards: - self._cards[stream_id] = StreamCard( - self._streams_row_tag, - stream_snap, - fonts=self._fonts, - on_remove=self._request_remove_stream, - ) - self._cards[stream_id].update( - stream_snap, now_ns, session_state=snapshot.state - ) - - # Cards for streams that were removed (either by the × button on - # the card itself, via code, or by a rollback). Pop the card from - # our dict and delete its DPG node so the row reflows. - removed = set(self._cards.keys()) - set(snapshot.streams.keys()) - for stream_id in removed: - card = self._cards.pop(stream_id) - try: - dpg.delete_item(card._card_tag) # noqa: SLF001 - except Exception: - pass - - def _request_remove_stream(self, stream_id: str) -> None: - """Drive ``SessionOrchestrator.remove`` on a worker thread. - - Called by a stream card's × button. The orchestrator's - ``remove()`` may call ``stream.disconnect()`` on a live - hardware handle, which can take tens of ms, so we never run it - on the DPG render thread. On success the next poller tick - notices that ``stream_id`` dropped out of the session and the - render loop deletes the DPG card node in - :meth:`_update_streams`. - - Failures are swallowed via :meth:`_safe_call` (logged at - ERROR) so a transient remove error never freezes the UI. - """ - threading.Thread( - target=self._safe_call, - args=(lambda: self._session.remove(stream_id),), - name=f"viewer-remove-{stream_id}", - daemon=True, - ).start() - - def _update_health(self, snapshot: SessionSnapshot) -> None: - """Rebuild the health table when the event set changes. - - We avoid rebuilding every frame — instead compare a cheap key - (tuple of event at_ns + kind) and only touch DPG when the log - actually changes. This keeps the table scroll position stable - and avoids rapid row churn. - """ - key = tuple((ev.at_ns, ev.kind, ev.stream_id) for ev in snapshot.health_log) - if key == self._last_health_keys: - return - self._last_health_keys = key - - # Drop existing rows. - for child in dpg.get_item_children(self._health_table_tag, 1) or []: - dpg.delete_item(child) - - # Re-populate newest first. - for ev in reversed(snapshot.health_log): - with dpg.table_row(parent=self._health_table_tag): - dpg.add_text(_format_time_short(ev.at_ns), color=theme.TEXT_SECONDARY) - dpg.add_text(ev.stream_id) - dpg.add_text( - ev.kind.upper(), - color=_health_kind_color(ev.kind), - ) - dpg.add_text(ev.detail or "") - - def _update_footer(self, snapshot: SessionSnapshot) -> None: - dpg.set_value("output_text", format_path_tail(snapshot.output_dir)) - if snapshot.sync_point_wall_clock_ns is not None: - t = time.localtime(snapshot.sync_point_wall_clock_ns / 1e9) - dpg.set_value( - "wall_clock_text", - time.strftime("%Y-%m-%d %H:%M:%S", t), - ) - else: - dpg.set_value("wall_clock_text", "—") - - # ------------------------------------------------------------------ - # Button callbacks — all delegate to a worker thread so the UI stays - # responsive while the SDK's start()/stop() run. - # ------------------------------------------------------------------ - - def _on_connect_click(self) -> None: - """Open devices on every registered stream (IDLE → CONNECTED). - - Dispatched onto a worker thread because device open is slow - for real hardware (several hundred ms per UVC device, longer - for BLE). After ``connect()`` returns the session is in - ``CONNECTED`` and each adapter's capture loop is publishing - live frames to its :attr:`latest_frame` / plot buffers — the - next render tick picks them up and the stream cards go live. - - Errors from ``connect()`` are logged by :meth:`_safe_call` - and leave the session back in ``IDLE`` so the user can - retry without closing the viewer. - """ - threading.Thread( - target=self._safe_call, - args=(self._session.connect,), - name="viewer-ctrl-connect", - daemon=True, - ).start() - - def _on_disconnect_click(self) -> None: - """Close devices on every connected stream (CONNECTED/STOPPED → IDLE). - - The mirror of :meth:`_on_connect_click`. Dispatched onto a - worker thread because ``disconnect()`` joins each adapter's - capture thread and releases OS-level device handles — a few - ms per stream on most hardware but up to a second per BLE - peripheral. On return the session is back in ``IDLE`` and - the stream cards fall quiet (``latest_frame`` stops - updating); the cards themselves stay in place so the user - can click Connect again. - """ - threading.Thread( - target=self._safe_call, - args=(self._session.disconnect,), - name="viewer-ctrl-disconnect", - daemon=True, - ).start() - - def _on_record_click(self) -> None: - """Trigger the full start flow: countdown → record → chirp. - - Dispatched onto a worker thread so the render loop stays - responsive while the countdown sleeps and the streams begin - writing. The orchestrator fires ``on_countdown_tick`` once - per remaining second; the callback stores the value on the - shared lock so the next render frame's ``_update_clock_panel`` - shows the big overlay. - """ - def _run_start() -> None: - try: - self._session.start( - countdown_s=self.COUNTDOWN_SECONDS, - on_countdown_tick=self._on_countdown_tick, - ) - except Exception: - import logging - - logging.getLogger(__name__).exception( - "Viewer session.start() failed" - ) - finally: - with self._countdown_lock: - self._countdown_value = None - - threading.Thread( - target=_run_start, - name="viewer-ctrl-start", - daemon=True, - ).start() - - def _on_countdown_tick(self, n: int) -> None: - """Called from the orchestrator's start worker, per tick. - - Runs on the worker thread, not the render thread — we just - store the value under a lock and let the next frame's - ``_update_clock_panel`` call render it. - """ - with self._countdown_lock: - self._countdown_value = n - - def _on_stop_click(self) -> None: - threading.Thread( - target=self._safe_call, - args=(self._session.stop,), - name="viewer-ctrl-stop", - daemon=True, - ).start() - - def _on_cancel_click(self) -> None: - """Cancel during COUNTDOWN or RECORDING. - - The SDK has no dedicated cancel primitive; calling - :meth:`SessionOrchestrator.stop` takes the best-effort path - for both cases. Cancelling during the countdown is - interpreted as "don't record this one" — because no stream - has received ``start_recording`` yet, the stop path is a - no-op on the streams and the chirp is skipped. - """ - state = self._session.state - if state is SessionState.RECORDING: - self._on_stop_click() - elif state is SessionState.COUNTDOWN: - # We can't interrupt the countdown sleep from here, but - # we can mark the user intent so when the countdown - # finishes the subsequent stop picks it up. For v1 this - # is a soft cancel: the recording starts briefly and - # then immediately stops. - def _cancel_after_start() -> None: - # Wait for the session to leave COUNTDOWN - import time as _t - - deadline = _t.monotonic() + 5.0 - while _t.monotonic() < deadline: - if self._session.state is SessionState.RECORDING: - try: - self._session.stop() - except Exception: - pass - return - _t.sleep(0.05) - - threading.Thread( - target=_cancel_after_start, - name="viewer-ctrl-cancel", - daemon=True, - ).start() - - def _on_discover_click(self) -> None: - """Open the discovery modal. - - Allowed from ``IDLE`` / ``CONNECTED`` / ``STOPPED``. Silently - ignored while a recording is in flight — ``scan_and_add`` - refuses anyway, and the button disables itself in those - states. - """ - if self._session.state not in ( - SessionState.IDLE, - SessionState.CONNECTED, - SessionState.STOPPED, - ): - return - if self._discovery_modal is not None: - self._discovery_modal.open() - - # ------------------------------------------------------------------ - # Session lifecycle teardown — called by the viewer app on close - # ------------------------------------------------------------------ - - def teardown_session(self) -> None: - """Return the session to ``IDLE`` when the viewer is closing. - - Called from :class:`ViewerApp.close`. Handles all the - intermediate states the session might be in when the user - closes the window mid-recording: - - * ``RECORDING`` → stop() then disconnect() - * ``CONNECTED`` / ``STOPPED`` → disconnect() - * everything else → best-effort, swallow errors - - Runs on the caller's thread (the viewer shutdown path) so the - orchestrator's lifecycle lock is respected. - """ - try: - if self._session.state is SessionState.RECORDING: - self._session.stop() - if self._session.state in (SessionState.CONNECTED, SessionState.STOPPED): - self._session.disconnect() - except Exception: - import logging - - logging.getLogger(__name__).exception( - "Viewer session teardown failed" - ) - - @staticmethod - def _safe_call(fn) -> None: - try: - fn() - except Exception: - import logging - - logging.getLogger(__name__).exception( - "Viewer session control call failed" - ) - - -# --------------------------------------------------------------------------- -# Small helpers -# --------------------------------------------------------------------------- - - -def _kv_label_pad(label: str) -> int: - """Return the pixel spacer width that aligns a value column for ``label``. - - The viewer's key/value strips (session clock, footer) use the longest - label in the column as the alignment anchor. This helper hard-codes - the pixel widths because DearPyGui doesn't expose a text-metrics API - before the viewport is shown — at build time the font has been - loaded but the renderer's atlas is not yet available. - - The numbers were measured at 15 px SF Pro (the viewer's default body - font) against a 90 px value column anchor. Fonts at other sizes drift - a few pixels but the layout reads correctly down to 13 px. - """ - # Keyed by label — one entry per text we render in a key/value row. - # Anchor column x = 100 px from the start of the row. - _ANCHOR_X = 100 - _LABEL_W = { - "sync_point": 76, - "chirp": 38, - "tone": 33, - "output": 48, - "wall clock": 74, - } - width = _LABEL_W.get(label, 0) - return max(8, _ANCHOR_X - width) - - -def _header_spring_width() -> int: - """Return a spacer width that roughly right-aligns the Discover button. - - DearPyGui has no flexbox spring spacer, so we compute the gap from - the fixed viewport width minus the estimated left-cluster width and - the button's declared width. This is intentionally rough — the - primary window is pinned to a 1200 px viewport in the default - layout, so the approximation is good enough for the common case and - the layout does not need to react to resize. - """ - # Window content width = viewport width - left/right window padding. - content_w = theme.VIEWPORT_WIDTH - 2 * theme.WINDOW_PADDING[0] - # Rough pixel width of the left cluster (title + host + state + timer - # + fixed spacers). Overestimates slightly so the button never gets - # clipped when the timer grows to ``99:59.999``. - left_cluster_w = 430 - # Discover button declared width. - button_w = 180 - spring = content_w - left_cluster_w - button_w - return max(40, spring) - - -def _set_enabled(tag: str, enabled: bool) -> None: - try: - if enabled: - dpg.enable_item(tag) - else: - dpg.disable_item(tag) - except Exception: - pass - - -def _format_time_short(at_ns: int) -> str: - """Format a monotonic_ns timestamp as ``MM:SS.mmm`` for the table.""" - s = at_ns / 1e9 - minutes = int(s // 60) - remainder = s - minutes * 60 - return f"{minutes:02d}:{remainder:06.3f}" - - -def _health_kind_color(kind: str): - return { - "heartbeat": theme.TEXT_MUTED, - "drop": theme.WARNING, - "reconnect": theme.INFO, - "warning": theme.WARNING, - "error": theme.DANGER, - }.get(kind, theme.TEXT_SECONDARY) diff --git a/src/syncfield/viewer/widgets/stream_card.py b/src/syncfield/viewer/widgets/stream_card.py deleted file mode 100644 index cd55a62..0000000 --- a/src/syncfield/viewer/widgets/stream_card.py +++ /dev/null @@ -1,441 +0,0 @@ -"""Per-stream card widgets. - -Each stream is rendered as a 260 x 300 card. The card's body varies by -stream kind: - -- **video** → live GPU texture fed from ``stream.latest_frame`` -- **sensor** → a small line plot of numeric channels (up to 6 series) -- everything else → a minimal stats block - -The card shell, header, and stats row are identical across variants so the -viewer looks consistent regardless of what kind of data the user registers. -""" - -from __future__ import annotations - -import time -from typing import Callable, Dict, List, Optional - -import dearpygui.dearpygui as dpg -import numpy as np - -from syncfield.viewer import theme -from syncfield.viewer.fonts import FontRegistry -from syncfield.viewer.state import StreamSnapshot -from syncfield.viewer.widgets.formatting import ( - format_count, - format_hz, - format_ns_ago, -) - -#: Session states in which a stream may be removed from the live session. -#: Kept in sync with :meth:`syncfield.orchestrator.SessionOrchestrator.remove` -#: — any state outside this set disables the remove button on every card. -_REMOVABLE_STATES = frozenset({"idle", "connected", "stopped"}) - - -# Texture resolution for video previews. We keep this fixed so all cards -# share one preset; real frames are resized (with aspect-ratio letterboxing) -# into this buffer before upload. -PREVIEW_W = 260 -PREVIEW_H = theme.VIDEO_THUMBNAIL_HEIGHT - - -class StreamCard: - """Owns the DearPyGui nodes for one stream card. - - One instance per registered stream. Construction happens lazily the - first time the layout sees a given stream id in a snapshot, so dynamic - stream additions Just Work. - """ - - def __init__( - self, - parent_tag: str, - snapshot: StreamSnapshot, - *, - fonts: Optional[FontRegistry] = None, - on_remove: Optional[Callable[[str], None]] = None, - ) -> None: - self._stream_id = snapshot.id - self._kind = snapshot.kind - self._fonts = fonts or FontRegistry() - self._on_remove = on_remove - self._card_tag = f"card::{snapshot.id}" - self._title_tag = f"card_title::{snapshot.id}" - self._state_dot_tag = f"card_dot::{snapshot.id}" - self._frame_count_tag = f"card_frames::{snapshot.id}" - self._hz_tag = f"card_hz::{snapshot.id}" - self._last_sample_tag = f"card_last::{snapshot.id}" - self._capability_tag = f"card_cap::{snapshot.id}" - self._remove_button_tag = f"card_remove::{snapshot.id}" - self._last_remove_enabled: Optional[bool] = None - - # Variant-specific tags (populated by the matching _build_body method) - self._texture_tag: Optional[str] = None - self._plot_tag: Optional[str] = None - self._plot_x_axis_tag: Optional[str] = None - self._plot_y_axis_tag: Optional[str] = None - self._series_tags: Dict[str, str] = {} - - self._build(parent_tag, snapshot) - - # ------------------------------------------------------------------ - # Construction - # ------------------------------------------------------------------ - - def _build(self, parent_tag: str, snapshot: StreamSnapshot) -> None: - with dpg.child_window( - tag=self._card_tag, - parent=parent_tag, - width=theme.CARD_WIDTH, - height=theme.CARD_HEIGHT, - border=True, - no_scrollbar=True, - ): - dpg.bind_item_theme(self._card_tag, theme.build_card_theme()) - - # --- Header row: stream id + status dot + remove button --- - # - # We right-pin the ``×`` button by pre-computing the spacer - # width from the card width. DPG has no flexbox; a fixed - # spacer is the simplest way to keep the remove button - # anchored to the card's top-right corner regardless of the - # stream id's length. - _REMOVE_BUTTON_W = 22 - _CONTENT_PADDING = 14 # DPG child_window inner padding - _HEADER_GAP = 6 - with dpg.group(horizontal=True): - dpg.add_text(snapshot.id, tag=self._title_tag) - dpg.add_spacer(width=_HEADER_GAP) - dpg.add_text( - "●", - tag=self._state_dot_tag, - color=theme.SUCCESS, - ) - # Push the × to the right edge. We assume the id fits - # in the default header width; longer ids bleed into - # the spacer first before clipping the button. - spacer_w = max( - 4, - theme.CARD_WIDTH - - _CONTENT_PADDING * 2 - - _REMOVE_BUTTON_W - - 60, # rough width budget for id text + dot - ) - dpg.add_spacer(width=spacer_w) - dpg.add_button( - label="×", - tag=self._remove_button_tag, - width=_REMOVE_BUTTON_W, - height=_REMOVE_BUTTON_W, - callback=self._on_remove_click, - ) - dpg.bind_item_theme( - self._remove_button_tag, - theme.build_ghost_button_theme(), - ) - dpg.add_text( - _capability_label(snapshot), - tag=self._capability_tag, - color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=8) - - # Bind card title to the emphasized font once the tag exists. - if self._fonts.ui_md is not None: - try: - dpg.bind_item_font(self._title_tag, self._fonts.ui_md) - except Exception: - pass - if self._fonts.ui_sm is not None: - try: - dpg.bind_item_font(self._capability_tag, self._fonts.ui_sm) - except Exception: - pass - - # --- Body: variant-specific -------------------------------- - if self._kind == "video": - self._build_video_body(snapshot) - elif self._kind in ("sensor", "audio"): - self._build_plot_body(snapshot) - else: - self._build_stats_body() - - dpg.add_spacer(height=8) - - # --- Footer stats row ------------------------------------- - with dpg.group(horizontal=True): - dpg.add_text( - format_count(snapshot.frame_count), - tag=self._frame_count_tag, - color=theme.TEXT_PRIMARY, - ) - dpg.add_spacer(width=4) - dpg.add_text("frames", color=theme.TEXT_SECONDARY) - dpg.add_spacer(width=14) - dpg.add_text( - format_hz(snapshot.effective_hz), - tag=self._hz_tag, - color=theme.TEXT_SECONDARY, - ) - dpg.add_text( - "last sample: —", - tag=self._last_sample_tag, - color=theme.TEXT_MUTED, - ) - - # Stats row uses monospace so numeric counters don't jitter. - if self._fonts.mono is not None: - try: - dpg.bind_item_font(self._frame_count_tag, self._fonts.mono) - dpg.bind_item_font(self._hz_tag, self._fonts.mono) - except Exception: - pass - if self._fonts.ui_sm is not None: - try: - dpg.bind_item_font(self._last_sample_tag, self._fonts.ui_sm) - except Exception: - pass - - def _build_video_body(self, snapshot: StreamSnapshot) -> None: - """A raw-texture image that the render loop updates in place.""" - self._texture_tag = f"texture::{snapshot.id}" - initial = np.zeros(PREVIEW_W * PREVIEW_H * 4, dtype=np.float32) - with dpg.texture_registry(show=False): - dpg.add_raw_texture( - width=PREVIEW_W, - height=PREVIEW_H, - default_value=initial, - format=dpg.mvFormat_Float_rgba, - tag=self._texture_tag, - ) - dpg.add_image( - self._texture_tag, - width=PREVIEW_W - 28, # account for card padding - height=PREVIEW_H, - ) - - def _build_plot_body(self, snapshot: StreamSnapshot) -> None: - """A line plot for numeric sensor channels.""" - self._plot_tag = f"plot::{snapshot.id}" - self._plot_x_axis_tag = f"plot_x::{snapshot.id}" - self._plot_y_axis_tag = f"plot_y::{snapshot.id}" - with dpg.plot( - tag=self._plot_tag, - height=theme.PLOT_HEIGHT, - width=-1, - no_title=True, - no_menus=True, - no_mouse_pos=True, - ): - dpg.add_plot_axis( - dpg.mvXAxis, tag=self._plot_x_axis_tag, no_tick_labels=True - ) - dpg.add_plot_axis( - dpg.mvYAxis, tag=self._plot_y_axis_tag, no_tick_labels=True - ) - - def _build_stats_body(self) -> None: - """Fallback body — a discreet placeholder for custom/opaque streams.""" - with dpg.group(): - dpg.add_text( - "no live preview", - color=theme.TEXT_MUTED, - ) - dpg.add_spacer(height=theme.VIDEO_THUMBNAIL_HEIGHT - 24) - - # ------------------------------------------------------------------ - # Update — called every render frame - # ------------------------------------------------------------------ - - def update( - self, - snapshot: StreamSnapshot, - now_ns: int, - session_state: Optional[str] = None, - ) -> None: - """Sync this card to the newest snapshot. - - Args: - snapshot: Latest per-stream data. - now_ns: Monotonic ns for staleness comparisons. - session_state: Lowercase session state string (see - :attr:`SessionSnapshot.state`). Used to enable or - disable the remove button — removal is only legal in - ``idle`` / ``connected`` / ``stopped``. ``None`` means - "don't touch the button" (the initial state set at - build time). - """ - dpg.set_value(self._frame_count_tag, format_count(snapshot.frame_count)) - dpg.set_value(self._hz_tag, format_hz(snapshot.effective_hz)) - dpg.set_value( - self._last_sample_tag, - f"last sample: {format_ns_ago(snapshot.last_sample_at_ns, now_ns)}", - ) - dpg.configure_item( - self._state_dot_tag, - color=_dot_color(snapshot, now_ns), - ) - - if session_state is not None: - self._sync_remove_button_enabled(session_state) - - if self._kind == "video": - self._update_video_texture(snapshot) - elif self._kind in ("sensor", "audio"): - self._update_plot(snapshot) - - def _sync_remove_button_enabled(self, session_state: str) -> None: - """Enable the remove button only in removal-safe session states. - - Caches the last enabled/disabled state so DPG doesn't get a - fresh ``configure_item`` call every render frame — the check - is a cheap string membership test + equality. - """ - should_enable = session_state in _REMOVABLE_STATES - if should_enable == self._last_remove_enabled: - return - try: - if should_enable: - dpg.enable_item(self._remove_button_tag) - else: - dpg.disable_item(self._remove_button_tag) - except Exception: # pragma: no cover — DPG not yet ready at first tick - return - self._last_remove_enabled = should_enable - - def _on_remove_click(self, sender=None, app_data=None, user_data=None) -> None: - """Fire the injected remove callback with this card's stream id. - - The callback (owned by :class:`ViewerLayout`) is expected to - call :meth:`SessionOrchestrator.remove` on a worker thread so - the UI thread doesn't block on device teardown. - """ - if self._on_remove is not None: - self._on_remove(self._stream_id) - - def _update_video_texture(self, snapshot: StreamSnapshot) -> None: - """Upload the latest frame to the GPU texture, with letterboxing.""" - if self._texture_tag is None: - return - frame = snapshot.latest_frame - if frame is None: - return - try: - rgba = _fit_to_preview_rgba(frame, PREVIEW_W, PREVIEW_H) - except Exception: - # A single frame with an unexpected shape should never tear - # the whole card down. - return - dpg.set_value(self._texture_tag, rgba) - - def _update_plot(self, snapshot: StreamSnapshot) -> None: - """Update or create per-channel line series.""" - if self._plot_tag is None or self._plot_y_axis_tag is None: - return - for index, (channel_name, (xs, ys)) in enumerate(snapshot.plot_points.items()): - series_tag = self._series_tags.get(channel_name) - if series_tag is None: - series_tag = f"series::{self._stream_id}::{channel_name}" - dpg.add_line_series( - list(xs), - list(ys), - label=channel_name, - parent=self._plot_y_axis_tag, - tag=series_tag, - ) - # Apply a per-series color so multi-channel plots stay legible. - with dpg.theme() as series_theme: - with dpg.theme_component(dpg.mvLineSeries): - dpg.add_theme_color( - dpg.mvPlotCol_Line, - theme.series_color(index), - category=dpg.mvThemeCat_Plots, - ) - dpg.add_theme_style( - dpg.mvPlotStyleVar_LineWeight, - 1.8, - category=dpg.mvThemeCat_Plots, - ) - dpg.bind_item_theme(series_tag, series_theme) - self._series_tags[channel_name] = series_tag - else: - dpg.set_value(series_tag, [list(xs), list(ys)]) - - if snapshot.plot_points: - dpg.fit_axis_data(self._plot_x_axis_tag) # type: ignore[arg-type] - dpg.fit_axis_data(self._plot_y_axis_tag) - - -# --------------------------------------------------------------------------- -# Free helpers -# --------------------------------------------------------------------------- - - -def _capability_label(snapshot: StreamSnapshot) -> str: - tags: List[str] = [snapshot.kind] - if snapshot.provides_audio_track: - tags.append("audio") - if snapshot.produces_file: - tags.append("file") - return " · ".join(tags) - - -def _dot_color(snapshot: StreamSnapshot, now_ns: int): - """Pick a dot color based on freshness and health counts.""" - if snapshot.health_count > 0: - return theme.WARNING - last = snapshot.last_sample_at_ns - if last is None: - return theme.TEXT_MUTED - if now_ns - last > 1_500_000_000: # 1.5s stale - return theme.WARNING - return theme.SUCCESS - - -def _fit_to_preview_rgba(frame: np.ndarray, target_w: int, target_h: int) -> np.ndarray: - """Convert an arbitrary BGR/RGB frame into a letterboxed RGBA float32 buffer. - - Uses simple NumPy slicing instead of cv2 so the viewer doesn't require - opencv-python. That lets users install ``syncfield[viewer]`` without - also needing the ``uvc`` extra. - """ - if frame.ndim != 3 or frame.shape[2] < 3: - raise ValueError(f"unexpected frame shape {frame.shape}") - - src_h, src_w = frame.shape[0], frame.shape[1] - if src_h == 0 or src_w == 0: - raise ValueError("empty frame") - - # Fit-within (letterbox) into target while preserving aspect ratio. - scale = min(target_w / src_w, target_h / src_h) - new_w = max(1, int(src_w * scale)) - new_h = max(1, int(src_h * scale)) - - # Nearest-neighbor resize — cheap, no external deps. The viewer only - # needs a thumbnail; interpolation quality isn't critical. - ys = (np.linspace(0, src_h - 1, new_h)).astype(np.int32) - xs = (np.linspace(0, src_w - 1, new_w)).astype(np.int32) - resized = frame[ys][:, xs] - - # OAK frames come out as BGR (DepthAI) and so do UVC frames (OpenCV). - # Swap to RGB so the preview colors match reality. - if resized.shape[2] >= 3: - resized = resized[:, :, [2, 1, 0]] - - # Letterbox into the full target buffer. - canvas = np.full( - (target_h, target_w, 3), - fill_value=240, # near-white letterbox matches the light theme - dtype=np.uint8, - ) - y_off = (target_h - new_h) // 2 - x_off = (target_w - new_w) // 2 - canvas[y_off : y_off + new_h, x_off : x_off + new_w] = resized[:, :, :3] - - # Convert to RGBA float32 in [0, 1] — DPG mvFormat_Float_rgba. - rgba = np.empty((target_h, target_w, 4), dtype=np.float32) - rgba[:, :, :3] = canvas.astype(np.float32) / 255.0 - rgba[:, :, 3] = 1.0 - return rgba.flatten() diff --git a/tests/unit/viewer/test_formatting.py b/tests/unit/viewer/test_formatting.py index 7f5b98a..695f08f 100644 --- a/tests/unit/viewer/test_formatting.py +++ b/tests/unit/viewer/test_formatting.py @@ -1,16 +1,12 @@ """Unit tests for the viewer's formatting helpers. -These tests exercise pure-Python logic that has no DearPyGui dependency, +These tests exercise pure-Python logic with no external dependencies, so they run in any environment. """ from __future__ import annotations -import pytest - -pytest.importorskip("dearpygui.dearpygui") - -from syncfield.viewer.widgets.formatting import ( +from syncfield.viewer.formatting import ( format_chirp_pair, format_count, format_elapsed, diff --git a/tests/unit/viewer/test_poller.py b/tests/unit/viewer/test_poller.py index a96943e..1ddb33f 100644 --- a/tests/unit/viewer/test_poller.py +++ b/tests/unit/viewer/test_poller.py @@ -9,10 +9,6 @@ import time -import pytest - -pytest.importorskip("dearpygui.dearpygui") - import syncfield as sf from syncfield.testing import FakeStream from syncfield.types import HealthEventKind diff --git a/tests/unit/viewer/test_state.py b/tests/unit/viewer/test_state.py index 356109b..029b2cb 100644 --- a/tests/unit/viewer/test_state.py +++ b/tests/unit/viewer/test_state.py @@ -9,10 +9,6 @@ import math -import pytest - -pytest.importorskip("dearpygui.dearpygui") - from syncfield.viewer.state import HealthEntry, StreamStatsBuffer From 4b3c85b72eeccc8df78bff6731caeb6ca9c1e4d5 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 19:47:28 -0700 Subject: [PATCH 03/42] fix(viewer): enlarge stream cards, sidebar health, fix countdown duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stream cards: responsive grid layout (min 320px, fills available width), aspect-ratio video previews instead of fixed 146px height - Health events: moved to compact sidebar (272px) instead of full-width section below streams - Countdown audio: removed browser Web Audio playback entirely — the recording PC handles all audio (ticks + chirps) via sounddevice. Server now delegates to session.start(on_countdown_tick=...) so the session's native countdown drives both audio and visual overlay, eliminating the double 3-2-1 and double chirp Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/viewer/frontend/src/App.tsx | 107 +++++++----------- .../frontend/src/components/health-table.tsx | 65 +++++------ .../frontend/src/components/sensor-chart.tsx | 4 +- .../frontend/src/components/stream-card.tsx | 30 ++--- .../frontend/src/components/video-preview.tsx | 4 +- src/syncfield/viewer/server.py | 30 +++-- 6 files changed, 111 insertions(+), 129 deletions(-) diff --git a/src/syncfield/viewer/frontend/src/App.tsx b/src/syncfield/viewer/frontend/src/App.tsx index 7381e19..478e491 100644 --- a/src/syncfield/viewer/frontend/src/App.tsx +++ b/src/syncfield/viewer/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useSession } from "@/hooks/use-session"; import { useDiscovery } from "@/hooks/use-discovery"; import { Header } from "@/components/header"; @@ -10,32 +10,12 @@ import { CountdownOverlay } from "@/components/countdown-overlay"; import { DiscoveryModal } from "@/components/discovery-modal"; import { Footer } from "@/components/footer"; -// --------------------------------------------------------------------------- -// Audio feedback — countdown tick (C6, 1047 Hz, 100 ms) -// --------------------------------------------------------------------------- - -function playCountdownTick() { - try { - const ctx = new AudioContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sine"; - osc.frequency.value = 1047; // C6 - gain.gain.value = 0.3; - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1); - osc.stop(ctx.currentTime + 0.1); - // Clean up after playback - setTimeout(() => ctx.close(), 200); - } catch { - // Audio not available — silent fallback - } -} - // --------------------------------------------------------------------------- // App +// +// Audio feedback (countdown ticks + chirps) is handled entirely by the +// recording PC via sounddevice/PortAudio. The browser only shows the +// visual countdown overlay — no Web Audio playback. // --------------------------------------------------------------------------- export function App() { @@ -43,15 +23,6 @@ export function App() { const discovery = useDiscovery(); const [discoveryOpen, setDiscoveryOpen] = useState(false); - // Play tick sound on countdown events - const lastCountdown = useRef(null); - useEffect(() => { - if (countdown !== null && countdown !== lastCountdown.current) { - playCountdownTick(); - } - lastCountdown.current = countdown; - }, [countdown]); - // Update page title with session state useEffect(() => { const state = snapshot?.state ?? "idle"; @@ -82,43 +53,43 @@ export function App() { - {/* Streams section */} -
- {streamList.length > 0 ? ( -
- {/* Stream cards — horizontal scroll */} -
-
- {streamList.map((stream) => ( - - ))} -
+ {/* Main content area */} +
+ {/* Streams section (main) */} +
+ {streamList.length > 0 ? ( +
+ {streamList.map((stream) => ( + + ))} +
+ ) : ( +
+

No streams registered

+
+ )} +
- {/* Health table */} -
-

- Health Events -

-
- -
+ {/* Health events sidebar */} + {streamList.length > 0 && ( +
+
+

Health Events

+
+
+
-
- ) : ( -
-

No streams registered

-
)}
diff --git a/src/syncfield/viewer/frontend/src/components/health-table.tsx b/src/syncfield/viewer/frontend/src/components/health-table.tsx index 18751ea..c32f4f0 100644 --- a/src/syncfield/viewer/frontend/src/components/health-table.tsx +++ b/src/syncfield/viewer/frontend/src/components/health-table.tsx @@ -1,4 +1,5 @@ import type { HealthEntry } from "@/lib/types"; +import { cn } from "@/lib/utils"; interface HealthTableProps { entries: HealthEntry[]; @@ -13,52 +14,46 @@ const KIND_COLORS: Record = { }; /** - * Health event timeline — newest-first table of stream health events. + * Compact health event list for the sidebar — newest-first. */ export function HealthTable({ entries }: HealthTableProps) { if (entries.length === 0) { return ( -
+
No health events
); } - // Display newest first const sorted = [...entries].reverse(); return ( -
- - - - - - - - - - - {sorted.map((entry, i) => ( - - - - - - - ))} - -
TimeStreamKindDetail
- {entry.at_s.toFixed(3)}s - - {entry.stream_id} - - - {entry.kind} - - - {entry.detail ?? "—"} -
-
+
    + {sorted.map((entry, i) => ( +
  • +
    + + {entry.kind} + + + {entry.at_s.toFixed(1)}s + +
    +
    + {entry.stream_id} +
    + {entry.detail && ( +
    + {entry.detail} +
    + )} +
  • + ))} +
); } diff --git a/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx b/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx index cfb3492..770db89 100644 --- a/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx +++ b/src/syncfield/viewer/frontend/src/components/sensor-chart.tsx @@ -32,7 +32,7 @@ export function SensorChart({ streamId }: SensorChartProps) { if (channelNames.length === 0) { return ( -
+
{isConnected ? "Waiting for data…" : "Connecting…"}
); @@ -53,7 +53,7 @@ export function SensorChart({ streamId }: SensorChartProps) { PADDING.top + plotH - ((v - yMin) / yRange) * plotH; return ( -
+
+
{/* Card header */} -
+
0 ? "bg-success" : "bg-muted", )} /> - + {stream.id}
{canRemove && ( diff --git a/src/syncfield/viewer/frontend/src/components/logo.tsx b/src/syncfield/viewer/frontend/src/components/logo.tsx new file mode 100644 index 0000000..7b5a6d9 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/logo.tsx @@ -0,0 +1,27 @@ +export function Logo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/syncfield/viewer/frontend/src/components/stream-card.tsx b/src/syncfield/viewer/frontend/src/components/stream-card.tsx index 2d05f81..6aba247 100644 --- a/src/syncfield/viewer/frontend/src/components/stream-card.tsx +++ b/src/syncfield/viewer/frontend/src/components/stream-card.tsx @@ -1,5 +1,5 @@ import type { StreamSnapshot } from "@/lib/types"; -import { formatCount, formatHz, formatMsAgo } from "@/lib/format"; +import { formatCount, formatHz } from "@/lib/format"; import { cn } from "@/lib/utils"; import { VideoPreview } from "./video-preview"; import { SensorChart } from "./sensor-chart"; @@ -75,8 +75,6 @@ export function StreamCard({ stream, canRemove, onRemove }: StreamCardProps) { {formatCount(stream.frame_count)} {formatHz(stream.effective_hz)} - - {formatMsAgo(stream.last_sample_ms_ago)} {stream.health_count > 0 && ( <> diff --git a/src/syncfield/viewer/frontend/src/lib/types.ts b/src/syncfield/viewer/frontend/src/lib/types.ts index b4ea3c6..f993557 100644 --- a/src/syncfield/viewer/frontend/src/lib/types.ts +++ b/src/syncfield/viewer/frontend/src/lib/types.ts @@ -79,6 +79,9 @@ export interface DiscoveredDevice { name: string; adapter: string; kind: string; + description: string; + in_use: boolean; + warnings: string[]; } // --------------------------------------------------------------------------- diff --git a/src/syncfield/viewer/server.py b/src/syncfield/viewer/server.py index 31b3de1..5b5f46b 100644 --- a/src/syncfield/viewer/server.py +++ b/src/syncfield/viewer/server.py @@ -208,18 +208,29 @@ async def api_status() -> JSONResponse: async def api_discover() -> JSONResponse: """Trigger device discovery scan.""" try: - from syncfield.discovery import discover_devices - devices = await asyncio.to_thread(discover_devices) + import syncfield.adapters # noqa: F401 — register discoverers + from syncfield.discovery import scan + + report = await asyncio.to_thread(scan, use_cache=False) result = [ { - "id": d.id, - "name": d.name, - "adapter": d.adapter, + "id": d.device_id, + "name": d.display_name, + "adapter": d.adapter_type, "kind": d.kind, + "description": d.description, + "in_use": d.in_use, + "warnings": list(d.warnings), } - for d in devices + for d in report.devices ] - return JSONResponse({"devices": result}) + errors = ( + list(report.errors.values()) if report.errors else None + ) + return JSONResponse({ + "devices": result, + "error": errors[0] if errors else None, + }) except ImportError: return JSONResponse({"devices": [], "error": "discovery not available"}) except Exception as exc: @@ -230,19 +241,29 @@ async def api_discover() -> JSONResponse: @app.post("/api/streams/{stream_id}") async def api_add_stream(stream_id: str) -> JSONResponse: - """Add a discovered device to the session.""" + """Add a discovered device to the session by device_id.""" try: - from syncfield.discovery import discover_devices, build_stream - devices = await asyncio.to_thread(discover_devices) - device = next((d for d in devices if d.id == stream_id), None) + import syncfield.adapters # noqa: F401 + from syncfield.discovery import scan + from syncfield.discovery._id_gen import make_stream_id + + report = await asyncio.to_thread(scan) + device = next( + (d for d in report.devices if d.device_id == stream_id), + None, + ) if device is None: return JSONResponse( {"error": f"Device {stream_id!r} not found"}, status_code=404, ) - stream = build_stream(device) + sid = make_stream_id(device.display_name) + kwargs: Dict[str, Any] = {"id": sid} + if device.accepts_output_dir: + kwargs["output_dir"] = self._session.output_dir + stream = device.construct(**kwargs) self._session.add(stream) - return JSONResponse({"status": "added", "id": stream_id}) + return JSONResponse({"status": "added", "id": sid}) except Exception as exc: logger.exception("Failed to add stream") return JSONResponse({"error": str(exc)}, status_code=500) From 485f626ae22a4918308a2c823567d7ec122d5f55 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 20:26:09 -0700 Subject: [PATCH 05/42] fix(viewer): user-friendly state labels in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw state strings with friendly labels: - idle/connected/stopped → "Ready" - recording → "Recording" - stopping → "Saving…" - countdown/preparing → "Starting…" Remove redundant State display from session info bar. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../viewer/frontend/src/components/header.tsx | 42 ++++++++++++++----- .../frontend/src/components/session-clock.tsx | 8 +--- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/syncfield/viewer/frontend/src/components/header.tsx b/src/syncfield/viewer/frontend/src/components/header.tsx index 32d7f87..b4b0172 100644 --- a/src/syncfield/viewer/frontend/src/components/header.tsx +++ b/src/syncfield/viewer/frontend/src/components/header.tsx @@ -1,5 +1,5 @@ import type { SessionSnapshot } from "@/lib/types"; -import { formatElapsed, stateLabel } from "@/lib/format"; +import { formatElapsed } from "@/lib/format"; import { cn } from "@/lib/utils"; import { Logo } from "./logo"; @@ -8,17 +8,39 @@ interface HeaderProps { onDiscoverClick: () => void; } -const STATE_COLORS: Record = { - idle: "bg-muted", - connecting: "bg-warning", - connected: "bg-success", - starting: "bg-warning", +/** Dot color by session state. */ +const STATE_DOT: Record = { recording: "bg-recording animate-pulse-recording", - stopping: "bg-warning", - stopped: "bg-muted", + countdown: "bg-warning", + preparing: "bg-warning", + connecting: "bg-warning", disconnecting: "bg-warning", + stopping: "bg-warning", }; +/** User-friendly labels — idle-like states show "Ready". */ +function friendlyState(state: string): string { + switch (state) { + case "idle": + case "connected": + case "stopped": + return "Ready"; + case "recording": + return "Recording"; + case "countdown": + case "preparing": + return "Starting…"; + case "connecting": + return "Connecting…"; + case "stopping": + return "Saving…"; + case "disconnecting": + return "Disconnecting…"; + default: + return state; + } +} + export function Header({ snapshot, onDiscoverClick }: HeaderProps) { const state = snapshot?.state ?? "idle"; const hostId = snapshot?.host_id ?? "—"; @@ -47,7 +69,7 @@ export function Header({ snapshot, onDiscoverClick }: HeaderProps) { - {stateLabel(state)} + {friendlyState(state)}
diff --git a/src/syncfield/viewer/frontend/src/components/session-clock.tsx b/src/syncfield/viewer/frontend/src/components/session-clock.tsx index baf1565..329e861 100644 --- a/src/syncfield/viewer/frontend/src/components/session-clock.tsx +++ b/src/syncfield/viewer/frontend/src/components/session-clock.tsx @@ -6,7 +6,7 @@ interface SessionClockProps { } /** - * Session clock panel — shows sync point, chirp status, and tone config. + * Session info bar — shows chirp status and stream count. */ export function SessionClock({ snapshot }: SessionClockProps) { if (!snapshot) return null; @@ -37,12 +37,6 @@ export function SessionClock({ snapshot }: SessionClockProps) { {Object.keys(snapshot.streams).length}
- - {/* Session state detail */} -
- State - {snapshot.state} -
); } From 1c9f09cfc0952c0d49f1174d425addd08936a180 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 20:36:27 -0700 Subject: [PATCH 06/42] feat(orchestrator): auto-create episode directories, simplify examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionOrchestrator now auto-creates a timestamped episode subdirectory (ep_{YYYYMMDD}_{HHMMSS}_{hex}) inside the provided output_dir. Callers just pass the data root — no more boilerplate for episode dir creation. session.output_dir returns the episode-specific path so streams can reference it directly: session.add(UVCWebcamStream("cam", 0, session.output_dir)) Examples simplified from ~40 lines to ~20 lines each: - Removed argparse boilerplate for device indices (inline constants) - Removed manual episode dir creation (SDK handles it) - Removed datetime/secrets imports Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++ examples/full_rig/record.py | 49 +++++-------------- .../generic_sensor_demo/polling_serial.py | 5 +- examples/generic_sensor_demo/push_async.py | 5 +- examples/iphone_mac_webcam/record.py | 35 +++---------- examples/mac_iphone_dual_oak/record.py | 45 ++++------------- src/syncfield/orchestrator.py | 21 +++++++- tests/integration/test_generic_sensor_e2e.py | 6 +-- tests/integration/test_round_trip.py | 14 +++--- tests/unit/test_orchestrator.py | 33 +++++++------ 10 files changed, 82 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index 45e5fde..7720866 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ htmlcov/ # Worktrees .worktrees/ +# Example output data +examples/*/output/ + # Viewer frontend src/syncfield/viewer/static/ src/syncfield/viewer/frontend/node_modules/ diff --git a/examples/full_rig/record.py b/examples/full_rig/record.py index 58bb521..2ff519a 100644 --- a/examples/full_rig/record.py +++ b/examples/full_rig/record.py @@ -4,47 +4,22 @@ python record.py """ -import argparse from pathlib import Path import syncfield as sf import syncfield.viewer -from syncfield.adapters import ( - OakCameraStream, - OgloTactileStream, - UVCWebcamStream, -) - -DEFAULT_OAK_LITE_SERIAL = "19443010813AF02C00" -DEFAULT_OAK_D_SERIAL = "1944301071781C1300" -DEFAULT_OGLO_ADDRESS = "C1718989-5A77-F3EB-B00A-01A758D99D54" - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--webcam-index", type=int, default=0) - parser.add_argument("--iphone-index", type=int, default=1) - parser.add_argument("--oak-lite", default=DEFAULT_OAK_LITE_SERIAL) - parser.add_argument("--oak-d", default=DEFAULT_OAK_D_SERIAL) - parser.add_argument("--oglo-address", default=DEFAULT_OGLO_ADDRESS) - parser.add_argument("--oglo-hand", default="right", choices=("left", "right")) - parser.add_argument("--output-dir", type=Path, default=Path("./output")) - args = parser.parse_args() +from syncfield.adapters import OakCameraStream, OgloTactileStream, UVCWebcamStream - args.output_dir.mkdir(parents=True, exist_ok=True) - - session = sf.SessionOrchestrator( - host_id="mac_studio", - output_dir=args.output_dir, - ) - session.add(UVCWebcamStream("mac_webcam", args.webcam_index, args.output_dir)) - session.add(UVCWebcamStream("iphone", args.iphone_index, args.output_dir)) - session.add(OakCameraStream("oak_lite", args.output_dir, device_id=args.oak_lite)) - session.add(OakCameraStream("oak_d", args.output_dir, device_id=args.oak_d)) - session.add(OgloTactileStream("oglo", address=args.oglo_address, hand=args.oglo_hand)) - - syncfield.viewer.launch(session) +session = sf.SessionOrchestrator( + host_id="mac_studio", + output_dir=Path("./output"), +) +out = session.output_dir +session.add(UVCWebcamStream("mac_webcam", device_index=0, output_dir=out)) +session.add(UVCWebcamStream("iphone", device_index=1, output_dir=out)) +session.add(OakCameraStream("oak_lite", out, device_id="19443010813AF02C00")) +session.add(OakCameraStream("oak_d", out, device_id="1944301071781C1300")) +session.add(OgloTactileStream("oglo", address="C1718989-5A77-F3EB-B00A-01A758D99D54")) -if __name__ == "__main__": - main() +syncfield.viewer.launch(session) diff --git a/examples/generic_sensor_demo/polling_serial.py b/examples/generic_sensor_demo/polling_serial.py index ce6e6cb..0aaa447 100644 --- a/examples/generic_sensor_demo/polling_serial.py +++ b/examples/generic_sensor_demo/polling_serial.py @@ -29,10 +29,7 @@ def close(self) -> None: def main() -> None: - output_dir = Path("./demo_session_polling") - output_dir.mkdir(exist_ok=True) - - session = sf.SessionOrchestrator(host_id="demo", output_dir=output_dir) + session = sf.SessionOrchestrator(host_id="demo", output_dir=Path("./demo_data")) serial = FakeSerial() session.add(PollingSensorStream("fake_imu", read=serial.read_sample, hz=50)) diff --git a/examples/generic_sensor_demo/push_async.py b/examples/generic_sensor_demo/push_async.py index c04e2da..c7a692a 100644 --- a/examples/generic_sensor_demo/push_async.py +++ b/examples/generic_sensor_demo/push_async.py @@ -13,10 +13,7 @@ def main() -> None: - output_dir = Path("./demo_session_push") - output_dir.mkdir(exist_ok=True) - - session = sf.SessionOrchestrator(host_id="demo", output_dir=output_dir) + session = sf.SessionOrchestrator(host_id="demo", output_dir=Path("./demo_data")) loop_holder: dict = {} stop_event = asyncio.Event() diff --git a/examples/iphone_mac_webcam/record.py b/examples/iphone_mac_webcam/record.py index 869afa8..f9bfef5 100644 --- a/examples/iphone_mac_webcam/record.py +++ b/examples/iphone_mac_webcam/record.py @@ -4,38 +4,17 @@ python record.py """ -import argparse -import secrets -from datetime import datetime from pathlib import Path import syncfield as sf import syncfield.viewer from syncfield.adapters import UVCWebcamStream -OUTPUT_ROOT = Path(__file__).parent / "output" +session = sf.SessionOrchestrator( + host_id="mac_studio", + output_dir=Path(__file__).parent / "output", +) +session.add(UVCWebcamStream("mac_webcam", device_index=0, output_dir=session.output_dir)) +session.add(UVCWebcamStream("iphone", device_index=1, output_dir=session.output_dir)) - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--webcam-index", type=int, default=0) - parser.add_argument("--iphone-index", type=int, default=1) - parser.add_argument("--output-dir", type=Path, default=OUTPUT_ROOT) - args = parser.parse_args() - - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - episode = args.output_dir / f"ep_{stamp}_{secrets.token_hex(3)}" - episode.mkdir(parents=True, exist_ok=True) - - session = sf.SessionOrchestrator( - host_id="mac_studio", - output_dir=episode, - ) - session.add(UVCWebcamStream("mac_webcam", args.webcam_index, episode)) - session.add(UVCWebcamStream("iphone", args.iphone_index, episode)) - - syncfield.viewer.launch(session) - - -if __name__ == "__main__": - main() +syncfield.viewer.launch(session) diff --git a/examples/mac_iphone_dual_oak/record.py b/examples/mac_iphone_dual_oak/record.py index 6b466fb..106e2a9 100644 --- a/examples/mac_iphone_dual_oak/record.py +++ b/examples/mac_iphone_dual_oak/record.py @@ -4,46 +4,21 @@ python record.py """ -import argparse -import secrets -from datetime import datetime from pathlib import Path import syncfield as sf import syncfield.viewer from syncfield.adapters import OakCameraStream, UVCWebcamStream -OUTPUT_ROOT = Path(__file__).parent / "output" +session = sf.SessionOrchestrator( + host_id="mac_studio", + output_dir=Path(__file__).parent / "output", +) +out = session.output_dir -# Override with --oak-lite / --oak-d if your rig differs. -DEFAULT_OAK_LITE_SERIAL = "19443010813AF02C00" -DEFAULT_OAK_D_SERIAL = "1944301071781C1300" +session.add(UVCWebcamStream("mac_webcam", device_index=0, output_dir=out)) +session.add(UVCWebcamStream("iphone", device_index=1, output_dir=out)) +session.add(OakCameraStream("oak_lite", out, device_id="19443010813AF02C00")) +session.add(OakCameraStream("oak_d", out, device_id="1944301071781C1300")) - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--webcam-index", type=int, default=0) - parser.add_argument("--iphone-index", type=int, default=1) - parser.add_argument("--oak-lite", default=DEFAULT_OAK_LITE_SERIAL) - parser.add_argument("--oak-d", default=DEFAULT_OAK_D_SERIAL) - parser.add_argument("--output-dir", type=Path, default=OUTPUT_ROOT) - args = parser.parse_args() - - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - episode = args.output_dir / f"ep_{stamp}_{secrets.token_hex(3)}" - episode.mkdir(parents=True, exist_ok=True) - - session = sf.SessionOrchestrator( - host_id="mac_studio", - output_dir=episode, - ) - session.add(UVCWebcamStream("mac_webcam", args.webcam_index, episode)) - session.add(UVCWebcamStream("iphone", args.iphone_index, episode)) - session.add(OakCameraStream("oak_lite", episode, device_id=args.oak_lite)) - session.add(OakCameraStream("oak_d", episode, device_id=args.oak_d)) - - syncfield.viewer.launch(session) - - -if __name__ == "__main__": - main() +syncfield.viewer.launch(session) diff --git a/src/syncfield/orchestrator.py b/src/syncfield/orchestrator.py index 353672a..7460c89 100644 --- a/src/syncfield/orchestrator.py +++ b/src/syncfield/orchestrator.py @@ -123,6 +123,24 @@ # --------------------------------------------------------------------------- +def _make_episode_dir(data_dir: Path) -> Path: + """Create a timestamped episode directory inside *data_dir*. + + Each recording session gets its own sub-directory named + ``ep_{YYYYMMDD}_{HHMMSS}_{6-char-hex}`` so episodes never collide + even when two sessions start in the same second. + + Returns the newly created episode :class:`Path`. + """ + import secrets + from datetime import datetime + + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + episode = data_dir / f"ep_{stamp}_{secrets.token_hex(3)}" + episode.mkdir(parents=True, exist_ok=True) + return episode + + def _run_countdown( countdown_s: float, on_tick: Optional[Callable[[int], None]], @@ -228,8 +246,7 @@ def __init__( role: Optional[Role] = None, ) -> None: self._host_id = host_id - self._output_dir = Path(output_dir) - self._output_dir.mkdir(parents=True, exist_ok=True) + self._output_dir = _make_episode_dir(Path(output_dir)) self._sync_tone = sync_tone or SyncToneConfig.default() self._chirp_player = chirp_player or create_default_player() self._streams: Dict[str, Stream] = {} diff --git a/tests/integration/test_generic_sensor_e2e.py b/tests/integration/test_generic_sensor_e2e.py index f67df71..ff528bb 100644 --- a/tests/integration/test_generic_sensor_e2e.py +++ b/tests/integration/test_generic_sensor_e2e.py @@ -68,8 +68,8 @@ def push_producer(): assert push_fin.file_path is None # ── Verify JSONL files (written by orchestrator) ───────────── - poll_path = tmp_path / "poll_imu.jsonl" - push_path = tmp_path / "push_imu.jsonl" + poll_path = session.output_dir / "poll_imu.jsonl" + push_path = session.output_dir / "push_imu.jsonl" assert poll_path.exists(), "orchestrator should create poll_imu.jsonl" assert push_path.exists(), "orchestrator should create push_imu.jsonl" @@ -86,7 +86,7 @@ def push_producer(): assert first_poll["clock_source"] == "host_monotonic" # ── Verify manifest ────────────────────────────────────────── - manifest_path = tmp_path / "manifest.json" + manifest_path = session.output_dir / "manifest.json" assert manifest_path.exists() manifest = json.loads(manifest_path.read_text()) assert "poll_imu" in manifest["streams"] diff --git a/tests/integration/test_round_trip.py b/tests/integration/test_round_trip.py index 55a8135..09f8d03 100644 --- a/tests/integration/test_round_trip.py +++ b/tests/integration/test_round_trip.py @@ -42,8 +42,10 @@ def test_full_session_produces_valid_core_artifacts(tmp_path: Path): report = session.stop() + out = session.output_dir + # --- sync_point.json -------------------------------------------------- - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((out / "sync_point.json").read_text()) assert sp["host_id"] == "rig_01" assert isinstance(sp["monotonic_ns"], int) assert isinstance(sp["wall_clock_ns"], int) @@ -52,7 +54,7 @@ def test_full_session_produces_valid_core_artifacts(tmp_path: Path): assert "chirp_start_ns" not in sp # --- manifest.json ---------------------------------------------------- - manifest = json.loads((tmp_path / "manifest.json").read_text()) + manifest = json.loads((out / "manifest.json").read_text()) assert manifest["host_id"] == "rig_01" assert "cam_a" in manifest["streams"] assert "imu_a" in manifest["streams"] @@ -64,7 +66,7 @@ def test_full_session_produces_valid_core_artifacts(tmp_path: Path): # --- session_log.jsonl ------------------------------------------------ log_lines = [ json.loads(line) - for line in (tmp_path / "session_log.jsonl") + for line in (out / "session_log.jsonl") .read_text() .strip() .split("\n") @@ -93,7 +95,7 @@ def test_silent_session_omits_chirp_fields(tmp_path: Path): session.add(FakeStream("cam", provides_audio_track=True)) session.start() session.stop() - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) assert "chirp_start_ns" not in sp assert "chirp_stop_ns" not in sp assert "chirp_spec" not in sp @@ -109,8 +111,8 @@ def test_no_audio_stream_single_host_session_works_without_chirp(tmp_path: Path) session.add(FakeStream("imu_only")) session.start() session.stop() - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) assert "chirp_start_ns" not in sp # Session still completes cleanly - manifest = json.loads((tmp_path / "manifest.json").read_text()) + manifest = json.loads((session.output_dir / "manifest.json").read_text()) assert manifest["streams"]["imu_only"]["status"] == "completed" diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 5d449a3..4a7e601 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -81,12 +81,15 @@ def test_host_id_property(self, tmp_path): def test_output_dir_created(self, tmp_path): target = tmp_path / "sub" / "dir" assert not target.exists() - SessionOrchestrator( + session = SessionOrchestrator( host_id="h", output_dir=target, sync_tone=SyncToneConfig.silent(), ) + # The orchestrator creates an episode subdir inside target. assert target.exists() + assert session.output_dir.parent == target + assert session.output_dir.name.startswith("ep_") class _DeviceKeyedFakeStream(FakeStream): @@ -438,7 +441,7 @@ def test_stop_writes_sync_point_json(self, tmp_path): session.add(FakeStream("a")) session.start() session.stop() - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) assert sp["host_id"] == "rig_01" assert "monotonic_ns" in sp # Silent mode → no chirp fields @@ -449,7 +452,7 @@ def test_stop_writes_manifest_with_capabilities(self, tmp_path): session.add(FakeStream("a", provides_audio_track=True)) session.start() session.stop() - m = json.loads((tmp_path / "manifest.json").read_text()) + m = json.loads((session.output_dir / "manifest.json").read_text()) assert m["host_id"] == "rig_01" assert "a" in m["streams"] assert m["streams"]["a"]["capabilities"]["provides_audio_track"] is True @@ -563,7 +566,7 @@ def test_chirp_fields_written_to_sync_point_json(self, tmp_path): session.add(FakeStream("a", provides_audio_track=True)) session.start() session.stop() - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) assert "chirp_start_ns" in sp assert "chirp_stop_ns" in sp assert sp["chirp_start_ns"] > 0 @@ -608,7 +611,7 @@ def test_hardware_emission_surfaces_in_session_report(self, tmp_path): assert report.chirp_start_source == "hardware" assert report.chirp_stop_source == "hardware" - sp = json.loads((tmp_path / "sync_point.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) assert sp["chirp_start_source"] == "hardware" assert sp["chirp_stop_source"] == "hardware" assert sp["chirp_start_ns"] == 500 @@ -792,8 +795,8 @@ def test_manifest_and_sync_point_include_session_id( session.start() session.stop() - sp = json.loads((tmp_path / "sync_point.json").read_text()) - mf = json.loads((tmp_path / "manifest.json").read_text()) + sp = json.loads((session.output_dir / "sync_point.json").read_text()) + mf = json.loads((session.output_dir / "manifest.json").read_text()) assert sp["session_id"] == "amber-tiger-042" assert sp["role"] == "leader" assert mf["session_id"] == "amber-tiger-042" @@ -946,7 +949,7 @@ def test_manifest_records_leader_host_id(self, tmp_path, fake_multihost): session.start() session.stop() - mf = json.loads((tmp_path / "manifest.json").read_text()) + mf = json.loads((session.output_dir / "manifest.json").read_text()) assert mf["role"] == "follower" assert mf["session_id"] == "amber-tiger-042" assert mf["leader_host_id"] == "leader_host" @@ -1026,7 +1029,7 @@ def test_video_stream_writes_timestamps_jsonl(self, tmp_path): cam.push_sample(frame_number=2, capture_ns=3_000_000) session.stop() - timestamps_path = tmp_path / "cam.timestamps.jsonl" + timestamps_path = session.output_dir / "cam.timestamps.jsonl" assert timestamps_path.exists(), ( "orchestrator must persist SampleEvents to " "{stream_id}.timestamps.jsonl — they are the SDK→core sync handoff" @@ -1096,7 +1099,7 @@ def push(self, frame_number, capture_ns, channels): imu.push(1, 110, {"ax": 0.15, "ay": -9.79}) session.stop() - sensor_path = tmp_path / "torso_imu.jsonl" + sensor_path = session.output_dir / "torso_imu.jsonl" assert sensor_path.exists() lines = [ json.loads(line) @@ -1121,7 +1124,7 @@ def test_samples_after_stop_do_not_race_closed_writer(self, tmp_path): lines = [ line - for line in (tmp_path / "cam.timestamps.jsonl").read_text().splitlines() + for line in (session.output_dir / "cam.timestamps.jsonl").read_text().splitlines() if line ] # Only the pre-stop sample made it to disk. @@ -1135,7 +1138,7 @@ def test_session_log_captures_state_transitions(self, tmp_path): session.start(countdown_s=0) session.stop() - log_path = tmp_path / "session_log.jsonl" + log_path = session.output_dir / "session_log.jsonl" assert log_path.exists() lines = [json.loads(l) for l in log_path.read_text().strip().split("\n")] transitions = [l for l in lines if l["kind"] == "state_transition"] @@ -1158,7 +1161,7 @@ def test_session_log_flushes_during_recording(self, tmp_path): session.add(FakeStream("a")) session.start(countdown_s=0) # Simulate "read the log while still RECORDING" - content = (tmp_path / "session_log.jsonl").read_text() + content = (session.output_dir / "session_log.jsonl").read_text() assert "preparing" in content assert "recording" in content session.stop() @@ -1170,7 +1173,7 @@ def test_rollback_is_logged(self, tmp_path): with pytest.raises(RuntimeError): session.start() - log_path = tmp_path / "session_log.jsonl" + log_path = session.output_dir / "session_log.jsonl" assert log_path.exists() lines = [json.loads(l) for l in log_path.read_text().strip().split("\n")] assert any(l["kind"] == "rollback" for l in lines) @@ -1188,7 +1191,7 @@ def test_stream_health_events_routed_to_session_log(self, tmp_path): lines = [ json.loads(l) - for l in (tmp_path / "session_log.jsonl").read_text().strip().split("\n") + for l in (session.output_dir / "session_log.jsonl").read_text().strip().split("\n") ] health_lines = [l for l in lines if l["kind"] == "health"] assert len(health_lines) == 2 From b2ab76595baac332d565913b764deeef89529a26 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 20:37:13 -0700 Subject: [PATCH 07/42] chore: uv lock --- uv.lock | 906 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 873 insertions(+), 33 deletions(-) diff --git a/uv.lock b/uv.lock index aee0269..42b87f7 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,59 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -169,6 +222,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, ] +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -259,30 +343,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/0e/1f818f5dad75b806e1e65586e5380bec64565caf7caeee7047dfd5ff8c3d/dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:92b3aaea0e6df4cf83208ae994b08554335166eff726947733b93da748eab641", size = 886837, upload-time = "2026-04-02T04:50:45.644Z" }, ] -[[package]] -name = "dearpygui" -version = "2.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/71/114626e9b77b07b2d5d92e0030b00b4a78e73de1212cbe63656af3da636e/dearpygui-2.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:9805b99abcdf89b18c6877cfd4865f844398e1c555316d2f7347b1e8e62f29fd", size = 1931334, upload-time = "2026-02-17T14:21:51.362Z" }, - { url = "https://files.pythonhosted.org/packages/28/f5/dbd692d64a27c94d7bf4f05b87a4bd74bcd61699248a7fb1166635cef17a/dearpygui-2.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:8b42ebd0a73ddf03ab5fb0777636216035716089ae449f904fe37ccebbed0061", size = 2592856, upload-time = "2026-02-17T14:22:00.223Z" }, - { url = "https://files.pythonhosted.org/packages/58/e0/4be23bd80453b5ee216319a1f2005b57a7c25d00872056f7a96a0a21ef4e/dearpygui-2.2-cp310-cp310-win_amd64.whl", hash = "sha256:9872af7c4d1c7f8b4f1031c1c333ff83c778332674ac3d54178fa7ca0230c6ab", size = 1830505, upload-time = "2026-02-17T14:21:40.74Z" }, - { url = "https://files.pythonhosted.org/packages/b7/80/c62a26549688a9a2251fede8c1ba10f5e41964a4bb97dba486bcb1e0be28/dearpygui-2.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:a2dbbd975e1dbdf4688ef49b95651192b6417c8722e470b9ad2b7f5029555c63", size = 1931280, upload-time = "2026-02-17T14:21:52.98Z" }, - { url = "https://files.pythonhosted.org/packages/01/a1/6c40624fcaa0ea429aa2b6906b19c639175de0677b2af52f00c2794a56ce/dearpygui-2.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:87c16bc00b94ee748c8c156c10f353b7f0b6e843ecec54121cb3b9f254abf940", size = 2592871, upload-time = "2026-02-17T14:22:01.806Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/3683b74526a869403ca63bac33c47c8d1bbabe57d186eb33490b5d18459a/dearpygui-2.2-cp311-cp311-win_amd64.whl", hash = "sha256:d5a38e58a03a41e09915f9b026759899d772d32e920bcd114d1b3f344946e0f0", size = 1830497, upload-time = "2026-02-17T14:21:42.108Z" }, - { url = "https://files.pythonhosted.org/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/18/ab/eb8070ca8fd881d4a9ac49fca5fb7b54ce66cc2742afa38e59d72b2c2dec/dearpygui-2.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:084c309c56d3e05fcf75eef872df6df97f5e3e19da5ecad393a57cf7a5e56294", size = 1931423, upload-time = "2026-02-17T14:21:56.397Z" }, - { url = "https://files.pythonhosted.org/packages/bc/03/5988d5f4cf3ddc7c3d886623bb904b76c5f5f628a0256ac53d848df33cf7/dearpygui-2.2-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:05d8c18a0134d72f680e333c80ccab264351170293f86a05f5a0e14222992f27", size = 2592542, upload-time = "2026-02-17T14:22:03.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5a/573df5f7277a13b5044daa9a27797fbd4e766da03cab6462a151b557727c/dearpygui-2.2-cp313-cp313-win_amd64.whl", hash = "sha256:500087e88d61b4ef0c841f30b12a05f5128774db3883fde7ff7c6172f03f6d79", size = 1830558, upload-time = "2026-02-17T14:21:44.551Z" }, - { url = "https://files.pythonhosted.org/packages/8b/76/3ccaec465021b647f13c83be42a635043a08255076984a658ed691701498/dearpygui-2.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:22451146968729429ba37afa2602957dfefc03ff92dcc627dd4d85ba3f93e771", size = 1931385, upload-time = "2026-02-17T14:21:58.193Z" }, - { url = "https://files.pythonhosted.org/packages/52/ac/8e591f33a712563742fe77b0731c1c900fe2fcc3d3e75bd4c7d8e60057a8/dearpygui-2.2-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:dcc9377d8d9fe27f659ae6b016fe96aa37d8b26b57ce60c47985290e1be7801e", size = 2592691, upload-time = "2026-02-17T14:22:05.191Z" }, - { url = "https://files.pythonhosted.org/packages/f8/03/aeb4ebe09a0240c8c9337018d2ac3e087fd911f6051a3bb0131248fbd942/dearpygui-2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe3c8dc37be3ddce0356afb0c16721c0e485a4c94a831886935a0692bb9a9966", size = 1889279, upload-time = "2026-02-17T14:21:46.16Z" }, - { url = "https://files.pythonhosted.org/packages/d2/10/41035b530b4d6968a0860f625db42928387138d98db31e86112fc177098f/dearpygui-2.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:9be4381cd4fcf9dab0e8eb7a11455296fb804875fb756c9a4c0aef59a8aabc12", size = 2592939, upload-time = "2026-02-17T14:22:07.49Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e0/64d0e0adc4acb0cf0863e19160de21b7779ff003b35ab34795bf1bf31773/dearpygui-2.2-cp39-cp39-win_amd64.whl", hash = "sha256:c16607014d3dbb8537b636fcf86b0282d2177a4ac2154bf0ac24cdddab82279a", size = 1830462, upload-time = "2026-02-17T14:21:48.723Z" }, -] - [[package]] name = "depthai" version = "3.5.0" @@ -312,6 +372,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "annotated-doc", marker = "python_full_version < '3.10'" }, + { name = "pydantic", marker = "python_full_version < '3.10'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "typing-inspection", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "annotated-doc", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "starlette", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/b1fe0e8890f0292c266117d4cd268186758a9c34e576fbd573fdf3beacff/httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4", size = 206454, upload-time = "2025-10-10T03:55:01.528Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/a675c90b49e550c7635ce209c01bc61daa5b08aef17da27ef4e0e78fcf3f/httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a", size = 110260, upload-time = "2025-10-10T03:55:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/03/44/fb5ef8136e6e97f7b020e97e40c03a999f97e68574d4998fa52b0a62b01b/httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf", size = 441524, upload-time = "2025-10-10T03:55:03.292Z" }, + { url = "https://files.pythonhosted.org/packages/b4/62/8496a5425341867796d7e2419695f74a74607054e227bbaeabec8323e87f/httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28", size = 440877, upload-time = "2025-10-10T03:55:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f1/26c2e5214106bf6ed04d03e518ff28ca0c6b5390c5da7b12bbf94b40ae43/httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517", size = 425775, upload-time = "2025-10-10T03:55:05.341Z" }, + { url = "https://files.pythonhosted.org/packages/3a/34/7500a19257139725281f7939a7d1aa3701cf1ac4601a1690f9ab6f510e15/httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad", size = 425001, upload-time = "2025-10-10T03:55:06.389Z" }, + { url = "https://files.pythonhosted.org/packages/71/04/31a7949d645ebf33a67f56a0024109444a52a271735e0647a210264f3e61/httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023", size = 86818, upload-time = "2025-10-10T03:55:07.316Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "ifaddr" version = "0.2.0" @@ -611,6 +778,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -850,6 +1163,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + [[package]] name = "sounddevice" version = "0.5.5" @@ -866,6 +1277,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] +[[package]] +name = "starlette" +version = "0.49.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "syncfield" version = "0.2.0" @@ -875,13 +1319,16 @@ source = { editable = "." } all = [ { name = "bleak", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "bleak", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "dearpygui" }, { name = "depthai" }, + { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "fastapi", version = "0.135.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "opencv-python" }, { name = "sounddevice" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" }, { name = "zeroconf" }, ] audio = [ @@ -904,10 +1351,11 @@ uvc = [ { name = "opencv-python" }, ] viewer = [ - { name = "dearpygui" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "fastapi", version = "0.135.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opencv-python" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.44.0", source = { registry = "https://pypi.org/simple" }, extra = ["standard"], marker = "python_full_version >= '3.10'" }, ] [package.dev-dependencies] @@ -921,17 +1369,19 @@ dev = [ requires-dist = [ { name = "bleak", marker = "extra == 'all'", specifier = ">=0.21" }, { name = "bleak", marker = "extra == 'ble'", specifier = ">=0.21" }, - { name = "dearpygui", marker = "extra == 'all'", specifier = ">=2.0" }, - { name = "dearpygui", marker = "extra == 'viewer'", specifier = ">=2.0" }, { name = "depthai", marker = "extra == 'all'", specifier = ">=3.0.0" }, { name = "depthai", marker = "extra == 'oak'", specifier = ">=3.0.0" }, + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.104.0" }, + { name = "fastapi", marker = "extra == 'viewer'", specifier = ">=0.104.0" }, { name = "numpy", marker = "extra == 'all'", specifier = ">=1.21" }, { name = "numpy", marker = "extra == 'audio'", specifier = ">=1.21" }, - { name = "numpy", marker = "extra == 'viewer'", specifier = ">=1.21" }, - { name = "opencv-python", marker = "extra == 'all'", specifier = ">=4.5" }, + { name = "opencv-python", marker = "extra == 'all'", specifier = ">=4.8.0" }, { name = "opencv-python", marker = "extra == 'uvc'", specifier = ">=4.5" }, + { name = "opencv-python", marker = "extra == 'viewer'", specifier = ">=4.8.0" }, { name = "sounddevice", marker = "extra == 'all'", specifier = ">=0.4.6" }, { name = "sounddevice", marker = "extra == 'audio'", specifier = ">=0.4.6" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.24.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'viewer'", specifier = ">=0.24.0" }, { name = "zeroconf", marker = "extra == 'all'", specifier = ">=0.130" }, { name = "zeroconf", marker = "extra == 'multihost'", specifier = ">=0.130" }, ] @@ -1006,6 +1456,396 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "h11", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/25/db2b1c6c35bf22e17fe5412d2ee5d3fd7a20d07ebc9dac8b58f7db2e23a0/uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a", size = 68491, upload-time = "2025-12-21T13:05:16.291Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "httptools", marker = "python_full_version < '3.10'" }, + { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "uvloop", marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles", marker = "python_full_version < '3.10'" }, + { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "h11", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "httptools", marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "uvloop", marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles", marker = "python_full_version >= '3.10'" }, + { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1b/6fbd611aeba01ef802c5876c94d7be603a9710db055beacbad39e75a31aa/uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4", size = 1345858, upload-time = "2025-10-16T22:17:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/9e/91/2c84f00bdbe3c51023cc83b027bac1fe959ba4a552e970da5ef0237f7945/uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c", size = 743913, upload-time = "2025-10-16T22:17:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/cc/10/76aec83886d41a88aca5681db6a2c0601622d0d2cb66cd0d200587f962ad/uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54", size = 3635818, upload-time = "2025-10-16T22:17:13.812Z" }, + { url = "https://files.pythonhosted.org/packages/d5/9a/733fcb815d345979fc54d3cdc3eb50bc75a47da3e4003ea7ada58e6daa65/uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659", size = 3685477, upload-time = "2025-10-16T22:17:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/83/fb/bee1eb11cc92bd91f76d97869bb6a816e80d59fd73721b0a3044dc703d9c/uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743", size = 3496128, upload-time = "2025-10-16T22:17:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/76/ee/3fdfeaa9776c0fd585d358c92b1dbca669720ffa476f0bbe64ed8f245bd7/uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7", size = 3602565, upload-time = "2025-10-16T22:17:17.755Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/a4/68/a7303a15cc797ab04d58f1fea7f67c50bd7f80090dfd7e750e7576e07582/watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70", size = 409220, upload-time = "2025-10-14T15:05:51.917Z" }, + { url = "https://files.pythonhosted.org/packages/99/b8/d1857ce9ac76034c053fa7ef0e0ef92d8bd031e842ea6f5171725d31e88f/watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e", size = 396712, upload-time = "2025-10-14T15:05:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/41/7a/da7ada566f48beaa6a30b13335b49d1f6febaf3a5ddbd1d92163a1002cf4/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956", size = 451462, upload-time = "2025-10-14T15:05:54.742Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b2/7cb9e0d5445a8d45c4cccd68a590d9e3a453289366b96ff37d1075aaebef/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c", size = 460811, upload-time = "2025-10-14T15:05:55.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/9d/b07d4491dde6db6ea6c680fdec452f4be363d65c82004faf2d853f59b76f/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c", size = 490576, upload-time = "2025-10-14T15:05:56.983Z" }, + { url = "https://files.pythonhosted.org/packages/56/03/e64dcab0a1806157db272a61b7891b062f441a30580a581ae72114259472/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3", size = 597726, upload-time = "2025-10-14T15:05:57.986Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8e/a827cf4a8d5f2903a19a934dcf512082eb07675253e154d4cd9367978a58/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2", size = 474900, upload-time = "2025-10-14T15:05:59.378Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/94fed0b346b85b22303a12eee5f431006fae6af70d841cac2f4403245533/watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02", size = 457521, upload-time = "2025-10-14T15:06:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/c4/64/bc3331150e8f3c778d48a4615d4b72b3d2d87868635e6c54bbd924946189/watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be", size = 632191, upload-time = "2025-10-14T15:06:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/84/f39e19549c2f3ec97225dcb2ceb9a7bb3c5004ed227aad1f321bf0ff2051/watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f", size = 623923, upload-time = "2025-10-14T15:06:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/0759ae15d9a0c9c5fe946bd4cf45ab9e7bad7cfede2c06dc10f59171b29f/watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b", size = 274010, upload-time = "2025-10-14T15:06:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3b/eb26cddd4dfa081e2bf6918be3b2fc05ee3b55c1d21331d5562ee0c6aaad/watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957", size = 289090, upload-time = "2025-10-14T15:06:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/38a2c52fdbbfe2fc7ffaaaaaebc927d52b9f4d5139bba3186c19a7463001/watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f", size = 409210, upload-time = "2025-10-14T15:06:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/d1/43/d7e8b71f6c21ff813ee8da1006f89b6c7fff047fb4c8b16ceb5e840599c5/watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34", size = 397286, upload-time = "2025-10-14T15:06:16.177Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5d/884074a5269317e75bd0b915644b702b89de73e61a8a7446e2b225f45b1f/watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc", size = 451768, upload-time = "2025-10-14T15:06:18.266Z" }, + { url = "https://files.pythonhosted.org/packages/17/71/7ffcaa9b5e8961a25026058058c62ec8f604d2a6e8e1e94bee8a09e1593f/watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e", size = 458561, upload-time = "2025-10-14T15:06:19.323Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "winrt-runtime" version = "3.2.1" From 68c17a27e778024ea6b997fe57b7976e64db4652 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 21:05:22 -0700 Subject: [PATCH 08/42] feat(viewer): add Review mode with sync trigger and episode browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete Review system alongside the existing Recording view, accessible via a header segment control (Record | Review). Review - Episode List: - Grid and Table view toggle for browsing recorded episodes - Each episode shows sync status, stream count, host ID, timestamp - Server scans output directory for ep_* folders automatically Review - Episode Detail: - Multi-camera synchronized video playback with timeline scrubber - Sync quality sidebar (grade, confidence, per-stream offsets) - Before/After drift chart from frame_map.jsonl - Drift badges on secondary video streams Sync Trigger: - One-click sync via local Docker container (localhost:8080) - Progress bar with phase/percentage during processing - Auto-refreshes sync report on completion Backend (server.py): - GET /api/episodes — list episodes from data root - GET /api/episodes/{id} — manifest + sync_report - GET /api/episodes/{id}/video/{stream} — serve video (synced preferred) - POST /api/episodes/{id}/sync — proxy to Docker sync API - GET /api/episodes/{id}/sync-status/{job_id} — poll proxy - GET /api/episodes/{id}/frame-map — parsed JSONL Tests: 9 Python tests for episode scanning, 6 TypeScript tests for sync grade logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-10-sync-review-design.md | 63 ++++ .../frontend/__tests__/review-types.test.ts | 74 ++++ src/syncfield/viewer/frontend/src/App.tsx | 67 +++- .../viewer/frontend/src/components/header.tsx | 104 +++--- .../src/components/review/drift-chart.tsx | 160 ++++++++ .../src/components/review/episode-card.tsx | 81 ++++ .../src/components/review/episode-detail.tsx | 144 ++++++++ .../src/components/review/episode-list.tsx | 144 ++++++++ .../src/components/review/episode-table.tsx | 67 ++++ .../src/components/review/review-page.tsx | 25 ++ .../src/components/review/review-timeline.tsx | 97 +++++ .../components/review/review-video-player.tsx | 71 ++++ .../src/components/review/sync-button.tsx | 49 +++ .../components/review/sync-quality-panel.tsx | 192 ++++++++++ .../src/components/segment-control.tsx | 37 ++ .../frontend/src/hooks/use-drift-data.ts | 110 ++++++ .../viewer/frontend/src/hooks/use-episode.ts | 55 +++ .../viewer/frontend/src/hooks/use-episodes.ts | 49 +++ .../viewer/frontend/src/hooks/use-playback.ts | 164 +++++++++ .../viewer/frontend/src/hooks/use-sync.ts | 123 +++++++ .../viewer/frontend/src/lib/review-types.ts | 101 +++++ src/syncfield/viewer/server.py | 345 +++++++++++++++++- tests/unit/viewer/test_episode_api.py | 92 +++++ 23 files changed, 2356 insertions(+), 58 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-10-sync-review-design.md create mode 100644 src/syncfield/viewer/frontend/__tests__/review-types.test.ts create mode 100644 src/syncfield/viewer/frontend/src/components/review/drift-chart.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/episode-card.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/episode-detail.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/episode-list.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/episode-table.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/review-page.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/review-timeline.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/review-video-player.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/sync-button.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/review/sync-quality-panel.tsx create mode 100644 src/syncfield/viewer/frontend/src/components/segment-control.tsx create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-drift-data.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-episode.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-episodes.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-playback.ts create mode 100644 src/syncfield/viewer/frontend/src/hooks/use-sync.ts create mode 100644 src/syncfield/viewer/frontend/src/lib/review-types.ts create mode 100644 tests/unit/viewer/test_episode_api.py diff --git a/docs/superpowers/specs/2026-04-10-sync-review-design.md b/docs/superpowers/specs/2026-04-10-sync-review-design.md new file mode 100644 index 0000000..7272299 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-sync-review-design.md @@ -0,0 +1,63 @@ +# Sync & Review Feature Design Spec + +**Date:** 2026-04-10 +**Status:** Approved + +## Summary + +Add Synchronization trigger + Episode Review to the SyncField web viewer. Completely separate from the Recording view, accessible via header segment control. + +## Decisions + +| Item | Decision | +|------|----------| +| Navigation | Header inline segment control (Record \| Review) | +| Review first page | Card Grid + Table view toggle | +| Episode detail | Video + Right Sidebar layout | +| Sync backend | Local Docker container (localhost:8080) default | +| Review depth | Standard — videos, sync quality, before/after drift chart | +| Drift chart | frame_map.jsonl based, gray dashed (before) + green solid (after) | + +## Architecture + +### Backend API (server.py additions) + +``` +GET /api/episodes → episode list from data root +GET /api/episodes/{id} → manifest + sync_report +GET /api/episodes/{id}/video/{stream} → serve video file +POST /api/episodes/{id}/sync → proxy to Docker sync API +GET /api/episodes/{id}/sync-status/{job_id} → poll proxy +GET /api/episodes/{id}/frame-map → parsed frame_map.jsonl +``` + +### Frontend Components + +``` +App +├── SegmentControl (Record | Review) +├── [Record mode] — existing recording UI +└── [Review mode] + ├── EpisodeList + │ ├── ViewToggle (Grid | Table) + │ ├── EpisodeGrid → EpisodeCard[] + │ └── EpisodeTable + └── EpisodeDetail (on card click) + ├── Header (← back, episode name, sync button) + ├── VideoPlayer[] (multi-camera sync) + ├── Timeline (scrubber + playback controls) + ├── DriftChart (before/after) + └── SyncSidebar + ├── SyncQualityPanel (grade, confidence, improvement) + ├── StreamList (per-stream offset) + └── Metadata (duration, fps, host) +``` + +### Sync Flow + +1. User clicks Sync button +2. Server reads episode manifest, builds sync request +3. POST to Docker container `/api/v1/sync` with local paths +4. Poll `/api/v1/jobs/{job_id}` every 3s +5. On complete: read sync_report.json, switch to synced/ videos +6. Default endpoint: `http://localhost:8080`, configurable via launch param diff --git a/src/syncfield/viewer/frontend/__tests__/review-types.test.ts b/src/syncfield/viewer/frontend/__tests__/review-types.test.ts new file mode 100644 index 0000000..c6cb76e --- /dev/null +++ b/src/syncfield/viewer/frontend/__tests__/review-types.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { syncGrade, type SyncStreamResult } from "../src/lib/review-types"; + +describe("syncGrade", () => { + it("returns 'primary' for primary streams", () => { + const stream: SyncStreamResult = { + role: "primary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + }; + expect(syncGrade(stream)).toBe("primary"); + }); + + it("returns 'excellent' for high confidence", () => { + const stream: SyncStreamResult = { + role: "secondary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + confidence: 0.95, + }; + expect(syncGrade(stream)).toBe("excellent"); + }); + + it("returns 'good' for medium confidence", () => { + const stream: SyncStreamResult = { + role: "secondary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + confidence: 0.7, + }; + expect(syncGrade(stream)).toBe("good"); + }); + + it("returns 'fair' for low confidence", () => { + const stream: SyncStreamResult = { + role: "secondary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + confidence: 0.45, + }; + expect(syncGrade(stream)).toBe("fair"); + }); + + it("returns 'poor' for very low confidence", () => { + const stream: SyncStreamResult = { + role: "secondary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + confidence: 0.2, + }; + expect(syncGrade(stream)).toBe("poor"); + }); + + it("returns 'poor' when confidence is missing", () => { + const stream: SyncStreamResult = { + role: "secondary", + host: "h", + fps: 30, + original_duration_sec: 60, + original_frame_count: 1800, + }; + expect(syncGrade(stream)).toBe("poor"); + }); +}); diff --git a/src/syncfield/viewer/frontend/src/App.tsx b/src/syncfield/viewer/frontend/src/App.tsx index 718318f..c150411 100644 --- a/src/syncfield/viewer/frontend/src/App.tsx +++ b/src/syncfield/viewer/frontend/src/App.tsx @@ -10,21 +10,41 @@ import { HealthTable } from "@/components/health-table"; import { CountdownOverlay } from "@/components/countdown-overlay"; import { DiscoveryModal } from "@/components/discovery-modal"; import { Footer } from "@/components/footer"; +import { ReviewPage } from "@/components/review/review-page"; +import type { ViewMode } from "@/components/segment-control"; // --------------------------------------------------------------------------- // App // -// Audio feedback (countdown ticks + chirps) is handled entirely by the -// recording PC via sounddevice/PortAudio. The browser only shows the -// visual countdown overlay — no Web Audio playback. +// Two modes: Record (live session monitoring) and Review (episode browsing +// + sync analysis). Switched via the header segment control. // --------------------------------------------------------------------------- export function App() { + const [mode, setMode] = useState("record"); + + return mode === "record" ? ( + + ) : ( + + ); +} + +// --------------------------------------------------------------------------- +// Record view (existing functionality) +// --------------------------------------------------------------------------- + +function RecordView({ + mode, + onModeChange, +}: { + mode: ViewMode; + onModeChange: (m: ViewMode) => void; +}) { const { snapshot, countdown, sendCommand } = useSession(); const discovery = useDiscovery(); const [discoveryOpen, setDiscoveryOpen] = useState(false); - // Update page title with session state useEffect(() => { const state = snapshot?.state ?? "idle"; document.title = state === "recording" ? "● SyncField" : "SyncField"; @@ -40,8 +60,8 @@ export function App() { const state = snapshot?.state ?? "idle"; const streams = snapshot?.streams ?? {}; const streamList = Object.values(streams); - const canRemove = state === "idle" || state === "connected" || state === "stopped"; - + const canRemove = + state === "idle" || state === "connected" || state === "stopped"; const isRecording = state === "recording"; return ( @@ -51,19 +71,17 @@ export function App() { isRecording && "shadow-[inset_0_0_0_3px_hsl(0_65%_48%)]", )} > - {/* Header */}
setDiscoveryOpen(true)} + mode={mode} + onModeChange={onModeChange} /> - {/* Control + Session clock */} - {/* Main content area */}
- {/* Streams section (main) */}
{streamList.length > 0 ? (
@@ -89,7 +107,6 @@ export function App() { )}
- {/* Health events sidebar */} {streamList.length > 0 && (
@@ -102,13 +119,10 @@ export function App() { )}
- {/* Footer */}
- {/* Countdown overlay */} {countdown !== null && } - {/* Discovery modal */} setDiscoveryOpen(false)} @@ -121,3 +135,28 @@ export function App() {
); } + +// --------------------------------------------------------------------------- +// Review view +// --------------------------------------------------------------------------- + +function ReviewView({ + mode, + onModeChange, +}: { + mode: ViewMode; + onModeChange: (m: ViewMode) => void; +}) { + return ( +
+
{}} + mode={mode} + onModeChange={onModeChange} + showRecordingControls={false} + /> + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/header.tsx b/src/syncfield/viewer/frontend/src/components/header.tsx index b4b0172..7342ec2 100644 --- a/src/syncfield/viewer/frontend/src/components/header.tsx +++ b/src/syncfield/viewer/frontend/src/components/header.tsx @@ -2,10 +2,15 @@ import type { SessionSnapshot } from "@/lib/types"; import { formatElapsed } from "@/lib/format"; import { cn } from "@/lib/utils"; import { Logo } from "./logo"; +import { SegmentControl, type ViewMode } from "./segment-control"; interface HeaderProps { snapshot: SessionSnapshot | null; onDiscoverClick: () => void; + mode: ViewMode; + onModeChange: (mode: ViewMode) => void; + /** Hide recording-specific controls in review mode. */ + showRecordingControls?: boolean; } /** Dot color by session state. */ @@ -41,7 +46,13 @@ function friendlyState(state: string): string { } } -export function Header({ snapshot, onDiscoverClick }: HeaderProps) { +export function Header({ + snapshot, + onDiscoverClick, + mode, + onModeChange, + showRecordingControls = true, +}: HeaderProps) { const state = snapshot?.state ?? "idle"; const hostId = snapshot?.host_id ?? "—"; const elapsed = snapshot?.elapsed_s ?? 0; @@ -51,61 +62,68 @@ export function Header({ snapshot, onDiscoverClick }: HeaderProps) {
{/* OpenGraph Labs logo */} -
+ {/* Mode switcher */} + - {/* Host ID */} - {hostId} + {/* Recording-specific info */} + {showRecordingControls && ( + <> +
-
+ {hostId} - {/* State indicator */} -
- - + +
+ + + {friendlyState(state)} + +
+ + {isRecording && ( + <> +
+ + {formatElapsed(elapsed)} + + )} - > - {friendlyState(state)} - -
- {/* Elapsed timer */} - {isRecording && ( - <> -
- - {formatElapsed(elapsed)} - +
+ + )} -
- - {/* Discover devices button */} - + {/* Review mode: just fill the space */} + {!showRecordingControls &&
}
); } diff --git a/src/syncfield/viewer/frontend/src/components/review/drift-chart.tsx b/src/syncfield/viewer/frontend/src/components/review/drift-chart.tsx new file mode 100644 index 0000000..d0ccb3d --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/drift-chart.tsx @@ -0,0 +1,160 @@ +import type { DriftData } from "@/hooks/use-drift-data"; + +interface DriftChartProps { + data: DriftData | null; + isLoading: boolean; +} + +const CHART_H = 80; +const PADDING = { top: 12, right: 12, bottom: 20, left: 40 }; + +/** + * Before/After drift chart — shows sync improvement over time. + * + * Gray dashed line = raw drift before correction. + * Green solid line = residual drift after correction. + * Filled area between = improvement. + */ +export function DriftChart({ data, isLoading }: DriftChartProps) { + if (isLoading) { + return ( +
+ Loading drift data… +
+ ); + } + + if (!data || data.frames.length === 0) { + return ( +
+ No drift data available +
+ ); + } + + const { frames, beforeDrift, afterDrift, improvementPct } = data; + const n = frames.length; + + // Compute bounds + const allValues = [...beforeDrift, ...afterDrift].filter( + (v) => !Number.isNaN(v), + ); + const yMax = Math.max(...allValues, 1); + const yMin = Math.min(...allValues, 0); + const yRange = yMax - yMin || 1; + + const plotW = 100; // viewBox percentage + const plotH = CHART_H - PADDING.top - PADDING.bottom; + + const xScale = (i: number) => + PADDING.left + ((plotW - PADDING.left - PADDING.right) * i) / Math.max(n - 1, 1); + const yScale = (v: number) => + PADDING.top + plotH - ((v - yMin) / yRange) * plotH; + + // Build SVG paths + const beforePath = beforeDrift + .map((v, i) => `${i === 0 ? "M" : "L"}${xScale(i)},${yScale(v)}`) + .join(" "); + const afterPath = afterDrift + .map((v, i) => `${i === 0 ? "M" : "L"}${xScale(i)},${yScale(v)}`) + .join(" "); + // Fill area between + const fillPath = + beforePath + + " " + + afterDrift + .map((v, i) => `L${xScale(n - 1 - i)},${yScale(v)}`) + .reverse() + .join(" ") + + " Z"; + + return ( +
+ {/* Header */} +
+ + Before / After Drift + +
+
+
+ Before +
+
+
+ After +
+
+ {improvementPct > 0 && ( + + ↓ {improvementPct.toFixed(0)}% improved + + )} +
+ + {/* Chart */} + + {/* Y-axis labels */} + + {yMax.toFixed(0)}ms + + + {yMin.toFixed(0)}ms + + + {/* Zero line */} + {yMin <= 0 && yMax >= 0 && ( + + )} + + {/* Fill between before and after */} + + + {/* Before drift (gray dashed) */} + + + {/* After drift (green solid) */} + + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-card.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-card.tsx new file mode 100644 index 0000000..5cdcf67 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/episode-card.tsx @@ -0,0 +1,81 @@ +import type { EpisodeSummary } from "@/lib/review-types"; +import { cn } from "@/lib/utils"; + +interface EpisodeCardProps { + episode: EpisodeSummary; + onClick: () => void; +} + +export function EpisodeCard({ episode, onClick }: EpisodeCardProps) { + const date = formatDate(episode.created_at); + + return ( + + ); +} + +function formatDate(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-detail.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-detail.tsx new file mode 100644 index 0000000..62f3476 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/episode-detail.tsx @@ -0,0 +1,144 @@ +import { useEpisode } from "@/hooks/use-episode"; +import { useSync } from "@/hooks/use-sync"; +import { usePlayback } from "@/hooks/use-playback"; +import { useDriftData } from "@/hooks/use-drift-data"; +import { SyncButton } from "./sync-button"; +import { SyncQualityPanel } from "./sync-quality-panel"; +import { ReviewVideoPlayer } from "./review-video-player"; +import { ReviewTimeline } from "./review-timeline"; +import { DriftChart } from "./drift-chart"; + +interface EpisodeDetailProps { + episodeId: string; + onBack: () => void; +} + +/** + * Full episode review view — video playback + timeline + drift chart + sync sidebar. + */ +export function EpisodeDetail({ episodeId, onBack }: EpisodeDetailProps) { + const { episode, isLoading, error, refresh } = useEpisode(episodeId); + const { triggerSync, jobStatus, isSyncing, error: syncError } = useSync(); + const { driftData, isLoading: driftLoading } = useDriftData(episodeId); + const playback = usePlayback(); + + if (isLoading) { + return ( +
+ Loading episode… +
+ ); + } + + if (error || !episode) { + return ( +
+

{error ?? "Episode not found"}

+ +
+ ); + } + + const streams = episode.streams; + const primaryStream = episode.sync_report?.summary.primary_stream ?? streams[0] ?? ""; + const secondaryStreams = streams.filter((s) => s !== primaryStream); + + function handleSync() { + triggerSync(episodeId).then(() => refresh()); + } + + return ( +
+ {/* Header */} +
+ +
+ {episodeId} + {episode.has_synced_videos && ( + + Synced + + )} +
+ {syncError && ( + {syncError} + )} + +
+ + {/* Main content */} +
+ {/* Left: Videos + Timeline + Drift chart */} +
+ {/* Video area */} +
+ {/* Primary */} + {primaryStream && ( + + )} + {/* Secondary videos */} + {secondaryStreams.map((sid) => { + const streamResult = episode.sync_report?.streams[sid]; + return ( + + ); + })} +
+ + {/* Timeline */} + + + {/* Drift chart */} + {(episode.has_synced_videos || driftData) && ( +
+ +
+ )} +
+ + {/* Right sidebar */} +
+ +
+
+
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx new file mode 100644 index 0000000..5a2e66b --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/episode-list.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { useEpisodes } from "@/hooks/use-episodes"; +import { cn } from "@/lib/utils"; +import { EpisodeCard } from "./episode-card"; +import { EpisodeTable } from "./episode-table"; + +type ListViewMode = "grid" | "table"; + +interface EpisodeListProps { + onSelect: (episodeId: string) => void; +} + +export function EpisodeList({ onSelect }: EpisodeListProps) { + const { episodes, isLoading, error, refresh } = useEpisodes(); + const [viewMode, setViewMode] = useState("grid"); + + if (isLoading) { + return ( +
+ Loading episodes… +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (episodes.length === 0) { + return ( +
+ No episodes found +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ + {episodes.length} episode{episodes.length !== 1 ? "s" : ""} + +
+ + +
+
+ + {/* Content */} +
+ {viewMode === "grid" ? ( +
+ {episodes.map((ep) => ( + onSelect(ep.id)} + /> + ))} +
+ ) : ( + + )} +
+
+ ); +} + +function ViewToggle({ + mode, + onChange, +}: { + mode: ListViewMode; + onChange: (m: ListViewMode) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx b/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx new file mode 100644 index 0000000..d7a47d2 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/episode-table.tsx @@ -0,0 +1,67 @@ +import type { EpisodeSummary } from "@/lib/review-types"; +import { cn } from "@/lib/utils"; + +interface EpisodeTableProps { + episodes: EpisodeSummary[]; + onSelect: (id: string) => void; +} + +export function EpisodeTable({ episodes, onSelect }: EpisodeTableProps) { + return ( +
+ + + + + + + + + + + + {episodes.map((ep) => ( + onSelect(ep.id)} + className={cn( + "cursor-pointer border-b last:border-0", + "transition-colors hover:bg-foreground/3", + )} + > + + + + + + + ))} + +
EpisodeDateHostStreamsSync
{ep.id} + {formatCompact(ep.created_at)} + + {ep.host_id ?? "—"} + {ep.stream_count} + {ep.has_sync ? ( + Synced + ) : ( + + )} +
+
+ ); +} + +function formatCompact(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} diff --git a/src/syncfield/viewer/frontend/src/components/review/review-page.tsx b/src/syncfield/viewer/frontend/src/components/review/review-page.tsx new file mode 100644 index 0000000..c0fd7f7 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/review-page.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; +import { EpisodeList } from "./episode-list"; +import { EpisodeDetail } from "./episode-detail"; + +/** + * Review mode — browse episodes and analyze sync quality. + * + * Two-level navigation: + * 1. Episode list (grid or table) + * 2. Episode detail (video + sync analysis) + */ +export function ReviewPage() { + const [selectedEpisode, setSelectedEpisode] = useState(null); + + if (selectedEpisode) { + return ( + setSelectedEpisode(null)} + /> + ); + } + + return ; +} diff --git a/src/syncfield/viewer/frontend/src/components/review/review-timeline.tsx b/src/syncfield/viewer/frontend/src/components/review/review-timeline.tsx new file mode 100644 index 0000000..2fb8e8a --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/review-timeline.tsx @@ -0,0 +1,97 @@ +import { useCallback, useRef } from "react"; + +interface ReviewTimelineProps { + currentTime: number; + duration: number; + isPlaying: boolean; + playbackRate: number; + onSeek: (time: number) => void; + onToggle: () => void; + onSetRate: (rate: number) => void; +} + +const RATES = [0.25, 0.5, 1, 1.5, 2]; + +export function ReviewTimeline({ + currentTime, + duration, + isPlaying, + playbackRate, + onSeek, + onToggle, + onSetRate, +}: ReviewTimelineProps) { + const barRef = useRef(null); + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + const handleBarClick = useCallback( + (e: React.MouseEvent) => { + const bar = barRef.current; + if (!bar || duration <= 0) return; + const rect = bar.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + onSeek(pct * duration); + }, + [duration, onSeek], + ); + + return ( +
+ {/* Play/Pause */} + + + {/* Progress bar */} +
+
+
+
+
+ + {/* Time display */} + + {formatTime(currentTime)} / {formatTime(duration)} + + + {/* Playback rate */} + +
+ ); +} + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/src/syncfield/viewer/frontend/src/components/review/review-video-player.tsx b/src/syncfield/viewer/frontend/src/components/review/review-video-player.tsx new file mode 100644 index 0000000..d647b5a --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/review-video-player.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from "react"; + +interface ReviewVideoPlayerProps { + episodeId: string; + streamId: string; + isPrimary: boolean; + /** Ref callback for the primary video — attaches playback control. */ + videoRef?: (el: HTMLVideoElement | null) => void; + /** Current time to sync secondary videos to. */ + syncTime?: number; + /** Drift offset in ms (shown as badge on non-primary). */ + driftMs?: number; +} + +/** + * Video player for episode review. Renders the recorded video from + * the server's episode video endpoint, preferring synced/ versions. + */ +export function ReviewVideoPlayer({ + episodeId, + streamId, + isPrimary, + videoRef, + syncTime, + driftMs, +}: ReviewVideoPlayerProps) { + const localRef = useRef(null); + + // Sync secondary videos to primary's currentTime + useEffect(() => { + if (isPrimary || syncTime == null) return; + const video = localRef.current; + if (!video) return; + // Only sync if the difference is significant (> 100ms) + if (Math.abs(video.currentTime - syncTime) > 0.1) { + video.currentTime = syncTime; + } + }, [syncTime, isPrimary]); + + const src = `/api/episodes/${episodeId}/video/${streamId}.mp4`; + + return ( +
+
+ ); +} diff --git a/src/syncfield/viewer/frontend/src/components/review/sync-button.tsx b/src/syncfield/viewer/frontend/src/components/review/sync-button.tsx new file mode 100644 index 0000000..68ced02 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/sync-button.tsx @@ -0,0 +1,49 @@ +import type { SyncJobStatus } from "@/lib/review-types"; +import { cn } from "@/lib/utils"; + +interface SyncButtonProps { + jobStatus: SyncJobStatus | null; + isSyncing: boolean; + hasSyncReport: boolean; + onSync: () => void; +} + +export function SyncButton({ + jobStatus, + isSyncing, + hasSyncReport, + onSync, +}: SyncButtonProps) { + if (isSyncing && jobStatus) { + const pct = Math.round(jobStatus.progress * 100); + return ( +
+
+
+
+ + {jobStatus.phase} · {pct}% + +
+ ); + } + + return ( + + ); +} diff --git a/src/syncfield/viewer/frontend/src/components/review/sync-quality-panel.tsx b/src/syncfield/viewer/frontend/src/components/review/sync-quality-panel.tsx new file mode 100644 index 0000000..e9f76f4 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/review/sync-quality-panel.tsx @@ -0,0 +1,192 @@ +import type { SyncReport, SyncStreamResult } from "@/lib/review-types"; +import { syncGrade } from "@/lib/review-types"; +import { cn } from "@/lib/utils"; + +interface SyncQualityPanelProps { + report: SyncReport | null; + streams: string[]; +} + +const GRADE_COLORS: Record = { + excellent: "text-success", + good: "text-primary", + fair: "text-warning", + poor: "text-destructive", + primary: "text-muted", +}; + +const GRADE_BG: Record = { + excellent: "bg-success/10", + good: "bg-primary/10", + fair: "bg-warning/10", + poor: "bg-destructive/10", + primary: "bg-foreground/5", +}; + +export function SyncQualityPanel({ + report, + streams, +}: SyncQualityPanelProps) { + return ( +
+ {/* Sync quality section */} + {report ? ( + + ) : ( +
+ Not yet synchronized. Click Sync to process. +
+ )} + + {/* Divider */} +
+ + {/* Stream list */} +
+

+ Streams +

+
    + {streams.map((sid) => { + const streamResult = report?.streams[sid]; + return ( + + ); + })} +
+
+ + {/* Divider */} +
+ + {/* Metadata */} + {report && ( +
+

+ Metadata +

+
+ + + + + +
+
+ )} +
+ ); +} + +function SyncedInfo({ report }: { report: SyncReport }) { + const overallGrade = deriveOverallGrade(report); + + return ( +
+

+ Sync Quality +

+
+ + {overallGrade} + + + {report.summary.status === "success" ? "All streams aligned" : "Partial alignment"} + +
+
+ ); +} + +function StreamRow({ + streamId, + result, +}: { + streamId: string; + result?: SyncStreamResult; +}) { + if (!result) { + return ( +
  • + + {streamId} +
  • + ); + } + + const grade = syncGrade(result); + const isPrimary = result.role === "primary"; + + return ( +
  • + + {streamId} + {isPrimary ? ( + (REF) + ) : ( + <> + + {result.offset_ms != null + ? `${result.offset_ms > 0 ? "+" : ""}${result.offset_ms.toFixed(1)}ms` + : ""} + + {result.confidence != null && ( + + {Math.round(result.confidence * 100)}% + + )} + + )} +
  • + ); +} + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} + +function deriveOverallGrade(report: SyncReport): string { + const secondaryStreams = Object.values(report.streams).filter( + (s) => s.role !== "primary", + ); + if (secondaryStreams.length === 0) return "primary"; + + const confidences = secondaryStreams + .map((s) => s.confidence ?? 0) + .filter((c) => c > 0); + if (confidences.length === 0) return "fair"; + + const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length; + if (avg >= 0.8) return "excellent"; + if (avg >= 0.6) return "good"; + if (avg >= 0.4) return "fair"; + return "poor"; +} diff --git a/src/syncfield/viewer/frontend/src/components/segment-control.tsx b/src/syncfield/viewer/frontend/src/components/segment-control.tsx new file mode 100644 index 0000000..59254bb --- /dev/null +++ b/src/syncfield/viewer/frontend/src/components/segment-control.tsx @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; + +export type ViewMode = "record" | "review"; + +interface SegmentControlProps { + mode: ViewMode; + onChange: (mode: ViewMode) => void; +} + +export function SegmentControl({ mode, onChange }: SegmentControlProps) { + return ( +
    + + +
    + ); +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-drift-data.ts b/src/syncfield/viewer/frontend/src/hooks/use-drift-data.ts new file mode 100644 index 0000000..6ca4a7b --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-drift-data.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FrameMapEntry } from "@/lib/review-types"; + +export interface DriftData { + /** Frame indices (x-axis). */ + frames: number[]; + /** Max |delta_ms| across all streams per frame before correction. */ + beforeDrift: number[]; + /** Max |delta_ms| across all streams per frame after correction. */ + afterDrift: number[]; + /** Improvement percentage: (1 - meanAfter / meanBefore) * 100. */ + improvementPct: number; +} + +interface UseDriftDataReturn { + /** Processed drift data for charting, or null while loading. */ + driftData: DriftData | null; + /** Whether the frame map is being loaded. */ + isLoading: boolean; +} + +/** + * REST hook for fetching and processing the frame map into drift data. + * + * Fetches `GET /api/episodes/{id}/frame-map` (JSONL format, one JSON + * object per line) and computes per-frame max drift before and after + * sync correction. The "before" drift uses the raw offset between + * original frame timing and primary time; the "after" drift uses the + * post-correction `delta_ms` values from the frame map. + */ +export function useDriftData(episodeId: string | null): UseDriftDataReturn { + const [driftData, setDriftData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const fetchDriftData = useCallback(async () => { + if (!episodeId) { + setDriftData(null); + return; + } + setIsLoading(true); + try { + const res = await fetch(`/api/episodes/${episodeId}/frame-map`); + if (!res.ok) { + setDriftData(null); + return; + } + const text = await res.text(); + const lines = text.trim().split("\n").filter(Boolean); + const entries: FrameMapEntry[] = lines.map( + (line) => JSON.parse(line) as FrameMapEntry, + ); + + if (entries.length === 0) { + setDriftData(null); + return; + } + + const frames: number[] = []; + const beforeDrift: number[] = []; + const afterDrift: number[] = []; + + for (const entry of entries) { + frames.push(entry.frame); + + const streamValues = Object.values(entry.streams); + // After correction: max |delta_ms| across all streams for this frame + const maxAfter = + streamValues.length > 0 + ? Math.max(...streamValues.map((s) => Math.abs(s.delta_ms))) + : 0; + afterDrift.push(maxAfter); + + // Before correction: use original_frame offset as a proxy + // The delta_ms in the frame map is the post-correction residual. + // For "before", we estimate from the difference between the + // original frame index and the mapped frame index, scaled by + // the frame's time step. + const maxBefore = + streamValues.length > 0 + ? Math.max( + ...streamValues.map((s) => + Math.abs(s.delta_ms + (entry.frame - s.frame) * (1000 / 30)), + ), + ) + : 0; + beforeDrift.push(maxBefore); + } + + // Compute improvement + const meanBefore = + beforeDrift.reduce((a, b) => a + b, 0) / beforeDrift.length; + const meanAfter = + afterDrift.reduce((a, b) => a + b, 0) / afterDrift.length; + const improvementPct = + meanBefore > 0 ? (1 - meanAfter / meanBefore) * 100 : 0; + + setDriftData({ frames, beforeDrift, afterDrift, improvementPct }); + } catch { + setDriftData(null); + } finally { + setIsLoading(false); + } + }, [episodeId]); + + useEffect(() => { + void fetchDriftData(); + }, [fetchDriftData]); + + return { driftData, isLoading }; +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-episode.ts b/src/syncfield/viewer/frontend/src/hooks/use-episode.ts new file mode 100644 index 0000000..184048a --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-episode.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; +import type { EpisodeDetail } from "@/lib/review-types"; + +interface UseEpisodeReturn { + /** Episode detail, or null while loading / on error. */ + episode: EpisodeDetail | null; + /** Whether the episode detail is being loaded. */ + isLoading: boolean; + /** Error message from the last failed fetch, if any. */ + error: string | null; + /** Re-fetch the episode detail. */ + refresh: () => Promise; +} + +/** + * REST hook for a single episode's detail. + * + * Fetches `GET /api/episodes/{id}` on mount and whenever `episodeId` + * changes. Exposes a `refresh()` callback for manual re-fetching. + */ +export function useEpisode(episodeId: string | null): UseEpisodeReturn { + const [episode, setEpisode] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!episodeId) { + setEpisode(null); + return; + } + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/episodes/${episodeId}`); + if (!res.ok) { + throw new Error(`Failed to fetch episode (${res.status})`); + } + const data: EpisodeDetail = await res.json(); + setEpisode(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to fetch episode", + ); + setEpisode(null); + } finally { + setIsLoading(false); + } + }, [episodeId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { episode, isLoading, error, refresh }; +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-episodes.ts b/src/syncfield/viewer/frontend/src/hooks/use-episodes.ts new file mode 100644 index 0000000..6ff83f5 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-episodes.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import type { EpisodeSummary } from "@/lib/review-types"; + +interface UseEpisodesReturn { + /** List of episodes from the most recent fetch. */ + episodes: EpisodeSummary[]; + /** Whether the episode list is being loaded. */ + isLoading: boolean; + /** Error message from the last failed fetch, if any. */ + error: string | null; + /** Re-fetch the episode list. */ + refresh: () => Promise; +} + +/** + * REST hook for the episode list. + * + * Fetches `GET /api/episodes` on mount and exposes a `refresh()` + * callback for manual re-fetching. + */ +export function useEpisodes(): UseEpisodesReturn { + const [episodes, setEpisodes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const res = await fetch("/api/episodes"); + if (!res.ok) { + throw new Error(`Failed to fetch episodes (${res.status})`); + } + const data: EpisodeSummary[] = await res.json(); + setEpisodes(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch episodes"); + setEpisodes([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { episodes, isLoading, error, refresh }; +} diff --git a/src/syncfield/viewer/frontend/src/hooks/use-playback.ts b/src/syncfield/viewer/frontend/src/hooks/use-playback.ts new file mode 100644 index 0000000..2fb3607 --- /dev/null +++ b/src/syncfield/viewer/frontend/src/hooks/use-playback.ts @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UsePlaybackReturn { + /** Current playback time in seconds. */ + currentTime: number; + /** Total duration of the primary video in seconds. */ + duration: number; + /** Whether the video is currently playing. */ + isPlaying: boolean; + /** Current playback rate (e.g. 1.0, 0.5, 2.0). */ + playbackRate: number; + /** Start playback. */ + play: () => void; + /** Pause playback. */ + pause: () => void; + /** Toggle play/pause. */ + toggle: () => void; + /** Seek to a specific time in seconds. */ + seek: (time: number) => void; + /** Set the playback rate. */ + setPlaybackRate: (rate: number) => void; + /** Ref to attach to the primary
    + + ); } + +function NavLink({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** @deprecated Use NavLinks instead */ +export function SegmentControl({ mode, onChange }: NavLinksProps) { + return ; +} From 6ff054460614fd55057496a7ada3805b7fb7bf21 Mon Sep 17 00:00:00 2001 From: styu12 Date: Fri, 10 Apr 2026 21:17:18 -0700 Subject: [PATCH 11/42] fix(viewer): global cursor:pointer for buttons, refined header nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - globals.css: cursor:pointer on all interactive elements (button, select, checkbox, links), cursor:not-allowed on disabled - Header nav: minimal underline indicator style instead of background pill — active link gets a 2px bottom bar, inactive is muted/60 with hover transition. Tighter tracking for professional feel. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/segment-control.tsx | 26 +++++++------------ .../viewer/frontend/src/styles/globals.css | 15 +++++++++++ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/syncfield/viewer/frontend/src/components/segment-control.tsx b/src/syncfield/viewer/frontend/src/components/segment-control.tsx index 4c89c8f..67f903b 100644 --- a/src/syncfield/viewer/frontend/src/components/segment-control.tsx +++ b/src/syncfield/viewer/frontend/src/components/segment-control.tsx @@ -9,17 +9,11 @@ interface NavLinksProps { export function NavLinks({ mode, onChange }: NavLinksProps) { return ( -