Skip to content

Architecture

System Overview

SprintLab is split into two independent processes — a React single-page app in the browser and a Python FastAPI server running locally. They communicate over HTTP: the frontend POSTs a video file and the backend streams results back as Server-Sent Events.

┌──────────────────────────────────────────────────────────────────┐
│  Browser (React SPA)                                             │
│                                                                  │
│  ┌───────────────────────────────────────┐  ┌───────────────┐   │
│  │  Viewport (orchestrator)              │  │  VideoContext  │   │
│  │  ┌──────────────────────────────────┐ │  │  shared state │   │
│  │  │ useVideoPlayback · useZoomPan    │ │  │  + metrics    │   │
│  │  │ useCalibration · useMeasurements │ │  └──────┬────────┘   │
│  │  │ useSprintMarkers · useCoM        │ │         │            │
│  │  │ useTrimCrop                      │ │         │            │
│  │  └──────────────────────────────────┘ │         │            │
│  └───────────────┬───────────────────────┘         │            │
│                  │                                  │            │
│  ┌───────────────┴───────┐                         │            │
│  │  Telemetry (shell)    │─────────────────────────┘            │
│  │  ContactsTab · CoMTab │                                      │
│  │  JointRow · Sparkline │                                      │
│  └───────────────────────┘                                      │
│                  │                                               │
│       useSprintMetrics (hook)                                    │
│            sprintMath.ts (pure)                                  │
│                  │                                               │
│       POST /infer/video  ←→  SSE stream                         │
└──────────────────┼───────────────────────────────────────────────┘

┌──────────────────▼───────────────────────────────────────────────┐
│  Backend (FastAPI, localhost:8000)                                │
│                                                                  │
│  GET  /health          readiness probe                           │
│  POST /infer/video     SSE: progress events + result             │
│                                                                  │
│  OpenCV → frame extraction                                       │
│  RTMLib Wholebody3d → 133 keypoints per frame                    │
│  ONNX Runtime → CPU inference                                    │
└──────────────────────────────────────────────────────────────────┘

Data Flow

1. Upload → Inference

The user uploads a video. The frontend reads it as a Blob and POSTs it to POST /infer/video. The backend opens the file with OpenCV and runs every frame through RTMLib's Wholebody3d model.

Two SSE event types are emitted:

Event typeWhenPayload
progressAfter each frame{ frame, total, pct, fps, elapsed, eta }
resultOnce, at the end{ fps, frame_width, frame_height, total_frames, n_kpts, frames }

The frontend shows the progress events in a status bar. When the result event arrives, keypoints are stored in a Map<frameIdx, Keypoint[]> inside VideoContext.

2. Keypoint Wire Format

Each entry in result.frames is a flat number[] of length n_kpts × 6:

[ x0, y0, s0,  x1, y1, s1,  ...  xN, yN, sN,   ← 2D coords + score (n_kpts × 3)
  x0, y0, z0,  x1, y1, z1,  ...  xN, yN, zN ]   ← 3D coords (n_kpts × 3)

The frontend splits at index n_kpts × 3 to recover 2D and 3D arrays separately. SprintLab currently uses only the 2D keypoints for all metric computation.

3. Metrics Computation

Once inference finishes, the useSprintMetrics hook runs a single useMemo pass over all frames. The hook:

  1. Extracts per-landmark point arrays using a confidence threshold (score ≥ 0.35)
  2. Detects ground contact windows for both feet
  3. Computes interior joint angles, segment inclinations, and thigh angles for every frame
  4. Applies box smoothing and central-difference differentiation to produce velocity and acceleration series
  5. Builds the CoM trajectory and integrates speed to get cumulative distance

All pure math lives in sprintMath.ts — framework-free TypeScript with no React dependencies.

4. Rendering

The Viewport renders multiple canvas overlays stacked on top of the video element:

OverlayPurpose
PoseOverlayDraws the 133-keypoint skeleton on each frame
CalibrationOverlayInteractive line-drawing tool for pixel-to-metre scaling
MeasurementOverlayFreehand distance and angle measurements
CropOverlayBox selection for video crop

The Telemetry panel is a thin tab shell that reads from VideoContext and delegates rendering to sub-components (ContactsTab, CoMTab, JointRow, Sparkline). A playhead drawn inside each sparkline tracks the current video frame.

Timeline

The Timeline component replaces the simple scrubber bar with a multi-lane, zoomable timeline. It reads ground contacts, CoM events, speed data, and sprint markers directly from VideoContext. Four lanes are stacked vertically:

LaneContent
Frame rulerAdaptive tick marks + frame numbers (interval adjusts to zoom level)
Contacts (GC)Coloured blocks — green for left foot, orange for right foot
Events (EV)Violet dots for CoM events, sky/red triangles for sprint start/finish
Speed (SPD)SVG polyline of horizontal CoM speed

A vertical playhead spans all lanes. When zoomed in, a minimap bar below the timeline shows the visible region within the full clip. The control section auto-sizes to its content rather than using a fixed height.

Command Palette

Ctrl+K opens a searchable command palette (powered by cmdk) that provides access to all major actions without reaching for buttons. Actions are grouped into categories:

GroupExamples
NavigationGo to any workflow stage (also 15 keys)
PlaybackPlay/pause, step, jump, set speed
ToolsStart calibration, measure distance/angle, toggle pose, trim & crop
SprintSet/clear start frame, reset sprint analysis
ViewToggle telemetry, pose panel, measurement panel
FileUpload video

Global keyboard shortcuts are handled by useKeyboardShortcuts, which reads from CommandContext. The Header shows a clickable Ctrl+K badge so new users can discover the palette.

Stage-Based Workflow

The UI is organised around five sequential stages. A StageBar at the top of the control section shows progress:

StagePurposeCompletion condition
ImportLoad a video fileVideo metadata exists
CalibrateSet a scale reference (pixels → metres)Calibration data set
AnalyseRun pose estimationPose status = ready
MeasurePlace sprint markers, take measurementsAny measurement, contact, or marker exists
ReportReview telemetry, trim/crop, exportN/A (viewing stage)

Stages are navigational, not gatekeeping — users can click any unlocked stage tab. Controls outside the active stage are dimmed (40% opacity) but remain interactive. The stage auto-advances from Import → Calibrate when a video is loaded.

State Management

SprintLab uses four React Contexts rather than a global state library:

UIContext

Manages the workflow stage and cross-component UI state:

  • stage — currently active stage tab (import | calibrate | analyse | measure | report)
  • completion — per-stage boolean flags derived from live state
  • hasVideo — whether a video file has been loaded (gates stage accessibility)

VideoContext

The central data store. Holds:

  • Video metadata (fps, total frames, frame dimensions)
  • Current frame index
  • Calibration data (pixelsPerMeter, aspectRatio)
  • Computed SprintMetrics (output of useSprintMetrics)
  • Ground contact events (detected + manually added)
  • Sprint markers (start frame, finish frame, mode)

PoseContext

A minimal context for pose processing status: idle | loading | ready | error. Used by the Telemetry panel to decide what empty state to display.

CommandContext

A lightweight action registry. Components register named callbacks (e.g. toggle-play, start-calibration) and the CommandPalette and useKeyboardShortcuts hook read from this registry. This decouples action producers (Viewport, Dashboard) from consumers (palette, hotkeys) without adding more fields to VideoContext.

Why four contexts?

VideoContext is large — it covers video state, calibration, and metrics all in one place to avoid deeply nested prop drilling. PoseContext is kept separate because its state is only relevant to a small number of components and changes at a different lifecycle (only during inference). UIContext owns presentation-layer state (stage navigation, completion indicators) that multiple components need but that doesn't belong in the data-oriented VideoContext. CommandContext is a thin action bus — it only stores function references, not state, and avoids coupling the command palette to every component it can control.

Visual System

Typography

Two-font system: Figtree Variable (font-sans) for UI text (labels, headers, buttons) and TheSansMonoSCd (font-mono) for data (readouts, timecodes, tables, sparkline tooltips). The base font is Figtree; data-heavy elements opt in to mono via Tailwind's font-mono class.

Stage Accent Colors

Each workflow stage has a unique accent color used in the StageBar tab, active badge, and indicator bar:

StageColor
ImportSky
CalibrateAmber
AnalyseViolet
MeasureEmerald
ReportOrange

Accent tokens are exported from UIContext as STAGE_ACCENT for use across components.

Depth & Glassmorphism

Panels use bg-white/80 dark:bg-zinc-950/80 backdrop-blur-sm for a frosted-glass effect. The control section casts an upward shadow (shadow-[0_-2px_8px_...]) to separate it from the viewport. Overlay side panels (PosePanel, MeasurementPanel, TrimCropPanel) use backdrop-blur-sm with 95% opacity backgrounds.

Micro-interactions

All icon buttons and stage tabs use active:scale-90 (or active:scale-95 for stage tabs) for tactile press feedback. Transitions use duration-150 for responsiveness.

Design Decisions

SSE instead of WebSockets

Inference is a one-way push: the backend sends data, the frontend only receives. SSE is simpler and more appropriate than WebSockets for this pattern — it uses plain HTTP, works through proxies, and reconnects automatically.

Client-side video processing

Video trimming, cropping, and export use FFmpeg.js (compiled to WebAssembly). This means no video data is sent to any server for those operations — the entire editing pipeline runs in the browser. This is both faster (no upload round-trip) and more privacy-preserving.

Pure math layer

All biomechanics computation is extracted into sprintMath.ts — a file with zero React dependencies. This makes the math fully unit-testable with Vitest without any DOM or component setup. See the Math Reference for the full equations.

Confidence thresholding

Keypoints with a score below 0.35 are treated as null. Null gaps in time series are forward-filled then backward-filled before smoothing, so a briefly occluded joint doesn't break the derivative chain.

Released under the MIT License.