From e6ff690a1a890c9e5d0cd5af552a30f869016870 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 10:04:58 +0200
Subject: [PATCH 01/29] feat: add motion capture posture analysis PRD
---
docs/prd-mocap-posture.md | 154 ++++++++++++++++++++++++++++++++++++++
1 file changed, 154 insertions(+)
create mode 100644 docs/prd-mocap-posture.md
diff --git a/docs/prd-mocap-posture.md b/docs/prd-mocap-posture.md
new file mode 100644
index 0000000..3cfa08d
--- /dev/null
+++ b/docs/prd-mocap-posture.md
@@ -0,0 +1,154 @@
+# PRD: Motion-capture posture analysis (freemocap integration)
+
+## Problem Statement
+
+I row indoors and the app already gives me deep performance analytics from SmartRow CSV (pace, power, stroke rate, PRs, training load). But the data is blind to **how I move**. Bad posture and stroke sequencing silently cap my power output, waste effort, and risk injury (lower-back rounding at the catch, early arm bend, opening the back too soon, asymmetric drive). I have no coach watching me. I want the app to see me row and tell me what's wrong with my technique — both while I'm rowing and after the session, alongside the metrics I already track.
+
+## Solution
+
+Integrate markerless motion capture so the app analyzes the rower's posture and stroke mechanics from video, derives per-stroke biomechanical metrics, detects posture faults, and delivers actionable coaching cues. Two capture paths:
+
+1. **Browser path (default)** — single webcam, in-browser MediaPipe Pose. Zero install. Live feedback during rowing, plus replay after.
+2. **Sidecar path (precision)** — local Python sidecar running [freemocap](https://github.com/freemocap/freemocap) with one or more cameras for 3D-accurate skeletons. For users who want serious technique work.
+
+Pose data is stored raw (keypoints per frame) and aligned to existing `RowingSession` / `StrokeData` so users can scrub video, see skeleton overlay, see flagged faults at exact stroke phases, and watch posture trends evolve next to power/pace trends.
+
+## User Stories
+
+1. As a rower, I want to start a webcam-based mocap session in one click, so that I can get posture feedback without installing software.
+2. As a rower, I want the app to detect my rowing machine in frame and confirm I'm positioned correctly, so that capture quality is good before I start.
+3. As a rower, I want a calibration step (sit at catch, sit at finish), so that the analyzer knows my anatomy and machine geometry.
+4. As a rower, I want live audio + visual cues during rowing ("back rounded", "arms bent early", "slow down recovery"), so that I can correct in real time.
+5. As a rower, I want live cues to be quiet and non-nagging by default, so that they don't break my flow.
+6. As a rower, I want every stroke segmented into catch / drive / finish / recovery automatically, so that faults are reported at the exact phase they occur.
+7. As a rower, I want per-stroke posture metrics computed (back angle at catch, shin vertical, hip-knee timing offset, layback angle, sequencing delay, left-right asymmetry), so that I have objective measurements not just opinions.
+8. As a rower, I want detected faults categorized by severity (info / warning / critical), so that I focus on the worst issues first.
+9. As a rower, I want a post-session posture replay screen, so that I can review my session video with skeleton overlay and fault annotations.
+10. As a rower, I want the replay timeline aligned with my SmartRow `StrokeData`, so that I can correlate posture issues with power / split / stroke-rate dips.
+11. As a rower, I want to scrub to any stroke and see the skeleton frozen at catch / finish, so that I can study my position.
+12. As a rower, I want to compare a fault-heavy stroke against a clean stroke from the same session side-by-side, so that I see the contrast.
+13. As a rower, I want a coaching summary at session end (top 3 issues, frequency, suggested drills), so that I leave the session with a clear next action.
+14. As a rower, I want fault frequency tracked over time on the dashboard, so that I see whether my technique is actually improving.
+15. As a rower, I want posture metrics surfaced inside the existing AI insights system, so that the AI can correlate posture with performance regressions.
+16. As a serious user, I want to opt into the freemocap sidecar with multi-camera 3D capture, so that I get precision metrics for technical work.
+17. As a serious user, I want clear setup instructions for the sidecar (Docker / Python), so that I can install it without expert help.
+18. As a serious user, I want browser captures and sidecar captures to share the same analysis pipeline and UI, so that the experience is consistent.
+19. As a privacy-conscious user, I want all video and pose data stored locally / in my own database by default, so that my body footage is not uploaded to third parties.
+20. As a privacy-conscious user, I want explicit opt-in before any pose data is sent to cloud AI, so that I control externalization.
+21. As a rower, I want to delete a mocap session (video + pose + metrics) with one action, so that I can clean up storage.
+22. As a rower, I want to know which past `RowingSession` rows have linked mocap data, so that I can find sessions worth reviewing.
+23. As a rower, I want to attach a mocap session to an existing CSV-imported `RowingSession`, so that historical sessions can also gain video context.
+24. As a rower, I want fault thresholds to be configurable (e.g., "warn me only if back angle < 15° at catch"), so that the system adapts to my body and goals.
+25. As a rower, I want sensible default thresholds based on standard rowing technique references, so that I don't have to tune anything to start.
+26. As a rower, I want the app to flag if camera framing degrades mid-session (occlusion, low light, person leaves frame), so that I trust the metrics.
+27. As a rower, I want capture FPS, model confidence, and tracked-keypoint counts shown as quality indicators, so that I can judge whether a session's analysis is reliable.
+28. As a rower, I want webcam access permission to be requested only when I start a capture, so that the app doesn't ask for camera on every page load.
+29. As a rower, I want to record without analysis if I'm just collecting footage, so that I can defer analysis to later.
+30. As a rower, I want re-analysis on demand (e.g., after fault rules improve), so that old sessions benefit from updated detectors.
+31. As a rower, I want the existing chat/AI to answer questions about my mocap data ("why does my back round at stroke 80?"), so that posture insights are conversational.
+32. As a rower, I want training plans to incorporate posture goals ("reduce early-arm-bend faults below 10%/session"), so that technique is a first-class plan dimension.
+33. As a rower, I want posture-derived achievements ("100 strokes with clean catch"), so that good technique earns recognition same as PRs.
+34. As a rower on a phone/tablet, I want a graceful degraded mode that records video for later replay even if live analysis is too heavy, so that mobile is still useful.
+35. As a developer, I want the analysis pipeline (segment → metrics → faults) to be pure / deterministic on a frame stream, so that fixture-driven tests verify correctness.
+36. As a developer, I want browser capture and freemocap sidecar to emit the same `PoseFrameStream` schema, so that downstream code is source-agnostic.
+
+## Implementation Decisions
+
+### New deep modules
+
+- **PoseCaptureSource** — interface emitting `PoseFrameStream` (timestamped keypoint frames + confidence + source-quality signals). Two implementations:
+ - `BrowserPoseSource` — webcam + MediaPipe Pose (33-keypoint or BlazePose-Heavy) in browser, runs in Web Worker.
+ - `FreemocapSidecarSource` — WebSocket client to local freemocap sidecar. Sidecar is a separate Python service (Docker image) wrapping freemocap, exposing a streaming pose API.
+- **StrokePhaseSegmenter** — pure function: `PoseFrameStream → Stroke[]`. Each `Stroke` has phase boundaries (catch, drive-start, finish, recovery-start) and frame indices. Detection rule based on hip-knee distance / handle-position proxy / seat travel.
+- **PostureMetricsCalculator** — pure function: `Stroke → PostureMetrics`. Computes: back angle at catch, back angle at finish, layback angle, shin vertical at catch, hip-knee opening offset (drive sequence), arm-bend onset frame, left-right asymmetry index, knee track deviation.
+- **PostureFaultDetector** — pure function: `PostureMetrics + thresholds → PostureFault[]`. Severity levels: info / warning / critical. Rule-based v1; pluggable so ML model can replace later.
+- **CoachingAdvisor** — `PostureFault[] + session history → CoachingCue[]`. Rule-based default cues; cloud AI augmentation behind existing `cloudAI` gate.
+- **PostureSessionRepository** — Prisma-hidden read/write of `MocapSession`, `PoseFrame`, `StrokePostureMetric`, `PostureFault`.
+
+### Modified modules
+
+- `aiAnalysis.ts` — extend prompt context to include posture summary when mocap data present.
+- `AIInsight` generation — new `category: "posture"` insights.
+- Dashboard / `analytics` — add posture-fault-frequency-over-time card.
+- `trainingPlans` — optional posture goals on a `TrainingPlan`.
+- `awards` — posture-derived achievement criteria.
+- Chat tool surface — expose mocap query functions to AI.
+- Existing `RowingSession` lookup — surface a "has mocap" badge.
+
+### Schema additions
+
+- `MocapSession` — id, userId, rowingSessionId? (nullable, can attach to existing CSV session), videoStoragePath, source ("browser" / "sidecar"), captureModelVersion, captureFps, durationSec, qualityScore, status, createdAt.
+- `PoseFrame` — id, mocapSessionId, frameIndex, timestampMs, keypointsJson (compact array), confidenceJson, sourceFlags. Stored raw for full replay (per user choice). Indexed by `mocapSessionId, frameIndex`.
+- `StrokePostureMetric` — id, mocapSessionId, strokeIndex, phaseBoundariesJson, metricsJson (back angle catch/finish, layback, shin-vertical, sequencing offsets, asymmetry, etc.), strokeDataId? (link to existing `StrokeData` row).
+- `PostureFault` — id, mocapSessionId, strokeIndex, faultType, severity, evidenceJson (frame index + metric value + threshold), createdAt.
+- `UserSettings.postureThresholds: Json?` — user-tunable rule thresholds.
+- `UserSettings.mocapPreferences: Json?` — capture source default, live-cue verbosity, audio on/off.
+
+### Architectural decisions
+
+- **Pose source abstraction is the deep boundary.** Browser MediaPipe and freemocap sidecar both produce identical `PoseFrameStream` shape. All downstream analysis is source-agnostic. This is the core deepening play.
+- **Analysis is pure.** `StrokePhaseSegmenter`, `PostureMetricsCalculator`, `PostureFaultDetector` are pure functions over data structures. No I/O, no DB. Tested with fixture frame streams.
+- **Live and replay share the pipeline.** Live mode runs the same segmenter/metrics/detector incrementally as frames arrive; replay runs them on the stored stream. No duplicate logic.
+- **Storage contract.** Raw `PoseFrame` rows are large but enable full replay + re-analysis when rules improve. Stored in Postgres (JSONB columns); video file stored on existing storage backend (`storage/` dir or Vercel Blob in deployed env). User may purge raw frames per session if storage pressure rises (derived metrics retained).
+- **Sidecar contract.** freemocap sidecar is an opt-in local Docker service. Communicates via WebSocket on localhost. Versioned schema. App degrades cleanly if sidecar is offline.
+- **Privacy.** Video + pose data are user-scoped, never sent to cloud unless cloud-AI is explicitly enabled in `UserSettings.cloudAIEnabled`. Coaching cues by default run on local rules.
+- **Frame budget.** Browser path targets ≥ 24 fps on a mid-tier laptop. Heavier work (full re-analysis, summaries) deferred to post-session. Mobile falls back to record-only when CPU is insufficient.
+- **Calibration.** Two-pose calibration (catch + finish) at session start establishes per-user joint baselines. Stored on user profile. Re-runnable.
+- **Coordinate alignment.** Browser path = 2D side view + heuristics. Sidecar path = 3D. Metric extraction respects whichever is available; faults that require 3D are skipped on 2D path with a clear UI marker.
+- **Stroke alignment with SmartRow.** When a `RowingSession` (CSV) is linked, the segmenter's stroke timeline is aligned to `StrokeData` timestamps via cross-correlation; metrics get joined to `StrokeData` rows.
+
+### API contracts
+
+- `POST /api/mocap/sessions` — create new mocap session (browser uploads chunked video + pose stream, or sidecar streams directly).
+- `GET /api/mocap/sessions/:id` — full mocap detail incl. metrics + faults + frame index.
+- `POST /api/mocap/sessions/:id/reanalyze` — re-run pipeline with current rules.
+- `POST /api/mocap/sessions/:id/link/:rowingSessionId` — attach mocap to existing CSV session.
+- `DELETE /api/mocap/sessions/:id` — cascade delete frames, metrics, faults, video.
+- WebSocket `/api/mocap/live` — live capture stream from browser; server emits incremental faults / cues.
+- Sidecar local URL configurable in settings; health-check endpoint required.
+
+## Testing Decisions
+
+A good test verifies external behavior of a deep module given a fixed input. It does not assert on private structure, intermediate variables, or implementation choices. The pose pipeline is uniquely well-suited because the core modules are pure functions over data.
+
+### Modules to test
+
+- **StrokePhaseSegmenter** — fixture: synthetic and recorded `PoseFrameStream` files representing clean strokes, missed catch, paused recovery, asymmetric drive. Assert: correct stroke count, phase boundary frame indices within tolerance.
+- **PostureMetricsCalculator** — fixture: hand-labeled `Stroke` with known geometry. Assert: computed angles within ±2° of ground truth.
+- **PostureFaultDetector** — fixture: `PostureMetrics` instances crafted to cross / not-cross thresholds. Assert: correct fault types and severities emitted; no false positives on clean reference.
+
+### Modules NOT tested at unit level
+
+- UI views (`LiveCoachingView`, `PostureReplayView`, `PostureTrendsCard`) — covered by manual QA + Playwright smoke tests for golden path.
+- `BrowserPoseSource` / `FreemocapSidecarSource` — thin adapters; correctness depends on external libs / services. Smoke-test only.
+- `PostureSessionRepository` — covered indirectly by API integration tests.
+
+### Prior art
+
+- Existing analytics tests (if present in `src/lib`) for trend computation patterns.
+- Existing parser tests around `csvParser.ts` / `strokeParser.ts` style: pure-function over fixture data.
+
+### Test fixtures
+
+- Bundle 5–10 short pose recordings (≤ 30 s each) covering: clean rowing, early arm bend, rounded back, slow recovery, asymmetry, lost tracking. Stored as JSON `PoseFrameStream` snapshots in the test fixture directory.
+
+## Out of Scope
+
+- Multi-user / coach views over mocap data.
+- Public sharing of mocap video or skeleton clips.
+- Smartphone-only mode as primary target (mobile is degraded record-only).
+- Hardware sensors (IMU on seat, force sensors on handle / footplate).
+- Strava / TrainingPeaks export of posture data.
+- ML-trained fault detectors (v1 is rule-based; ML pluggable later).
+- On-water rowing capture.
+- Real-time streaming of mocap to a remote coach.
+- Custom freemocap installation flows beyond the documented Docker sidecar.
+
+## Further Notes
+
+- freemocap upstream is GPL-licensed Python. Sidecar runs as separate process; no GPL code is linked into Next.js app. Confirm license interaction during implementation.
+- Browser MediaPipe Pose is Apache-2.0 and ships as JS package — no licensing concern.
+- Phase 1 ships browser path + analysis pipeline + replay UI + dashboard widget. Phase 2 ships freemocap sidecar + 3D-aware metrics. Phase 3 ships AI-augmented cues + posture-aware training plans + posture achievements.
+- Deep-module boundary (`PoseFrameStream` as universal contract between capture and analysis) is the key bet — lets source change without rewriting analysis, and lets analysis evolve without touching capture. Contract reviewed and stabilized first.
+- Performance baseline must be measured early on target dev machine; if browser pipeline cannot hit 24 fps, live-cue feature ships as post-stroke (≤ 1 s lag) instead of intra-stroke.
+- Storage growth: raw `PoseFrame` rows ~2–4 KB per frame × 24 fps × 30 min = ~100 MB per session worst-case. Retention policy (auto-purge raw frames after N days, keep metrics + video) is a likely follow-up.
From f4e3604c0cd37af2c4eb168994110d21a73ec992 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 11:03:35 +0200
Subject: [PATCH 02/29] docs(mocap): add CONTEXT, ADRs, and resolved-decisions
section
Establish the canonical mocap domain vocabulary and the architectural
decisions that issue #9 and follow-ups build on.
- CONTEXT.md: glossary entries for CapturePerspective,
StrokeSegmentationSource, MocapSession, CueLatencyBand,
PoseFrameStream, PostureFault (v1 catalog), FaultThresholds,
Calibration.
- ADR-0001: store raw PoseFrameStream as a binary blob, not Postgres
JSONB. Drops the per-frame Prisma table from the PRD draft.
- ADR-0002: defer sidecar contract to Phase 2. v1 = browser-2D only.
- ADR-0003: run analysis pipeline in the browser. WebSocket
/api/mocap/live is persistence-only; server runs no analysis during
capture.
- ADR-0004: cloud-AI payload tiering for mocap data.
- PRD: append "Resolved Decisions" section linking ADRs and locking
v1 ship scope (US 1-11, 13, 19-30, 35, 36 ship; US 12, 14-18,
31-34 deferred to Phase 2).
These are referenced by issue #9 and must land before any mocap
implementation diff can be reviewed coherently.
Co-Authored-By: Claude Opus 4.7
---
CONTEXT.md | 84 +++++++++++++++++++
.../0001-pose-frame-stream-as-binary-blob.md | 56 +++++++++++++
.../0002-defer-sidecar-contract-to-phase-2.md | 47 +++++++++++
.../0003-browser-side-analysis-pipeline.md | 54 ++++++++++++
docs/adr/0004-cloud-ai-mocap-payload-tiers.md | 62 ++++++++++++++
docs/prd-mocap-posture.md | 31 +++++++
6 files changed, 334 insertions(+)
create mode 100644 CONTEXT.md
create mode 100644 docs/adr/0001-pose-frame-stream-as-binary-blob.md
create mode 100644 docs/adr/0002-defer-sidecar-contract-to-phase-2.md
create mode 100644 docs/adr/0003-browser-side-analysis-pipeline.md
create mode 100644 docs/adr/0004-cloud-ai-mocap-payload-tiers.md
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..f20d772
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,84 @@
+# Context
+
+Domain glossary for rowing-tracker. Terms here are canonical — use them in code, issues, and UI copy. If a concept isn't here, either it's not yet domain-load-bearing, or there's a gap to resolve via `/grill-with-docs`.
+
+## Glossary
+
+### CapturePerspective
+
+Which physical viewpoint a mocap session was recorded from. Determines which posture metrics are computable.
+
+- `side-left` — single webcam, rower's left side facing camera. Browser path.
+- `side-right` — single webcam, rower's right side facing camera. Browser path.
+- `sidecar-3d` — multi-camera 3D capture via freemocap sidecar. All metrics available.
+
+Browser path emits `side-left` or `side-right` only. Metrics that require `sidecar-3d` (left-right asymmetry, knee track deviation) are marked **unavailable** on side captures — never silently zeroed or estimated. UI surfaces the unavailable state as "requires multi-camera capture."
+
+### StrokeSegmentationSource
+
+How a mocap session's stroke phase boundaries (catch / drive / finish / recovery) were derived.
+
+- `pose-segmented` — boundaries computed from the pose stream alone (hip-knee distance signal). Used during live capture, when no CSV is available yet. Lower confidence; downstream metrics tagged accordingly.
+- `csv-aligned` — boundaries taken from `StrokeData` rows (SmartRow ground truth), with the pose stream time-aligned to them via cross-correlation. Used in post-session replay after the user imports the SmartRow CSV and links it to the mocap session.
+
+A mocap session always begins life as `pose-segmented`. When linked to a `RowingSession` (CSV import), re-segmentation to `csv-aligned` is **mandatory** — re-run metrics and faults atomically with the link. Never leave a linked session at `pose-segmented`.
+
+### MocapSession
+
+A captured rowing session containing video + pose stream + derived metrics + faults. Independent of `RowingSession` (which is CSV/erg-derived). May exist standalone (no CSV linked) or be linked to exactly one `RowingSession`.
+
+Lifecycle states (proposed): `capturing` → `analyzing` → `ready` → optionally `linked` (when joined to a `RowingSession`).
+
+**Linking to a `RowingSession`** is bidirectional and exclusive — one `MocapSession` is linked to at most one `RowingSession` and vice versa. Either side may be unlinked; relinking is allowed. Linking triggers mandatory re-analysis (`pose-segmented` → `csv-aligned`) as a background job; the mocap row goes back to `analyzing` until it completes. Unlinking reverts to `pose-segmented` and re-runs metrics. CSV import auto-prompts to link when a capture window overlaps a new `RowingSession` by ±2 minutes — user confirms, never silent.
+
+### CueLatencyBand
+
+When a coaching cue is delivered relative to the stroke that triggered it.
+
+- `intra-stroke` — fired mid-stroke from per-frame rules. **Out of v1 scope.** Pose-segmented stroke boundaries are too noisy for reliable real-time fault attribution within a 2.5s stroke window.
+- `post-stroke` — fired ≤1s after stroke completes. The "live coaching" experience in v1.
+- `post-session` — surfaced in replay / coaching summary after capture ends.
+
+v1 fault detector runs at the **stroke** granularity only — one pass per closed stroke. No per-frame fault rule path.
+
+### PoseFrameStream
+
+A timestamped sequence of keypoint frames with confidence values, produced by a `PoseCaptureSource` and consumed by the analysis pipeline.
+
+**v1 shape:** 2D side-view keypoints — `{x, y, confidence}` per keypoint, plus per-frame source-quality flags. Browser path only (see ADR-0002). The sidecar / 3D path is deferred to Phase 2; when it lands, the schema version bumps and an optional `z` channel is added without breaking existing blobs.
+
+### PostureFault (v1 catalog)
+
+Stroke-granular faults the v1 detector emits. All computable from a 2D side-view `PoseFrameStream`. Each fault is named, attributed to a stroke phase, and has severity bands defined in `FaultThresholds`.
+
+| Fault key | Phase | Severity bands |
+| --- | --- | --- |
+| `rounded_back_at_catch` | catch | warning < 30°, critical < 20° (back angle) |
+| `early_arm_bend` | drive | info / warning by frame-offset of arm-bend onset vs leg-extension completion |
+| `back_opens_before_legs_drive` | drive | warning if torso angle changes before legs start extending |
+| `excessive_layback` | finish | info > 30°, warning > 45° (torso past vertical) |
+| `slow_recovery_ratio` | recovery | warning > 2.5, critical > 3.5 (recovery / drive duration ratio) |
+
+**Excluded from v1**, surfaced as "metric available, detection deferred" or "requires multi-camera capture":
+
+- `left_right_asymmetry` — needs front view or `sidecar-3d`
+- `knee_track_deviation` — needs front view or `sidecar-3d`
+- `shin_not_vertical_at_catch` — disambiguating near-side shin from far-side shin in 2D is unreliable
+
+This catalog is the canonical vocabulary. Test fixtures, threshold tuning, coaching cue copy, and AI prompt context all reference these exact keys. Anything outside this list is out of v1 scope.
+
+### FaultThresholds
+
+The numeric bands a `PostureFault` rule fires against (e.g. "back angle at catch < threshold → rounded-back fault"). Stored on `UserSettings.postureThresholds: Json?`.
+
+**Defaults are hand-coded, conservative, and versioned in code** (`postureThresholdsV1`, `postureThresholdsV2`, …). Each default carries a source comment citing rowing-technique references. Conservative bands = wide tolerances, fewer false positives, fewer angry users in v1.
+
+Migration: when a new defaults version ships, users who haven't touched their thresholds upgrade automatically. Users with `userOverridden: true` keep their custom values; never stomp explicit customisation.
+
+### Calibration
+
+A pair of reference pose frames captured before recording starts: one at **catch** position, one at **finish** position. Used as pixel-space baselines for downstream metric calculations (e.g. "back angle delta from this rower's own catch baseline").
+
+**Stored per `MocapSession`, not per `User`.** 2D side-view baselines are only meaningful for the camera setup that produced them — angle, distance, and framing change between sessions even when the rower doesn't. Storing on the user would imply a stable camera position the system can't verify. Recapture (~10 s) is required at the start of each session.
+
+**Storage:** persisted as one binary blob per `MocapSession`, alongside the video file (see ADR-0001). Not a Postgres table. The `MocapSession` row points at it via `poseStreamPath`. Header carries `fps`, `keypointSchemaVersion`, `frameCount`. Random access by frame index = byte-range read.
diff --git a/docs/adr/0001-pose-frame-stream-as-binary-blob.md b/docs/adr/0001-pose-frame-stream-as-binary-blob.md
new file mode 100644
index 0000000..e2c5463
--- /dev/null
+++ b/docs/adr/0001-pose-frame-stream-as-binary-blob.md
@@ -0,0 +1,56 @@
+# ADR-0001: Store raw PoseFrameStream as a binary blob, not Postgres JSONB
+
+**Status:** Accepted
+**Date:** 2026-05-08
+**Context owner:** mocap posture analysis (see `docs/prd-mocap-posture.md`)
+
+## Context
+
+Mocap sessions produce a stream of pose keypoint frames at ~24 fps. A 30-minute session yields ~43,200 frames at ~2-4 KB each — roughly 100 MB of raw pose data per session, on top of the recorded video.
+
+The original PRD draft proposed a `PoseFrame` table in Postgres with a JSONB `keypointsJson` column, indexed on `(mocapSessionId, frameIndex)`. This would let downstream code load any frame via Prisma the same way other entities are loaded.
+
+We considered the actual access patterns:
+
+- **Re-analysis** — stream all frames of one session through the pure pipeline. Sequential.
+- **Replay scrubbing** — random access to a frame by index. Localised reads.
+- **Stroke window read** — load all frames between two `frameIndex` values. Contiguous range.
+- **Cross-session ad-hoc SQL on individual frames** — none. All queries that span sessions operate on derived rows (`StrokePostureMetric`, `PostureFault`), not raw frames.
+
+There are no query patterns that benefit from per-frame SQL. The JSONB design optimises for a use case that doesn't exist.
+
+## Decision
+
+Store the raw `PoseFrameStream` for each `MocapSession` as a **single binary blob** on the same storage backend as the recorded video — local `storage/` directory in development, Vercel Blob in deployed environments. The `MocapSession` row holds a `poseStreamPath: string` pointing at it.
+
+Format: packed Float32 array of keypoints, prefixed by a small header carrying `{ fps, keypointSchemaVersion, frameCount, keypointsPerFrame }`. Random access by frame index = byte-range read at `header_size + frameIndex * frame_stride`.
+
+Postgres holds only the **derived** rows: `StrokePostureMetric`, `PostureFault`, plus the `MocapSession` row itself. These are small, indexable, queryable, and cheap to back up.
+
+Re-analysis streams the blob through the pipeline and rewrites the derived rows atomically. Scrubbing fetches byte-ranges. The Prisma schema stays compact.
+
+## Consequences
+
+**Positive**
+
+- Postgres footprint stays proportional to derived data, not raw capture volume. No 43k-row inserts per session, no JSONB TOAST bloat, no expensive vacuum.
+- Re-analysis is a sequential file read instead of a 43k-row Prisma scan.
+- Backups, replication, and DB restore stay cheap as mocap usage grows.
+- Pose-stream blob lives next to the video on the same storage backend, so retention / purge / quota logic is one decision, not two.
+
+**Negative**
+
+- A custom serialiser is required (header + packed Float32 layout, version field). Schema evolution of the keypoint format requires a versioned reader.
+- No ad-hoc SQL on raw frames. If a future need emerges (unlikely, but e.g. cross-session frame-level statistics), it would mean adding an index/materialisation layer on top of the blobs.
+- Storage backend abstraction must support byte-range reads (Vercel Blob does; local filesystem does trivially).
+
+**Neutral**
+
+- A user-initiated "purge raw frames, keep metrics" action becomes "delete the blob"; derived rows are untouched. This matches the PRD's footnote about retention pressure.
+- The `PoseFrame` Prisma model in the PRD's schema-additions section is dropped; replaced by `MocapSession.poseStreamPath`.
+
+## Alternatives considered
+
+- **Postgres JSONB row-per-frame (the PRD's original proposal).** Rejected for the bloat / vacuum / re-analysis cost reasons above, given that no query pattern needs per-frame SQL.
+- **Parquet / Arrow file format instead of packed Float32.** More tooling-friendly, but adds a dependency for a one-shape data stream where a 32-byte header + flat array is sufficient. Revisit if cross-session analytics on raw frames ever becomes a real need.
+- **Object store with one file per frame.** Random access via filename, but 43k tiny files per session is a worse storage pattern than one ~100 MB blob.
diff --git a/docs/adr/0002-defer-sidecar-contract-to-phase-2.md b/docs/adr/0002-defer-sidecar-contract-to-phase-2.md
new file mode 100644
index 0000000..065dbe9
--- /dev/null
+++ b/docs/adr/0002-defer-sidecar-contract-to-phase-2.md
@@ -0,0 +1,47 @@
+# ADR-0002: Defer freemocap sidecar contract design to Phase 2
+
+**Status:** Accepted
+**Date:** 2026-05-08
+**Context owner:** mocap posture analysis (see `docs/prd-mocap-posture.md`)
+
+## Context
+
+The PRD names `PoseFrameStream` as "the universal contract between capture and analysis" and calls source-abstraction "the deepening play" — the architectural bet that lets the capture source change (browser webcam → freemocap sidecar) without rewriting the analysis pipeline.
+
+The PRD's Phase 1 ships browser-only. Phase 2 ships the sidecar. But the PRD designs the sidecar contract up front: WebSocket protocol, Docker image, health-check API, versioned schema, 3D depth-bearing keypoints.
+
+A contract designed against one real implementation and one imagined implementation is, in practice, a contract designed against the imagined one. We don't yet know:
+
+- The exact shape of keypoints freemocap emits (count, ordering, coordinate frame, confidence semantics).
+- Which fault rules actually benefit from 3D depth in practice — vs. which are answered just as well from 2D side view with calibrated heuristics.
+- Whether the live coaching path even applies to multi-camera 3D capture, or whether sidecar usage is exclusively post-session technique work.
+
+Locking those decisions now means picking guesses; widening the contract later is cheap because of ADR-0001 (the blob has a `keypointSchemaVersion` header field).
+
+## Decision
+
+In v1, design `PoseFrameStream` against the **browser path only**: 2D keypoints (x, y), per-keypoint confidence, source-quality flags. No depth field. No sidecar process, no WebSocket protocol, no Docker image, no health-check API.
+
+Treat the sidecar as not-yet-existent. Phase 2 will design its contract against the realities of freemocap's actual output and the lessons of v1's fault rules — at that point, the schema version on stored blobs bumps, the reader handles both versions, and the analysis pipeline gains a 3D-aware path where it matters.
+
+## Consequences
+
+**Positive**
+
+- One real implementation, no premature abstraction. The "interface" is just whatever shape `BrowserPoseSource` emits.
+- Faster v1: no sidecar contract review, no Docker image, no two-process integration testing.
+- Phase 2 sidecar work starts from real demand (specific fault rules that need depth) rather than a speculative interface.
+
+**Negative**
+
+- When Phase 2 lands, `PoseFrameStream` widens from `{x, y, conf}` to `{x, y, z?, conf}`. Versioned blob reader handles old captures; pipeline gains a depth-aware branch.
+- Until Phase 2, the abstraction the PRD originally called "the deepening play" doesn't exist as such — the analysis pipeline is coupled to one source. Acceptable, because there is only one source.
+
+**Neutral**
+
+- Anything in the PRD's `### API contracts` section that is sidecar-specific (sidecar local URL, sidecar health-check) is out of v1 scope. The browser API (`POST /api/mocap/sessions`, `GET /api/mocap/sessions/:id`, `POST /api/mocap/sessions/:id/reanalyze`, `POST /api/mocap/sessions/:id/link/:rowingSessionId`, `DELETE /api/mocap/sessions/:id`, `WebSocket /api/mocap/live`) stays.
+
+## Alternatives considered
+
+- **Lock both contracts now (the PRD's original plan).** Rejected. Single-implementation abstraction is an interface waiting for its second implementation — and the second implementation's real shape is unknown until freemocap is wired up.
+- **Build a stub sidecar in v1 to validate the contract.** Rejected as scope creep — a stub doesn't surface the schema mismatches that a real freemocap integration would.
diff --git a/docs/adr/0003-browser-side-analysis-pipeline.md b/docs/adr/0003-browser-side-analysis-pipeline.md
new file mode 100644
index 0000000..b8a47c4
--- /dev/null
+++ b/docs/adr/0003-browser-side-analysis-pipeline.md
@@ -0,0 +1,54 @@
+# ADR-0003: Run the analysis pipeline in the browser; server is dumb storage in live mode
+
+**Status:** Accepted
+**Date:** 2026-05-08
+**Context owner:** mocap posture analysis (see `docs/prd-mocap-posture.md`)
+
+## Context
+
+The mocap pipeline has two consumer paths:
+
+- **Live capture** — pose inference at ~24 fps, stroke segmentation, per-stroke metrics, fault detection, coaching cues delivered with `post-stroke` latency (≤1 s after stroke end; see `CueLatencyBand` in CONTEXT.md).
+- **Post-session re-analysis** — same pipeline rerun on stored frames after a `RowingSession` is linked, or after fault rules are updated.
+
+The PRD draft proposed a `WebSocket /api/mocap/live` where the server "emits incremental faults / cues." That implies server-side analysis during capture. Two issues:
+
+1. Round-tripping pose frames to a server adds tens to hundreds of milliseconds of network latency per stroke window, blowing the post-stroke budget.
+2. The server has no information the client doesn't — pose frames originate in the browser, and analysis is pure functions over those frames. There's nothing for the server to compute that the client can't.
+
+The PRD also commits to a privacy stance: "all video and pose data stored locally / in my own database by default … explicit opt-in before any pose data is sent to cloud AI." Server-side live analysis routes pose data through the server unconditionally, conflicting with that stance for users who would otherwise capture and view locally.
+
+## Decision
+
+Run the full analysis pipeline (segmenter, metrics calculator, fault detector, coaching advisor) **in the browser** during live capture. The pipeline is implemented as pure functions over `PoseFrameStream`, with no I/O, no DB calls, and no server dependency.
+
+Pose inference runs in a **Web Worker with OffscreenCanvas** using MediaPipe Tasks (WASM build). WebGPU acceleration is future-opt-in, not v1 default.
+
+`WebSocket /api/mocap/live` is for **persistence only**: the browser streams frames to the server, the server appends them to the session's blob storage. The server does not run the analysis pipeline during live capture and does not emit faults or cues over the WebSocket.
+
+The same pipeline code runs server-side on demand for **re-analysis** — `POST /api/mocap/sessions/:id/reanalyze` reads the blob, runs the pure pipeline, rewrites derived rows. That's the only server-side execution path.
+
+## Consequences
+
+**Positive**
+
+- Live `post-stroke` latency budget is achievable — no network round trip for analysis.
+- Privacy posture matches the PRD: faults and cues are computed locally; pose data only leaves the browser when the user has opted into persistence (and never reaches a third-party AI service unless `cloudAIEnabled` is set).
+- The pipeline is one codebase. Pure functions over data structures are portable: same code runs in a Web Worker live and on a Vercel Function for re-analysis.
+- If a user denies upload permission or the network drops, live coaching keeps working — the browser has everything it needs.
+
+**Negative**
+
+- Bundle size grows by the size of the analysis pipeline (segmenter, metrics, fault detector, default thresholds, coaching cue text). Mitigated by: pipeline is pure logic, no large model weights; lazy-loaded on the mocap route, not the dashboard.
+- Re-analysis on the server requires a runtime that can execute the same TypeScript modules. Vercel Functions on Node.js handle this; no isomorphic concerns for pure code.
+- If the pipeline ever needs heavy compute (ML model inference for fault detection — explicitly out of v1 scope per PRD), the browser path becomes constrained. At that point, this decision can be revisited for the heavy-compute branch only; the rule-based v1 path stays browser-side.
+
+**Neutral**
+
+- The PRD's `CoachingAdvisor` cloud-AI augmentation (behind `cloudAIEnabled`) is unaffected: that's an opt-in, post-session enrichment that already routes through existing `cloudAI.ts` infrastructure.
+
+## Alternatives considered
+
+- **Server-side analysis with live WebSocket fault stream (the PRD's draft).** Rejected for the latency and privacy reasons above.
+- **Hybrid: client computes, server validates.** Rejected as duplicate work — server has nothing to validate against. The client's frames are ground truth.
+- **Pose inference on the server (upload video, server runs MediaPipe).** Rejected: kills live latency, defeats the local-first privacy stance, and adds GPU/CPU server cost for compute the client can do.
diff --git a/docs/adr/0004-cloud-ai-mocap-payload-tiers.md b/docs/adr/0004-cloud-ai-mocap-payload-tiers.md
new file mode 100644
index 0000000..1ca15f4
--- /dev/null
+++ b/docs/adr/0004-cloud-ai-mocap-payload-tiers.md
@@ -0,0 +1,62 @@
+# ADR-0004: Cloud-AI mocap payload — fault-summary by default, detailed metrics opt-in, raw frames never
+
+**Status:** Accepted
+**Date:** 2026-05-08
+**Context owner:** mocap posture analysis (see `docs/prd-mocap-posture.md`)
+
+## Context
+
+When `UserSettings.cloudAIEnabled` is on, the existing `aiAnalysis.ts` flow sends a context payload to a third-party LLM (Anthropic/OpenAI). The mocap PRD asks for posture data to be included in that context so the AI can correlate posture with performance.
+
+Pose data is unusually sensitive among the things this app handles. A raw `PoseFrameStream` is essentially a low-resolution biometric capture of a user's body in motion — sending it to a third-party API conflicts with the project's standing privacy posture (`prd.md` §13.4.1) even when the user has enabled cloud AI for textual training data.
+
+But "cloud AI off entirely for mocap" loses the feature's biggest payoff: the LLM correlating posture faults with power/pace dips and giving narrative coaching.
+
+There are three plausible payload tiers:
+
+1. **Raw `PoseFrameStream`** — keypoints over time. Biometric. Also useless to a text LLM, which can't reason over keypoint arrays.
+2. **`StrokePostureMetric` rows** — angles, offsets, asymmetry numbers per stroke. Numeric, geometric, not directly biometric, but reconstructs a coarse body model.
+3. **`PostureFault` summary** — fault counts by type, severity, phase, plus session-level quality flags. Most compressed; most LLM-friendly; shares no body geometry.
+
+## Decision
+
+Three tiers, three policies:
+
+- **Tier 1 (raw frames):** never sent to cloud AI. No flag, no opt-in. Hard wall.
+- **Tier 3 (fault summary):** sent when `cloudAIEnabled` is true. This is the default mocap → cloud-AI payload.
+- **Tier 2 (per-stroke metrics):** sent only when both `cloudAIEnabled` AND a new `UserSettings.mocapDetailedAIShare` flag are true. Off by default.
+
+The fault-summary contract (tier 3) is fixed:
+
+```
+Mocap summary:
+- Faults: (, severity=) ...
+- Quality: % tracked, , fps
+- Strokes analyzed:
+```
+
+No keypoints, no per-frame data, no per-stroke geometry, no video. Future fault types extend this format; the contract stays additive.
+
+## Consequences
+
+**Positive**
+
+- Default cloud-AI behaviour preserves user privacy: aggregate fault counts share *no* reconstructable body geometry.
+- Power-user "share more for better insights" is one explicit toggle away — clear consent path, not buried in cloud-AI's general flag.
+- The LLM gets enough signal from tier 3 to write useful coaching ("you had 12 rounded-back faults at catch — work on lat engagement before the drive") without ever holding biometric data.
+- Hard wall on tier 1 means no future bug or refactor can accidentally leak raw pose data — the code path doesn't exist.
+
+**Negative**
+
+- Two flags (`cloudAIEnabled`, `mocapDetailedAIShare`) instead of one. Slightly more UX surface in settings.
+- Tier 3 may not be enough for the most fine-grained AI queries ("why did my back round more on stroke 80 specifically?"). Those queries require tier 2 — user opts in or the answer stays generic.
+
+**Neutral**
+
+- Existing `cloudAI.ts` and `aiAnalysis.ts` get a new context-builder for mocap that materialises only tier 3 by default; tier 2 enrichment is gated and additive.
+
+## Alternatives considered
+
+- **Single flag covering both tier 2 and tier 3.** Rejected: collapses two distinct privacy decisions ("share that I had faults" vs "share my exact body geometry") into one toggle that users can't reason about.
+- **No detailed share at all (tier 3 only, ever).** Rejected: forecloses the per-stroke AI query feature without user input. The opt-in pathway preserves it for users who want it.
+- **Anonymise tier 2 by removing identifying joint configuration.** Rejected: the data being shared *is* joint geometry. Anonymising it would mean removing the signal that makes it useful.
diff --git a/docs/prd-mocap-posture.md b/docs/prd-mocap-posture.md
index 3cfa08d..cd46238 100644
--- a/docs/prd-mocap-posture.md
+++ b/docs/prd-mocap-posture.md
@@ -144,6 +144,37 @@ A good test verifies external behavior of a deep module given a fixed input. It
- Real-time streaming of mocap to a remote coach.
- Custom freemocap installation flows beyond the documented Docker sidecar.
+## Resolved Decisions (grilling 2026-05-08)
+
+This section reflects the outcome of `/grill-with-docs` against this PRD. Where it conflicts with the body of the PRD above, **this section wins** and the older sections will be updated lazily.
+
+### Architecture (see `docs/adr/`)
+
+- **ADR-0001** — raw `PoseFrameStream` is stored as one binary blob per `MocapSession` alongside the video, not as Postgres JSONB rows. The `PoseFrame` Prisma model from `### Schema additions` is dropped; replaced by `MocapSession.poseStreamPath`.
+- **ADR-0002** — sidecar contract is deferred to Phase 2. v1 `PoseFrameStream` shape is browser-2D only (`{x, y, confidence}` per keypoint, plus quality flags). No Docker image, no WebSocket sidecar protocol, no health-check API in v1.
+- **ADR-0003** — analysis pipeline runs in the browser (Web Worker, MediaPipe Tasks WASM). `WebSocket /api/mocap/live` is for persistence only; server does not emit faults during live capture. Server-side execution is for `POST /api/mocap/sessions/:id/reanalyze` only.
+- **ADR-0004** — cloud-AI mocap payload is `PostureFault` summary (tier 3) by default; per-stroke metrics (tier 2) opt-in via `UserSettings.mocapDetailedAIShare`; raw frames (tier 1) never cross to cloud.
+
+### Domain terms (see `CONTEXT.md`)
+
+`CapturePerspective`, `StrokeSegmentationSource`, `MocapSession`, `CueLatencyBand`, `PoseFrameStream`, `Calibration`, `PostureFault` (v1 catalog), `FaultThresholds`. Use these exact terms in code, issues, and UI copy.
+
+### Locked design choices
+
+- **Browser path is side-view only.** `side-left` or `side-right`. Front view, asymmetry, knee-track deviation = `requires-multi-cam`, deferred to sidecar.
+- **Live capture = `pose-segmented`.** Live SmartRow CSV streaming is not available; CSV arrives post-session. Linking a `RowingSession` to a `MocapSession` triggers mandatory atomic re-analysis to `csv-aligned` as a background job.
+- **Live cues are `post-stroke`** (≤ 1 s after stroke completes), not intra-stroke. Fault detector runs at stroke granularity only.
+- **Calibration is per-session, not per-user.** Two reference frames (catch, finish) captured at session start, stored on `MocapSession`.
+- **Auto-link prompt** on CSV import when capture window overlaps within ±2 minutes; user always confirms, never silent. Linking is bidirectional, exclusive, reversible (`unlink` endpoint).
+- **v1 fault catalog is fixed at 5 types**: `rounded_back_at_catch`, `early_arm_bend`, `back_opens_before_legs_drive`, `excessive_layback`, `slow_recovery_ratio`. Anything else is out of v1 scope.
+- **Default thresholds are hand-coded and versioned** (`postureThresholdsV1`). Conservative bands. Auto-migrate on version bump unless user has set `userOverridden: true`.
+
+### v1 ship scope (Phase 1)
+
+**Ships:** US 1-11, 13, 19-30, 35, 36 (US 36 reduced to "single-source `PoseFrameStream` shape, versioned for future widening").
+
+**Deferred to Phase 2:** US 12 (side-by-side compare), 14 (fault-frequency dashboard card), 15 (posture in `aiAnalysis.ts` insights), 16-18 (sidecar), 31 (chat tool exposure), 32-33 (posture in training plans / achievements), 34 (mobile degraded mode).
+
## Further Notes
- freemocap upstream is GPL-licensed Python. Sidecar runs as separate process; no GPL code is linked into Next.js app. Confirm license interaction during implementation.
From 701e1444fdb375cf3c48c3e4aa90e996828551e6 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 11:04:14 +0200
Subject: [PATCH 03/29] feat(mocap): MocapSession schema, storage abstraction,
create/delete API
PR1 of issue #9: lay the foundation for browser-path webcam capture.
Capture worker, append endpoint, and capture UI follow in PR2/PR3.
- Prisma: add MocapSession model with unique rowingSessionId for the
bidirectional-exclusive link mandated in CONTEXT.md.
- src/lib/mocap/storage.ts: MocapStorage interface (path generation,
byte-range read, append, delete, exists) per ADR-0001 storage
contract. LocalDiskStorage impl writes under storage/mocap//
/ and guards against path traversal. Vercel Blob impl
is stubbed behind the same interface and gated on
MOCAP_STORAGE_BACKEND.
- src/lib/mocap/poseFrameStream.ts: 32-byte header + 404-byte frames
(33 keypoints * x,y,confidence Float32 + timestamp Float32 +
qualityFlags Uint32). Reader rejects unknown magic / format /
schema versions. frameCount uses an OPEN_FRAME_COUNT sentinel
during streaming append, derivable from blob size on tab-close
truncation (acceptance: no partial-frame corruption).
- POST /api/mocap/sessions: validates source/perspective with zod,
creates the row, allocates storage paths, writes the header into
the pose-stream blob, returns id + paths.
- DELETE /api/mocap/sessions/[id]: cascades to both blobs via the
storage abstraction.
- GET /api/mocap/sessions/[id]: read access used by PR2/PR3.
Refs #9.
Co-Authored-By: Claude Opus 4.7
---
.../migration.sql | 34 ++++
prisma/schema.prisma | 167 ++++++++++--------
src/app/api/mocap/sessions/[id]/route.ts | 51 ++++++
src/app/api/mocap/sessions/route.ts | 74 ++++++++
src/lib/mocap/poseFrameStream.ts | 152 ++++++++++++++++
src/lib/mocap/storage.ts | 115 ++++++++++++
6 files changed, 524 insertions(+), 69 deletions(-)
create mode 100644 prisma/migrations/20260508120000_add_mocap_session/migration.sql
create mode 100644 src/app/api/mocap/sessions/[id]/route.ts
create mode 100644 src/app/api/mocap/sessions/route.ts
create mode 100644 src/lib/mocap/poseFrameStream.ts
create mode 100644 src/lib/mocap/storage.ts
diff --git a/prisma/migrations/20260508120000_add_mocap_session/migration.sql b/prisma/migrations/20260508120000_add_mocap_session/migration.sql
new file mode 100644
index 0000000..dd820ec
--- /dev/null
+++ b/prisma/migrations/20260508120000_add_mocap_session/migration.sql
@@ -0,0 +1,34 @@
+-- CreateTable
+CREATE TABLE "MocapSession" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "rowingSessionId" TEXT,
+ "videoStoragePath" TEXT NOT NULL,
+ "poseStreamPath" TEXT NOT NULL,
+ "source" TEXT NOT NULL,
+ "captureModelVersion" TEXT NOT NULL,
+ "capturePerspective" TEXT NOT NULL,
+ "captureFps" DOUBLE PRECISION NOT NULL,
+ "durationSec" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "qualityScore" DOUBLE PRECISION,
+ "status" TEXT NOT NULL DEFAULT 'capturing',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "MocapSession_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "MocapSession_rowingSessionId_key" ON "MocapSession"("rowingSessionId");
+
+-- CreateIndex
+CREATE INDEX "MocapSession_userId_idx" ON "MocapSession"("userId");
+
+-- CreateIndex
+CREATE INDEX "MocapSession_userId_createdAt_idx" ON "MocapSession"("userId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "MocapSession" ADD CONSTRAINT "MocapSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "MocapSession" ADD CONSTRAINT "MocapSession_rowingSessionId_fkey" FOREIGN KEY ("rowingSessionId") REFERENCES "RowingSession"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 70c5ecd..ab6ad87 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -31,6 +31,7 @@ model User {
accounts Account[]
sessions AuthSession[]
rowingSessions RowingSession[]
+ mocapSessions MocapSession[]
personalRecords PersonalRecord[]
earnedAwards EarnedAward[]
aiAwards AIAwardSuggestion[]
@@ -101,30 +102,31 @@ model PasswordResetToken {
// ============================================================================
model RowingSession {
- id String @id @default(cuid())
- userId String
- timestamp DateTime
- distance Int
- duration Int
- energy Int
- strokeCount Int
- avgPower Float
- maxPower Float
- wattPerKg Float
- avgSplit Float
- minSplit Float
- avgWork Float
- avgStrokeLength Float
- avgStrokeRate Float
- maxStrokeRate Float
- consistencyScore Float? // Pre-computed consistency score (0-100)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- importedAt DateTime @default(now())
- sourceFile String?
+ id String @id @default(cuid())
+ userId String
+ timestamp DateTime
+ distance Int
+ duration Int
+ energy Int
+ strokeCount Int
+ avgPower Float
+ maxPower Float
+ wattPerKg Float
+ avgSplit Float
+ minSplit Float
+ avgWork Float
+ avgStrokeLength Float
+ avgStrokeRate Float
+ maxStrokeRate Float
+ consistencyScore Float? // Pre-computed consistency score (0-100)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ importedAt DateTime @default(now())
+ sourceFile String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
strokeData StrokeData[]
+ mocapSession MocapSession?
personalRecords PersonalRecord[]
trainingSessionLinks TrainingSessionLink[]
@@ -135,7 +137,7 @@ model RowingSession {
}
model StrokeData {
- id String @id @default(cuid())
+ id String @id @default(cuid())
sessionId String
strokeIndex Int
time Float
@@ -175,6 +177,33 @@ model PersonalRecord {
@@index([userId])
}
+// ============================================================================
+// MOCAP (motion-capture posture analysis — see docs/prd-mocap-posture.md)
+// ============================================================================
+
+model MocapSession {
+ id String @id @default(cuid())
+ userId String
+ rowingSessionId String? @unique
+ videoStoragePath String
+ poseStreamPath String
+ source String
+ captureModelVersion String
+ capturePerspective String
+ captureFps Float
+ durationSec Float @default(0)
+ qualityScore Float?
+ status String @default("capturing")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ rowingSession RowingSession? @relation(fields: [rowingSessionId], references: [id], onDelete: SetNull)
+
+ @@index([userId])
+ @@index([userId, createdAt])
+}
+
// ============================================================================
// AWARDS & ACHIEVEMENTS
// ============================================================================
@@ -215,15 +244,15 @@ model AIAwardSuggestion {
}
model GeneratedAchievement {
- id String @id @default(cuid())
- userId String
- awardId String
- story String? @db.Text
- imageUrl String?
- hasImage Boolean @default(false)
+ id String @id @default(cuid())
+ userId String
+ awardId String
+ story String? @db.Text
+ imageUrl String?
+ hasImage Boolean @default(false)
colorPalette String? @default("classic")
- earnedAt DateTime?
- generatedAt DateTime @default(now())
+ earnedAt DateTime?
+ generatedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -372,7 +401,7 @@ model AIInsight {
archived Boolean @default(false)
dateGenerated DateTime @default(now())
archivedAt DateTime?
- feedback String? // 'helpful' | 'not_helpful' | 'action_taken'
+ feedback String? // 'helpful' | 'not_helpful' | 'action_taken'
feedbackAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -402,7 +431,7 @@ model MemoryDocument {
status String?
uploadedAt DateTime @default(now())
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, type])
@@ -413,44 +442,44 @@ model MemoryDocument {
// ============================================================================
model UserSettings {
- id String @id @default(cuid())
- userId String @unique
- theme String @default("system")
- units String @default("metric")
- dateFormat String @default("MM/DD/YYYY")
- timeFormat String @default("24h")
- language String @default("en")
- timeZone String? @default("UTC")
- defaultChartType String @default("line")
- animationsEnabled Boolean @default(true)
- showPromptSuggestions Boolean @default(true)
- customPrompts String[]
- trainingZones Json?
- preferredMetrics String[]
- weeklyGoalType String @default("sessions")
- weeklyGoalTarget Int @default(3)
- restDayAlerts Boolean @default(true)
- adaptationEnabled Boolean @default(true)
- sessionReminders Boolean @default(false)
- weeklyProgress Boolean @default(true)
- achievementAlerts Boolean @default(true)
- planReminders Boolean @default(true)
- adherenceAlerts Boolean @default(true)
- cloudAIEnabled Boolean @default(false)
- maxTokens Int @default(1500)
- aiConfig Json?
- customPromptsAi Json?
- userProfileContext String? @db.Text
- userProfileRawInput String? @db.Text
- dashboardSettings Json?
- sessionsViewSettings Json?
+ id String @id @default(cuid())
+ userId String @unique
+ theme String @default("system")
+ units String @default("metric")
+ dateFormat String @default("MM/DD/YYYY")
+ timeFormat String @default("24h")
+ language String @default("en")
+ timeZone String? @default("UTC")
+ defaultChartType String @default("line")
+ animationsEnabled Boolean @default(true)
+ showPromptSuggestions Boolean @default(true)
+ customPrompts String[]
+ trainingZones Json?
+ preferredMetrics String[]
+ weeklyGoalType String @default("sessions")
+ weeklyGoalTarget Int @default(3)
+ restDayAlerts Boolean @default(true)
+ adaptationEnabled Boolean @default(true)
+ sessionReminders Boolean @default(false)
+ weeklyProgress Boolean @default(true)
+ achievementAlerts Boolean @default(true)
+ planReminders Boolean @default(true)
+ adherenceAlerts Boolean @default(true)
+ cloudAIEnabled Boolean @default(false)
+ maxTokens Int @default(1500)
+ aiConfig Json?
+ customPromptsAi Json?
+ userProfileContext String? @db.Text
+ userProfileRawInput String? @db.Text
+ dashboardSettings Json?
+ sessionsViewSettings Json?
sessionAnalysisSettings Json?
- chartSettings Json?
- analyticsSettings Json?
- sessionsRevision Int @default(0)
- insightsRevision Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ chartSettings Json?
+ analyticsSettings Json?
+ sessionsRevision Int @default(0)
+ insightsRevision Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
diff --git a/src/app/api/mocap/sessions/[id]/route.ts b/src/app/api/mocap/sessions/[id]/route.ts
new file mode 100644
index 0000000..dbb8302
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ return NextResponse.json({ session: row });
+}
+
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const storage = getMocapStorage();
+ await Promise.allSettled([
+ storage.delete(row.videoStoragePath),
+ storage.delete(row.poseStreamPath),
+ ]);
+ await prisma.mocapSession.delete({ where: { id } });
+
+ return NextResponse.json({ success: true, id });
+}
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
new file mode 100644
index 0000000..d340a4a
--- /dev/null
+++ b/src/app/api/mocap/sessions/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { z } from "zod";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import { encodeHeader } from "@/lib/mocap/poseFrameStream";
+
+const CreateBody = z.object({
+ source: z.enum(["browser"]),
+ captureModelVersion: z.string().min(1).max(120),
+ capturePerspective: z.enum(["side-left", "side-right"]),
+ captureFps: z.number().positive().max(240),
+});
+
+export async function POST(req: Request) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ let body: z.infer;
+ try {
+ body = CreateBody.parse(await req.json());
+ } catch (err) {
+ return NextResponse.json(
+ { error: "Invalid request body", details: err instanceof Error ? err.message : String(err) },
+ { status: 400 },
+ );
+ }
+
+ const userId = session.user.id;
+ const storage = getMocapStorage();
+
+ const created = await prisma.$transaction(async (tx) => {
+ const row = await tx.mocapSession.create({
+ data: {
+ userId,
+ source: body.source,
+ captureModelVersion: body.captureModelVersion,
+ capturePerspective: body.capturePerspective,
+ captureFps: body.captureFps,
+ videoStoragePath: "pending",
+ poseStreamPath: "pending",
+ status: "capturing",
+ },
+ });
+ const videoStoragePath = storage.videoPath(userId, row.id);
+ const poseStreamPath = storage.poseStreamPath(userId, row.id);
+ return tx.mocapSession.update({
+ where: { id: row.id },
+ data: { videoStoragePath, poseStreamPath },
+ });
+ });
+
+ try {
+ const header = encodeHeader({ fps: body.captureFps });
+ await storage.appendBytes(created.poseStreamPath, header);
+ } catch (err) {
+ await prisma.mocapSession.delete({ where: { id: created.id } }).catch(() => {});
+ return NextResponse.json(
+ { error: "Failed to initialize pose stream blob", details: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json({
+ id: created.id,
+ videoStoragePath: created.videoStoragePath,
+ poseStreamPath: created.poseStreamPath,
+ status: created.status,
+ createdAt: created.createdAt,
+ });
+}
diff --git a/src/lib/mocap/poseFrameStream.ts b/src/lib/mocap/poseFrameStream.ts
new file mode 100644
index 0000000..3ee2091
--- /dev/null
+++ b/src/lib/mocap/poseFrameStream.ts
@@ -0,0 +1,152 @@
+/**
+ * PoseFrameStream binary blob format (ADR-0001).
+ *
+ * Layout: [header: 32 bytes][frame 0][frame 1]...[frame N-1]
+ *
+ * Random access by frame index = byte-range read at
+ * HEADER_SIZE + frameIndex * BYTES_PER_FRAME
+ *
+ * Header `frameCount` is OPEN_FRAME_COUNT during streaming append and updated
+ * to the final count on session finalize. Readers must accept either form: when
+ * they see OPEN_FRAME_COUNT, they derive the count from file size.
+ */
+
+export const MAGIC = new Uint8Array([0x4d, 0x4f, 0x50, 0x53]); // "MOPS"
+export const HEADER_SIZE = 32;
+export const FORMAT_VERSION = 1;
+export const KEYPOINT_SCHEMA_V1 = 1;
+export const KEYPOINTS_PER_FRAME_V1 = 33;
+export const BYTES_PER_FRAME_V1 =
+ 4 /* timestampMs Float32 */ +
+ KEYPOINTS_PER_FRAME_V1 * 3 * 4 /* x, y, confidence Float32 */ +
+ 4; /* qualityFlags Uint32 */
+export const OPEN_FRAME_COUNT = 0xffffffff;
+
+export const QUALITY_FLAG = {
+ LOW_LIGHT: 1 << 0,
+ OCCLUDED: 1 << 1,
+ OUT_OF_FRAME: 1 << 2,
+ LOW_CONFIDENCE: 1 << 3,
+ CAMERA_MOTION: 1 << 4,
+} as const;
+
+export interface PoseStreamHeader {
+ formatVersion: number;
+ keypointSchemaVersion: number;
+ fps: number;
+ keypointsPerFrame: number;
+ bytesPerFrame: number;
+ frameCount: number; // OPEN_FRAME_COUNT during streaming
+}
+
+export interface PoseFrame {
+ timestampMs: number;
+ /** Length = keypointsPerFrame * 3 (x, y, confidence interleaved). */
+ keypoints: Float32Array;
+ qualityFlags: number;
+}
+
+export class PoseStreamFormatError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "PoseStreamFormatError";
+ }
+}
+
+export function encodeHeader(opts: {
+ fps: number;
+ keypointSchemaVersion?: number;
+ frameCount?: number;
+}): Uint8Array {
+ const schema = opts.keypointSchemaVersion ?? KEYPOINT_SCHEMA_V1;
+ if (schema !== KEYPOINT_SCHEMA_V1) {
+ throw new PoseStreamFormatError(
+ `Unsupported keypointSchemaVersion ${schema}`,
+ );
+ }
+ const buf = new Uint8Array(HEADER_SIZE);
+ const view = new DataView(buf.buffer);
+ buf.set(MAGIC, 0);
+ view.setUint16(4, FORMAT_VERSION, true);
+ view.setUint16(6, schema, true);
+ view.setFloat32(8, opts.fps, true);
+ view.setUint16(12, KEYPOINTS_PER_FRAME_V1, true);
+ view.setUint16(14, BYTES_PER_FRAME_V1, true);
+ view.setUint32(16, opts.frameCount ?? OPEN_FRAME_COUNT, true);
+ // bytes 20-31 reserved (zero)
+ return buf;
+}
+
+export function decodeHeader(bytes: Uint8Array): PoseStreamHeader {
+ if (bytes.byteLength < HEADER_SIZE) {
+ throw new PoseStreamFormatError(
+ `Header too short: ${bytes.byteLength} < ${HEADER_SIZE}`,
+ );
+ }
+ for (let i = 0; i < MAGIC.length; i++) {
+ if (bytes[i] !== MAGIC[i]) {
+ throw new PoseStreamFormatError("Bad magic; not a PoseFrameStream blob");
+ }
+ }
+ const view = new DataView(bytes.buffer, bytes.byteOffset, HEADER_SIZE);
+ const formatVersion = view.getUint16(4, true);
+ if (formatVersion !== FORMAT_VERSION) {
+ throw new PoseStreamFormatError(
+ `Unsupported formatVersion ${formatVersion}; reader knows ${FORMAT_VERSION}`,
+ );
+ }
+ const keypointSchemaVersion = view.getUint16(6, true);
+ if (keypointSchemaVersion !== KEYPOINT_SCHEMA_V1) {
+ throw new PoseStreamFormatError(
+ `Unsupported keypointSchemaVersion ${keypointSchemaVersion}; reader knows ${KEYPOINT_SCHEMA_V1}`,
+ );
+ }
+ return {
+ formatVersion,
+ keypointSchemaVersion,
+ fps: view.getFloat32(8, true),
+ keypointsPerFrame: view.getUint16(12, true),
+ bytesPerFrame: view.getUint16(14, true),
+ frameCount: view.getUint32(16, true),
+ };
+}
+
+export function encodeFrame(frame: PoseFrame): Uint8Array {
+ if (frame.keypoints.length !== KEYPOINTS_PER_FRAME_V1 * 3) {
+ throw new PoseStreamFormatError(
+ `Expected ${KEYPOINTS_PER_FRAME_V1 * 3} keypoint floats, got ${frame.keypoints.length}`,
+ );
+ }
+ const buf = new Uint8Array(BYTES_PER_FRAME_V1);
+ const view = new DataView(buf.buffer);
+ view.setFloat32(0, frame.timestampMs, true);
+ const floats = new Float32Array(buf.buffer, 4, KEYPOINTS_PER_FRAME_V1 * 3);
+ floats.set(frame.keypoints);
+ view.setUint32(BYTES_PER_FRAME_V1 - 4, frame.qualityFlags >>> 0, true);
+ return buf;
+}
+
+export function decodeFrame(bytes: Uint8Array, offset = 0): PoseFrame {
+ if (bytes.byteLength - offset < BYTES_PER_FRAME_V1) {
+ throw new PoseStreamFormatError(
+ `Frame slice too short: ${bytes.byteLength - offset} < ${BYTES_PER_FRAME_V1}`,
+ );
+ }
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, BYTES_PER_FRAME_V1);
+ const timestampMs = view.getFloat32(0, true);
+ const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ for (let i = 0; i < keypoints.length; i++) {
+ keypoints[i] = view.getFloat32(4 + i * 4, true);
+ }
+ const qualityFlags = view.getUint32(BYTES_PER_FRAME_V1 - 4, true);
+ return { timestampMs, keypoints, qualityFlags };
+}
+
+export function frameByteOffset(frameIndex: number): number {
+ return HEADER_SIZE + frameIndex * BYTES_PER_FRAME_V1;
+}
+
+export function framesFromBlobSize(blobSize: number): number {
+ if (blobSize < HEADER_SIZE) return 0;
+ return Math.floor((blobSize - HEADER_SIZE) / BYTES_PER_FRAME_V1);
+}
diff --git a/src/lib/mocap/storage.ts b/src/lib/mocap/storage.ts
new file mode 100644
index 0000000..e4601b1
--- /dev/null
+++ b/src/lib/mocap/storage.ts
@@ -0,0 +1,115 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+
+export type ByteRange = { start: number; end?: number };
+
+export interface MocapStorage {
+ videoPath(userId: string, sessionId: string): string;
+ poseStreamPath(userId: string, sessionId: string): string;
+ appendBytes(storagePath: string, bytes: Uint8Array): Promise;
+ read(storagePath: string, range?: ByteRange): Promise;
+ size(storagePath: string): Promise;
+ exists(storagePath: string): Promise;
+ delete(storagePath: string): Promise;
+}
+
+const MOCAP_ROOT = "mocap";
+
+class LocalDiskStorage implements MocapStorage {
+ constructor(private readonly root: string) {}
+
+ videoPath(userId: string, sessionId: string): string {
+ return path.posix.join(MOCAP_ROOT, userId, sessionId, "video.webm");
+ }
+
+ poseStreamPath(userId: string, sessionId: string): string {
+ return path.posix.join(MOCAP_ROOT, userId, sessionId, "pose-stream.bin");
+ }
+
+ private resolve(storagePath: string): string {
+ const abs = path.resolve(this.root, storagePath);
+ const rootAbs = path.resolve(this.root);
+ if (!abs.startsWith(rootAbs + path.sep) && abs !== rootAbs) {
+ throw new Error(`Path escapes storage root: ${storagePath}`);
+ }
+ return abs;
+ }
+
+ async appendBytes(storagePath: string, bytes: Uint8Array): Promise {
+ const abs = this.resolve(storagePath);
+ await fs.mkdir(path.dirname(abs), { recursive: true });
+ await fs.appendFile(abs, bytes);
+ }
+
+ async read(storagePath: string, range?: ByteRange): Promise {
+ const abs = this.resolve(storagePath);
+ if (!range) {
+ const buf = await fs.readFile(abs);
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ }
+ const fh = await fs.open(abs, "r");
+ try {
+ const stat = await fh.stat();
+ const end = range.end ?? stat.size;
+ const length = Math.max(0, end - range.start);
+ const buf = Buffer.alloc(length);
+ if (length > 0) {
+ await fh.read(buf, 0, length, range.start);
+ }
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ } finally {
+ await fh.close();
+ }
+ }
+
+ async size(storagePath: string): Promise {
+ const abs = this.resolve(storagePath);
+ const stat = await fs.stat(abs);
+ return stat.size;
+ }
+
+ async exists(storagePath: string): Promise {
+ try {
+ await fs.access(this.resolve(storagePath));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async delete(storagePath: string): Promise {
+ const abs = this.resolve(storagePath);
+ await fs.rm(abs, { force: true });
+ const dir = path.dirname(abs);
+ try {
+ const entries = await fs.readdir(dir);
+ if (entries.length === 0) {
+ await fs.rmdir(dir);
+ }
+ } catch {
+ // ignore
+ }
+ }
+}
+
+let instance: MocapStorage | null = null;
+
+export function getMocapStorage(): MocapStorage {
+ if (instance) return instance;
+
+ const backend = process.env.MOCAP_STORAGE_BACKEND ?? "local";
+ if (backend === "local") {
+ const root = process.env.MOCAP_STORAGE_ROOT
+ ? path.resolve(process.env.MOCAP_STORAGE_ROOT)
+ : path.resolve(process.cwd(), "storage");
+ instance = new LocalDiskStorage(root);
+ return instance;
+ }
+
+ // Vercel Blob backend: deferred. Add @vercel/blob dependency and a
+ // VercelBlobStorage class implementing MocapStorage. Append uses overwrite
+ // via put({allowOverwrite: true}); byte-range read via fetch with Range header.
+ throw new Error(
+ `MOCAP_STORAGE_BACKEND="${backend}" not implemented. Set MOCAP_STORAGE_BACKEND=local for now.`,
+ );
+}
From cf7f6c618ada419c9bae15b7886a6b6bb8413e73 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 11:53:23 +0200
Subject: [PATCH 04/29] Add mocap analysis pipeline
---
.claude/settings.local.json | 17 +-
package-lock.json | 527 ++++++
package.json | 4 +
playwright.config.ts | 42 +
.../api/mocap/sessions/[id]/finalize/route.ts | 113 ++
.../mocap/sessions/[id]/pose-stream/route.ts | 94 +
src/app/api/mocap/sessions/[id]/pose/route.ts | 53 +
.../api/mocap/sessions/[id]/video/route.ts | 43 +
src/app/mocap/page.tsx | 383 ++++
src/components/navigation.tsx | 3 +-
src/lib/mocap/analysis/index.ts | 5 +
.../mocap/analysis/postureFaultDetector.ts | 160 ++
src/lib/mocap/analysis/postureMetrics.ts | 233 +++
src/lib/mocap/analysis/postureThresholds.ts | 154 ++
.../mocap/analysis/strokePhaseSegmenter.ts | 160 ++
src/lib/mocap/analysis/types.ts | 144 ++
src/lib/mocap/browserPoseSource.ts | 222 +++
src/lib/mocap/poseWorker.ts | 173 ++
src/lib/mocap/storage.ts | 16 +
src/lib/mocap/videoUploader.ts | 45 +
tests/e2e/mocap-capture.spec.ts | 38 +
.../mocap/asymmetric-side-unavailable.json | 1549 +++++++++++++++++
.../mocap/back-opens-before-legs.json | 864 +++++++++
tests/fixtures/mocap/clean-reference.json | 859 +++++++++
tests/fixtures/mocap/early-arm-bend.json | 864 +++++++++
tests/fixtures/mocap/excessive-layback.json | 864 +++++++++
tests/fixtures/mocap/lost-tracking.json | 859 +++++++++
.../fixtures/mocap/rounded-back-critical.json | 864 +++++++++
.../mocap/slow-recovery-critical.json | 1548 ++++++++++++++++
tests/mocapAnalysis.test.ts | 195 +++
tests/poseFrameStream.test.ts | 116 ++
31 files changed, 11209 insertions(+), 2 deletions(-)
create mode 100644 playwright.config.ts
create mode 100644 src/app/api/mocap/sessions/[id]/finalize/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/pose-stream/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/pose/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/video/route.ts
create mode 100644 src/app/mocap/page.tsx
create mode 100644 src/lib/mocap/analysis/index.ts
create mode 100644 src/lib/mocap/analysis/postureFaultDetector.ts
create mode 100644 src/lib/mocap/analysis/postureMetrics.ts
create mode 100644 src/lib/mocap/analysis/postureThresholds.ts
create mode 100644 src/lib/mocap/analysis/strokePhaseSegmenter.ts
create mode 100644 src/lib/mocap/analysis/types.ts
create mode 100644 src/lib/mocap/browserPoseSource.ts
create mode 100644 src/lib/mocap/poseWorker.ts
create mode 100644 src/lib/mocap/videoUploader.ts
create mode 100644 tests/e2e/mocap-capture.spec.ts
create mode 100644 tests/fixtures/mocap/asymmetric-side-unavailable.json
create mode 100644 tests/fixtures/mocap/back-opens-before-legs.json
create mode 100644 tests/fixtures/mocap/clean-reference.json
create mode 100644 tests/fixtures/mocap/early-arm-bend.json
create mode 100644 tests/fixtures/mocap/excessive-layback.json
create mode 100644 tests/fixtures/mocap/lost-tracking.json
create mode 100644 tests/fixtures/mocap/rounded-back-critical.json
create mode 100644 tests/fixtures/mocap/slow-recovery-critical.json
create mode 100644 tests/mocapAnalysis.test.ts
create mode 100644 tests/poseFrameStream.test.ts
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 6f12184..ec238ad 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -17,7 +17,22 @@
"Bash(npx tsx:*)",
"Bash(npx eslint:*)",
"Bash(find:*)",
- "Bash(wc:*)"
+ "Bash(wc:*)",
+ "Bash(gh repo *)",
+ "Bash(gh label *)",
+ "Bash(gh issue *)",
+ "Bash(sed -i '' 's|BLOCKER_1|#9|' /tmp/issue-2-calibration.md)",
+ "Bash(sed -i '' 's|BLOCKER_3|#10|' /tmp/issue-6-thresholds.md)",
+ "Bash(sed -i '' 's|BLOCKER_3|#10|; s|BLOCKER_4|#13|' /tmp/issue-5-cues.md)",
+ "Bash(sed -i '' 's|BLOCKER_4|#13|' /tmp/issue-7-link.md)",
+ "Bash(sed -i '' 's|BLOCKER_4|#13|' /tmp/issue-8-lifecycle.md)",
+ "Bash(sed -i '' 's|BLOCKER_4|#13|' /tmp/issue-9-cloudai.md)",
+ "Bash(npx prisma *)",
+ "Bash(PGPASSWORD=rowing_dev_password psql -h localhost -U rowing -d rowing_tracker -f prisma/migrations/20260508120000_add_mocap_session/migration.sql)",
+ "Bash(PGPASSWORD=rowing_dev_password psql -h localhost -U rowing -d rowing_tracker -c \"INSERT INTO _prisma_migrations \\(id, checksum, finished_at, migration_name, started_at, applied_steps_count\\) VALUES \\(gen_random_uuid\\(\\)::text, md5\\('20260508120000_add_mocap_session'\\), now\\(\\), '20260508120000_add_mocap_session', now\\(\\), 1\\) ON CONFLICT DO NOTHING;\")",
+ "Bash(git commit *)",
+ "Bash(xargs -I{} echo {})",
+ "Bash(npm test *)"
]
}
}
diff --git a/package-lock.json b/package-lock.json
index 287aaf2..ee957d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
+ "@mediapipe/tasks-vision": "^0.10.35",
"@playwright/test": "^1.56.1",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
@@ -70,6 +71,7 @@
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4",
+ "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
@@ -540,6 +542,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -1308,6 +1752,12 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
+ "node_modules/@mediapipe/tasks-vision": {
+ "version": "0.10.35",
+ "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
+ "integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
+ "license": "Apache-2.0"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -5439,6 +5889,48 @@
"benchmarks"
]
},
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "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"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -10926,6 +11418,41 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
diff --git a/package.json b/package.json
index 8e62b94..db64f5d 100644
--- a/package.json
+++ b/package.json
@@ -25,10 +25,13 @@
"db:seed": "npx prisma db seed",
"db:reset": "npx prisma migrate reset",
"admin:promote": "npx tsx scripts/promote-admin.ts",
+ "test": "npx tsx --test tests/*.test.ts",
+ "test:e2e": "npx playwright test",
"postinstall": "prisma generate"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
+ "@mediapipe/tasks-vision": "^0.10.35",
"@playwright/test": "^1.56.1",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
@@ -88,6 +91,7 @@
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4",
+ "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
},
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..2e0bb02
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
+const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`;
+
+export default defineConfig({
+ testDir: "./tests/e2e",
+ timeout: 60_000,
+ expect: { timeout: 10_000 },
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? "github" : "list",
+ use: {
+ baseURL: BASE_URL,
+ trace: "retain-on-failure",
+ video: "retain-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ launchOptions: {
+ args: [
+ "--use-fake-ui-for-media-stream",
+ "--use-fake-device-for-media-stream",
+ "--autoplay-policy=no-user-gesture-required",
+ ],
+ },
+ },
+ },
+ ],
+ webServer: process.env.PLAYWRIGHT_SKIP_SERVER
+ ? undefined
+ : {
+ command: "npm run dev",
+ url: BASE_URL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
new file mode 100644
index 0000000..cfe2973
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -0,0 +1,113 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { z } from "zod";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import {
+ BYTES_PER_FRAME_V1,
+ HEADER_SIZE,
+ framesFromBlobSize,
+} from "@/lib/mocap/poseFrameStream";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const FRAME_COUNT_OFFSET = 16;
+
+const Body = z.object({
+ durationSec: z.number().nonnegative().max(60 * 60 * 8),
+ qualityScore: z.number().min(0).max(1).optional(),
+});
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: {
+ id: true,
+ status: true,
+ poseStreamPath: true,
+ videoStoragePath: true,
+ },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (row.status !== "capturing") {
+ return NextResponse.json(
+ { error: `Session not capturing (status=${row.status})` },
+ { status: 409 },
+ );
+ }
+
+ let body: z.infer;
+ try {
+ body = Body.parse(await req.json());
+ } catch (err) {
+ return NextResponse.json(
+ {
+ error: "Invalid request body",
+ details: err instanceof Error ? err.message : String(err),
+ },
+ { status: 400 },
+ );
+ }
+
+ const storage = getMocapStorage();
+
+ let poseSize = 0;
+ try {
+ poseSize = await storage.size(row.poseStreamPath);
+ } catch {
+ return NextResponse.json(
+ { error: "Pose stream missing" },
+ { status: 500 },
+ );
+ }
+ if (poseSize < HEADER_SIZE) {
+ return NextResponse.json(
+ { error: "Pose stream truncated below header" },
+ { status: 500 },
+ );
+ }
+ const trailing = (poseSize - HEADER_SIZE) % BYTES_PER_FRAME_V1;
+ if (trailing !== 0) {
+ return NextResponse.json(
+ {
+ error: `Pose stream has ${trailing} trailing bytes (corrupt)`,
+ },
+ { status: 500 },
+ );
+ }
+ const frameCount = framesFromBlobSize(poseSize);
+
+ const headerPatch = new Uint8Array(4);
+ new DataView(headerPatch.buffer).setUint32(0, frameCount, true);
+ await storage.writeAt(row.poseStreamPath, headerPatch, FRAME_COUNT_OFFSET);
+
+ const updated = await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: {
+ status: "ready",
+ durationSec: body.durationSec,
+ qualityScore: body.qualityScore ?? null,
+ },
+ });
+
+ return NextResponse.json({
+ id: updated.id,
+ status: updated.status,
+ durationSec: updated.durationSec,
+ frameCount,
+ poseStreamBytes: poseSize,
+ });
+}
diff --git a/src/app/api/mocap/sessions/[id]/pose-stream/route.ts b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
new file mode 100644
index 0000000..ab0618e
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
@@ -0,0 +1,94 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+function parseRange(
+ header: string | null,
+ totalSize: number,
+): { start: number; end: number } | null {
+ if (!header) return null;
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!m) return null;
+ const startStr = m[1];
+ const endStr = m[2];
+ if (startStr === "" && endStr === "") return null;
+ let start: number;
+ let end: number;
+ if (startStr === "") {
+ const suffix = parseInt(endStr, 10);
+ if (!Number.isFinite(suffix) || suffix <= 0) return null;
+ start = Math.max(0, totalSize - suffix);
+ end = totalSize - 1;
+ } else {
+ start = parseInt(startStr, 10);
+ end = endStr === "" ? totalSize - 1 : parseInt(endStr, 10);
+ }
+ if (
+ !Number.isFinite(start) ||
+ !Number.isFinite(end) ||
+ start < 0 ||
+ end < start ||
+ start >= totalSize
+ ) {
+ return null;
+ }
+ end = Math.min(end, totalSize - 1);
+ return { start, end };
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { poseStreamPath: true },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const storage = getMocapStorage();
+ const totalSize = await storage.size(row.poseStreamPath);
+ const range = parseRange(req.headers.get("range"), totalSize);
+
+ if (!range) {
+ const bytes = await storage.read(row.poseStreamPath);
+ const u8 = new Uint8Array(bytes);
+ return new Response(u8 as BodyInit, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Length": String(totalSize),
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "private, no-store",
+ },
+ });
+ }
+
+ const slice = await storage.read(row.poseStreamPath, {
+ start: range.start,
+ end: range.end + 1,
+ });
+ return new Response(new Uint8Array(slice) as BodyInit, {
+ status: 206,
+ headers: {
+ "Content-Type": "application/octet-stream",
+ "Content-Length": String(slice.byteLength),
+ "Content-Range": `bytes ${range.start}-${range.end}/${totalSize}`,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "private, no-store",
+ },
+ });
+}
diff --git a/src/app/api/mocap/sessions/[id]/pose/route.ts b/src/app/api/mocap/sessions/[id]/pose/route.ts
new file mode 100644
index 0000000..c263381
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/pose/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import { BYTES_PER_FRAME_V1 } from "@/lib/mocap/poseFrameStream";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { id: true, status: true, poseStreamPath: true },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (row.status !== "capturing") {
+ return NextResponse.json(
+ { error: `Session not capturing (status=${row.status})` },
+ { status: 409 },
+ );
+ }
+
+ const buf = new Uint8Array(await req.arrayBuffer());
+ if (buf.byteLength === 0) {
+ return NextResponse.json({ appended: 0 });
+ }
+ if (buf.byteLength % BYTES_PER_FRAME_V1 !== 0) {
+ return NextResponse.json(
+ {
+ error: `Body length ${buf.byteLength} not multiple of frame size ${BYTES_PER_FRAME_V1}`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const storage = getMocapStorage();
+ await storage.appendBytes(row.poseStreamPath, buf);
+
+ const framesAppended = buf.byteLength / BYTES_PER_FRAME_V1;
+ return NextResponse.json({ appended: framesAppended });
+}
diff --git a/src/app/api/mocap/sessions/[id]/video/route.ts b/src/app/api/mocap/sessions/[id]/video/route.ts
new file mode 100644
index 0000000..a8e3c0b
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/video/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { id: true, status: true, videoStoragePath: true },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (row.status !== "capturing") {
+ return NextResponse.json(
+ { error: `Session not capturing (status=${row.status})` },
+ { status: 409 },
+ );
+ }
+
+ const buf = new Uint8Array(await req.arrayBuffer());
+ if (buf.byteLength === 0) {
+ return NextResponse.json({ appended: 0 });
+ }
+
+ const storage = getMocapStorage();
+ await storage.appendBytes(row.videoStoragePath, buf);
+
+ return NextResponse.json({ appended: buf.byteLength });
+}
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
new file mode 100644
index 0000000..b42f787
--- /dev/null
+++ b/src/app/mocap/page.tsx
@@ -0,0 +1,383 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ BrowserPoseSource,
+ type PoseSourceStatus,
+} from "@/lib/mocap/browserPoseSource";
+import { VideoUploader } from "@/lib/mocap/videoUploader";
+
+const CAPTURE_FPS = 30;
+const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
+const VIDEO_TIMESLICE_MS = 1000;
+
+type CaptureState =
+ | { kind: "idle" }
+ | { kind: "starting" }
+ | {
+ kind: "capturing";
+ sessionId: string;
+ startedAt: number;
+ }
+ | {
+ kind: "stopping";
+ sessionId: string;
+ }
+ | {
+ kind: "done";
+ sessionId: string;
+ durationSec: number;
+ frameCount: number;
+ }
+ | { kind: "error"; message: string };
+
+export default function MocapCapturePage() {
+ const videoRef = useRef(null);
+ const streamRef = useRef(null);
+ const recorderRef = useRef(null);
+ const uploaderRef = useRef(null);
+ const sourceRef = useRef(null);
+ const startedAtRef = useRef(0);
+
+ const [state, setState] = useState({ kind: "idle" });
+ const [framesEncoded, setFramesEncoded] = useState(0);
+ const [poseStatus, setPoseStatus] = useState("idle");
+ const [perspective, setPerspective] = useState<"side-left" | "side-right">(
+ "side-right",
+ );
+ const [elapsedSec, setElapsedSec] = useState(0);
+
+ useEffect(() => {
+ if (state.kind !== "capturing") return;
+ const t = setInterval(() => {
+ setElapsedSec((Date.now() - startedAtRef.current) / 1000);
+ }, 250);
+ return () => clearInterval(t);
+ }, [state.kind]);
+
+ const teardown = useCallback(async () => {
+ sourceRef.current = null;
+ recorderRef.current = null;
+ uploaderRef.current = null;
+ if (streamRef.current) {
+ for (const track of streamRef.current.getTracks()) track.stop();
+ streamRef.current = null;
+ }
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ }, []);
+
+ const handleError = useCallback(
+ async (err: unknown, sessionId?: string) => {
+ const message = err instanceof Error ? err.message : String(err);
+ setState({ kind: "error", message });
+ try {
+ await sourceRef.current?.stop();
+ } catch {
+ // ignore
+ }
+ try {
+ recorderRef.current?.stop();
+ } catch {
+ // ignore
+ }
+ await uploaderRef.current?.drain();
+ await teardown();
+ if (sessionId) {
+ // Best-effort delete of the abandoned row
+ fetch(`/api/mocap/sessions/${sessionId}`, { method: "DELETE" }).catch(
+ () => {},
+ );
+ }
+ },
+ [teardown],
+ );
+
+ const start = useCallback(async () => {
+ setState({ kind: "starting" });
+ let sessionId: string | undefined;
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { width: 1280, height: 720, frameRate: CAPTURE_FPS },
+ audio: false,
+ });
+ streamRef.current = stream;
+ const video = videoRef.current!;
+ video.srcObject = stream;
+ await video.play();
+
+ const createRes = await fetch("/api/mocap/sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ source: "browser",
+ captureModelVersion: CAPTURE_MODEL_VERSION,
+ capturePerspective: perspective,
+ captureFps: CAPTURE_FPS,
+ }),
+ });
+ if (!createRes.ok) {
+ throw new Error(`Create session failed: ${createRes.status}`);
+ }
+ const created: { id: string } = await createRes.json();
+ sessionId = created.id;
+
+ uploaderRef.current = new VideoUploader(sessionId, (err) =>
+ handleError(err, sessionId),
+ );
+
+ const mimeType = pickRecorderMime();
+ const recorder = new MediaRecorder(stream, {
+ mimeType,
+ videoBitsPerSecond: 2_500_000,
+ });
+ recorder.ondataavailable = (event) => {
+ if (event.data && event.data.size > 0) {
+ uploaderRef.current?.enqueue(event.data);
+ }
+ };
+ recorder.onerror = (event) => {
+ handleError(
+ (event as ErrorEvent).error ?? new Error("MediaRecorder error"),
+ sessionId,
+ );
+ };
+ recorderRef.current = recorder;
+
+ const source = new BrowserPoseSource({
+ sessionId,
+ videoEl: video,
+ onStatus: (s) => setPoseStatus(s),
+ onFrame: (info) => setFramesEncoded(info.framesEncoded),
+ onError: (err) => handleError(err, sessionId),
+ });
+ sourceRef.current = source;
+ await source.init();
+
+ recorder.start(VIDEO_TIMESLICE_MS);
+ source.start();
+ startedAtRef.current = Date.now();
+ setElapsedSec(0);
+ setFramesEncoded(0);
+ setState({
+ kind: "capturing",
+ sessionId,
+ startedAt: startedAtRef.current,
+ });
+ } catch (err) {
+ await handleError(err, sessionId);
+ }
+ }, [handleError, perspective]);
+
+ const stop = useCallback(async () => {
+ if (state.kind !== "capturing") return;
+ const sessionId = state.sessionId;
+ setState({ kind: "stopping", sessionId });
+ try {
+ const recorder = recorderRef.current;
+ if (recorder && recorder.state !== "inactive") {
+ await new Promise((resolve) => {
+ recorder.onstop = () => resolve();
+ recorder.stop();
+ });
+ }
+ await sourceRef.current?.stop();
+ await uploaderRef.current?.drain();
+
+ const durationSec = (Date.now() - startedAtRef.current) / 1000;
+ const finalizeRes = await fetch(
+ `/api/mocap/sessions/${sessionId}/finalize`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ durationSec }),
+ },
+ );
+ if (!finalizeRes.ok) {
+ throw new Error(`Finalize failed: ${finalizeRes.status}`);
+ }
+ const finalized: {
+ id: string;
+ durationSec: number;
+ frameCount: number;
+ } = await finalizeRes.json();
+ await teardown();
+ setState({
+ kind: "done",
+ sessionId: finalized.id,
+ durationSec: finalized.durationSec,
+ frameCount: finalized.frameCount,
+ });
+ } catch (err) {
+ await handleError(err, sessionId);
+ }
+ }, [state, handleError, teardown]);
+
+ useEffect(() => {
+ return () => {
+ sourceRef.current?.stop().catch(() => {});
+ recorderRef.current?.stop();
+ teardown();
+ };
+ }, [teardown]);
+
+ useEffect(() => {
+ if (state.kind !== "capturing") return;
+ const onUnload = () => {
+ try {
+ recorderRef.current?.requestData?.();
+ recorderRef.current?.stop();
+ } catch {
+ // ignore
+ }
+ };
+ window.addEventListener("beforeunload", onUnload);
+ return () => window.removeEventListener("beforeunload", onUnload);
+ }, [state.kind]);
+
+ return (
+
+
+
+ Motion capture session
+
+ Single-webcam pose capture. Camera permission is requested only when
+ you click Start.
+
+
+
+
+
+ Perspective:
+
+ setPerspective(e.target.value as "side-left" | "side-right")
+ }
+ disabled={state.kind !== "idle" && state.kind !== "done"}
+ >
+ Side (right toward camera)
+ Side (left toward camera)
+
+
+ {state.kind === "idle" || state.kind === "done" ? (
+
+ Start mocap session
+
+ ) : null}
+ {state.kind === "starting" ? (
+
+ Starting…
+
+ ) : null}
+ {state.kind === "capturing" ? (
+
+ Stop
+
+ ) : null}
+ {state.kind === "stopping" ? (
+
+ Finalising…
+
+ ) : null}
+
+
+
+
+ {state.kind === "capturing" ? (
+
+
+ REC {elapsedSec.toFixed(1)}s
+
+ ) : null}
+
+
+
+
+
+
+ 0
+ ? (framesEncoded / elapsedSec).toFixed(1)
+ : "0.0"
+ }
+ />
+
+
+ {state.kind === "done" ? (
+
+
+ Session {state.sessionId} stored.
+
+
+ {state.frameCount} pose frames · {state.durationSec.toFixed(1)}s
+ duration
+
+
+ ) : null}
+
+ {state.kind === "error" ? (
+
+ Error: {state.message}
+
+ ) : null}
+
+
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+
+function pickRecorderMime(): string {
+ const candidates = [
+ "video/webm;codecs=vp9",
+ "video/webm;codecs=vp8",
+ "video/webm",
+ "video/mp4",
+ ];
+ for (const c of candidates) {
+ if (
+ typeof MediaRecorder !== "undefined" &&
+ MediaRecorder.isTypeSupported(c)
+ ) {
+ return c;
+ }
+ }
+ return "video/webm";
+}
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
index 05b45e6..2076c94 100644
--- a/src/components/navigation.tsx
+++ b/src/components/navigation.tsx
@@ -16,7 +16,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { settings } from '@/lib/settings';
-import { Upload, BarChart3, List, Trophy, MessageCircle, Target, Settings as SettingsIcon, Gauge, Brain, User, LogOut, UserCircle, Sun, Moon, Monitor, RefreshCw } from 'lucide-react';
+import { Upload, BarChart3, List, Trophy, MessageCircle, Target, Settings as SettingsIcon, Gauge, Brain, User, LogOut, UserCircle, Sun, Moon, Monitor, RefreshCw, Video } from 'lucide-react';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: BarChart3 },
@@ -26,6 +26,7 @@ const navigation = [
{ name: 'Personal Records', href: '/prs', icon: Trophy },
{ name: 'Training Plans', href: '/plans', icon: Target },
{ name: 'AI Coach', href: '/chat', icon: MessageCircle },
+ { name: 'Mocap', href: '/mocap', icon: Video },
{ name: 'Sync', href: '/sync', icon: RefreshCw },
];
diff --git a/src/lib/mocap/analysis/index.ts b/src/lib/mocap/analysis/index.ts
new file mode 100644
index 0000000..7939373
--- /dev/null
+++ b/src/lib/mocap/analysis/index.ts
@@ -0,0 +1,5 @@
+export * from "./types";
+export * from "./strokePhaseSegmenter";
+export * from "./postureMetrics";
+export * from "./postureFaultDetector";
+export * from "./postureThresholds";
diff --git a/src/lib/mocap/analysis/postureFaultDetector.ts b/src/lib/mocap/analysis/postureFaultDetector.ts
new file mode 100644
index 0000000..405cea9
--- /dev/null
+++ b/src/lib/mocap/analysis/postureFaultDetector.ts
@@ -0,0 +1,160 @@
+import { postureThresholdsV1, type PostureThresholdBands } from "./postureThresholds";
+import type { PostureFault, PostureMetrics } from "./types";
+
+export function PostureFaultDetector(
+ metrics: PostureMetrics,
+ thresholds: PostureThresholdBands = postureThresholdsV1.thresholds,
+): PostureFault[] {
+ const faults: PostureFault[] = [];
+
+ if (
+ metrics.backAngleAtCatchDeg <
+ thresholds.rounded_back_at_catch.criticalBelowDeg
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "rounded_back_at_catch",
+ severity: "critical",
+ phase: "catch",
+ evidence: {
+ metric: "backAngleAtCatchDeg",
+ value: metrics.backAngleAtCatchDeg,
+ threshold: thresholds.rounded_back_at_catch.criticalBelowDeg,
+ },
+ });
+ } else if (
+ metrics.backAngleAtCatchDeg <
+ thresholds.rounded_back_at_catch.warningBelowDeg
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "rounded_back_at_catch",
+ severity: "warning",
+ phase: "catch",
+ evidence: {
+ metric: "backAngleAtCatchDeg",
+ value: metrics.backAngleAtCatchDeg,
+ threshold: thresholds.rounded_back_at_catch.warningBelowDeg,
+ },
+ });
+ }
+
+ const armLead = metrics.armBendBeforeLegsCompleteFrames;
+ if (
+ armLead !== null &&
+ armLead >= thresholds.early_arm_bend.warningBeforeLegsCompleteFrames
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "early_arm_bend",
+ severity: "warning",
+ phase: "drive",
+ evidence: {
+ metric: "armBendBeforeLegsCompleteFrames",
+ value: armLead,
+ threshold: thresholds.early_arm_bend.warningBeforeLegsCompleteFrames,
+ frameIndex: metrics.armBendOnsetFrameIndex ?? undefined,
+ },
+ });
+ } else if (
+ armLead !== null &&
+ armLead >= thresholds.early_arm_bend.infoBeforeLegsCompleteFrames
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "early_arm_bend",
+ severity: "info",
+ phase: "drive",
+ evidence: {
+ metric: "armBendBeforeLegsCompleteFrames",
+ value: armLead,
+ threshold: thresholds.early_arm_bend.infoBeforeLegsCompleteFrames,
+ frameIndex: metrics.armBendOnsetFrameIndex ?? undefined,
+ },
+ });
+ }
+
+ const openingOffset = metrics.hipKneeOpeningOffsetFrames;
+ if (
+ openingOffset !== null &&
+ openingOffset <=
+ -thresholds.back_opens_before_legs_drive
+ .warningTorsoOpensBeforeLegsFrames
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "back_opens_before_legs_drive",
+ severity: "warning",
+ phase: "drive",
+ evidence: {
+ metric: "hipKneeOpeningOffsetFrames",
+ value: openingOffset,
+ threshold:
+ -thresholds.back_opens_before_legs_drive
+ .warningTorsoOpensBeforeLegsFrames,
+ },
+ });
+ }
+
+ if (metrics.laybackAngleDeg > thresholds.excessive_layback.warningAboveDeg) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "excessive_layback",
+ severity: "warning",
+ phase: "finish",
+ evidence: {
+ metric: "laybackAngleDeg",
+ value: metrics.laybackAngleDeg,
+ threshold: thresholds.excessive_layback.warningAboveDeg,
+ },
+ });
+ } else if (
+ metrics.laybackAngleDeg > thresholds.excessive_layback.infoAboveDeg
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "excessive_layback",
+ severity: "info",
+ phase: "finish",
+ evidence: {
+ metric: "laybackAngleDeg",
+ value: metrics.laybackAngleDeg,
+ threshold: thresholds.excessive_layback.infoAboveDeg,
+ },
+ });
+ }
+
+ if (
+ metrics.recoveryDriveRatio >
+ thresholds.slow_recovery_ratio.criticalAboveRatio
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "slow_recovery_ratio",
+ severity: "critical",
+ phase: "recovery",
+ evidence: {
+ metric: "recoveryDriveRatio",
+ value: metrics.recoveryDriveRatio,
+ threshold: thresholds.slow_recovery_ratio.criticalAboveRatio,
+ },
+ });
+ } else if (
+ metrics.recoveryDriveRatio >
+ thresholds.slow_recovery_ratio.warningAboveRatio
+ ) {
+ faults.push({
+ strokeIndex: metrics.strokeIndex,
+ faultType: "slow_recovery_ratio",
+ severity: "warning",
+ phase: "recovery",
+ evidence: {
+ metric: "recoveryDriveRatio",
+ value: metrics.recoveryDriveRatio,
+ threshold: thresholds.slow_recovery_ratio.warningAboveRatio,
+ },
+ });
+ }
+
+ return faults;
+}
diff --git a/src/lib/mocap/analysis/postureMetrics.ts b/src/lib/mocap/analysis/postureMetrics.ts
new file mode 100644
index 0000000..36e88c7
--- /dev/null
+++ b/src/lib/mocap/analysis/postureMetrics.ts
@@ -0,0 +1,233 @@
+import {
+ getPosePoint,
+ landmarkSide,
+ type Calibration,
+ type PoseAnalysisFrame,
+ type PoseFrameStream,
+ type PoseLandmarkName,
+ type PosePoint,
+ type PostureMetrics,
+ type Stroke,
+} from "./types";
+
+const MIN_CONFIDENCE = 0.25;
+
+export function PostureMetricsCalculator(
+ stream: PoseFrameStream,
+ stroke: Stroke,
+ calibration?: Calibration,
+): PostureMetrics {
+ void calibration;
+ const catchFrame = frameAt(stream, stroke.catchFrameIndex);
+ const finishFrame = frameAt(stream, stroke.finishFrameIndex);
+ const backAngleAtCatchDeg = torsoBackAngleDeg(stream, catchFrame);
+ const backAngleAtFinishDeg = torsoBackAngleDeg(stream, finishFrame);
+ const laybackAngleDeg = Math.max(0, 90 - backAngleAtFinishDeg);
+
+ const legSignal = legExtensionSignal(stream, stroke);
+ const catchLeg = legSignal[0]?.value ?? 0;
+ const finishLeg = legSignal[stroke.finishFrameIndex - stroke.catchFrameIndex]
+ ?.value ?? catchLeg;
+ const legRange = Math.max(0.0001, finishLeg - catchLeg);
+
+ const legExtensionStartFrameIndex = firstSignalFrameAtLeast(
+ legSignal,
+ catchLeg + legRange * 0.2,
+ );
+ const legExtensionCompleteFrameIndex = firstSignalFrameAtLeast(
+ legSignal,
+ catchLeg + legRange * 0.8,
+ );
+ const torsoOpenFrameIndex = firstTorsoChangeFrame(
+ stream,
+ stroke,
+ backAngleAtCatchDeg,
+ );
+ const armBendOnsetFrameIndex = firstArmBendFrame(stream, stroke);
+
+ return {
+ strokeIndex: stroke.strokeIndex,
+ segmentationSource: stroke.segmentationSource,
+ backAngleAtCatchDeg,
+ backAngleAtFinishDeg,
+ laybackAngleDeg,
+ hipKneeOpeningOffsetFrames:
+ legExtensionStartFrameIndex === null || torsoOpenFrameIndex === null
+ ? null
+ : torsoOpenFrameIndex - legExtensionStartFrameIndex,
+ armBendOnsetFrameIndex,
+ legExtensionCompleteFrameIndex,
+ armBendBeforeLegsCompleteFrames:
+ armBendOnsetFrameIndex === null || legExtensionCompleteFrameIndex === null
+ ? null
+ : legExtensionCompleteFrameIndex - armBendOnsetFrameIndex,
+ recoveryDriveRatio: recoveryDriveRatio(stroke),
+ leftRightAsymmetry:
+ stream.capturePerspective === "sidecar-3d"
+ ? { available: false, reason: "insufficient-tracking" }
+ : { available: false, reason: "requires-sidecar-3d" },
+ shinVerticalAtCatchDeg:
+ stream.capturePerspective === "sidecar-3d"
+ ? { available: false, reason: "insufficient-tracking" }
+ : { available: false, reason: "requires-sidecar-3d" },
+ kneeTrackDeviation:
+ stream.capturePerspective === "sidecar-3d"
+ ? { available: false, reason: "insufficient-tracking" }
+ : { available: false, reason: "requires-sidecar-3d" },
+ };
+}
+
+function frameAt(stream: PoseFrameStream, frameIndex: number): PoseAnalysisFrame {
+ const frame = stream.frames[frameIndex];
+ if (!frame) {
+ throw new Error(`Frame ${frameIndex} is outside the PoseFrameStream`);
+ }
+ return frame;
+}
+
+function sideNames(stream: PoseFrameStream): {
+ shoulder: PoseLandmarkName;
+ elbow: PoseLandmarkName;
+ wrist: PoseLandmarkName;
+ hip: PoseLandmarkName;
+ knee: PoseLandmarkName;
+} {
+ const side = landmarkSide(stream.capturePerspective);
+ return {
+ shoulder: `${side}Shoulder` as PoseLandmarkName,
+ elbow: `${side}Elbow` as PoseLandmarkName,
+ wrist: `${side}Wrist` as PoseLandmarkName,
+ hip: `${side}Hip` as PoseLandmarkName,
+ knee: `${side}Knee` as PoseLandmarkName,
+ };
+}
+
+function requiredPoint(
+ frame: PoseAnalysisFrame,
+ name: PoseLandmarkName,
+): PosePoint {
+ const point = getPosePoint(frame, name);
+ if (!point || point.confidence < MIN_CONFIDENCE) {
+ throw new Error(`Missing tracked landmark ${name}`);
+ }
+ return point;
+}
+
+function torsoBackAngleDeg(
+ stream: PoseFrameStream,
+ frame: PoseAnalysisFrame,
+): number {
+ const names = sideNames(stream);
+ const hip = requiredPoint(frame, names.hip);
+ const shoulder = requiredPoint(frame, names.shoulder);
+ const dx = shoulder.x - hip.x;
+ const dyUp = hip.y - shoulder.y;
+ const raw = radiansToDegrees(Math.atan2(dyUp, dx));
+ const normalized = raw < 0 ? raw + 180 : raw;
+ return normalized > 90 ? 180 - normalized : normalized;
+}
+
+function legExtensionSignal(
+ stream: PoseFrameStream,
+ stroke: Stroke,
+): Array<{ frameIndex: number; value: number }> {
+ const names = sideNames(stream);
+ const signal: Array<{ frameIndex: number; value: number }> = [];
+ for (
+ let frameIndex = stroke.catchFrameIndex;
+ frameIndex <= stroke.finishFrameIndex;
+ frameIndex++
+ ) {
+ const frame = frameAt(stream, frameIndex);
+ const hip = requiredPoint(frame, names.hip);
+ const knee = requiredPoint(frame, names.knee);
+ signal.push({
+ frameIndex,
+ value: Math.hypot(hip.x - knee.x, hip.y - knee.y),
+ });
+ }
+ return signal;
+}
+
+function firstSignalFrameAtLeast(
+ signal: Array<{ frameIndex: number; value: number }>,
+ threshold: number,
+): number | null {
+ for (const point of signal) {
+ if (point.value >= threshold) return point.frameIndex;
+ }
+ return null;
+}
+
+function firstTorsoChangeFrame(
+ stream: PoseFrameStream,
+ stroke: Stroke,
+ catchAngleDeg: number,
+): number | null {
+ for (
+ let frameIndex = stroke.catchFrameIndex + 1;
+ frameIndex <= stroke.finishFrameIndex;
+ frameIndex++
+ ) {
+ const frame = frameAt(stream, frameIndex);
+ if (Math.abs(torsoBackAngleDeg(stream, frame) - catchAngleDeg) >= 5) {
+ return frameIndex;
+ }
+ }
+ return null;
+}
+
+function firstArmBendFrame(
+ stream: PoseFrameStream,
+ stroke: Stroke,
+): number | null {
+ const initialAngle = elbowAngleDeg(stream, frameAt(stream, stroke.catchFrameIndex));
+ const threshold = Math.min(160, initialAngle - 15);
+ for (
+ let frameIndex = stroke.catchFrameIndex + 1;
+ frameIndex <= stroke.finishFrameIndex;
+ frameIndex++
+ ) {
+ const angle = elbowAngleDeg(stream, frameAt(stream, frameIndex));
+ if (angle <= threshold) return frameIndex;
+ }
+ return null;
+}
+
+function elbowAngleDeg(
+ stream: PoseFrameStream,
+ frame: PoseAnalysisFrame,
+): number {
+ const names = sideNames(stream);
+ const shoulder = requiredPoint(frame, names.shoulder);
+ const elbow = requiredPoint(frame, names.elbow);
+ const wrist = requiredPoint(frame, names.wrist);
+ return angleAtPointDeg(shoulder, elbow, wrist);
+}
+
+function angleAtPointDeg(a: PosePoint, vertex: PosePoint, b: PosePoint): number {
+ const ax = a.x - vertex.x;
+ const ay = a.y - vertex.y;
+ const bx = b.x - vertex.x;
+ const by = b.y - vertex.y;
+ const denom = Math.hypot(ax, ay) * Math.hypot(bx, by);
+ if (denom === 0) return 0;
+ const cos = Math.max(-1, Math.min(1, (ax * bx + ay * by) / denom));
+ return radiansToDegrees(Math.acos(cos));
+}
+
+function recoveryDriveRatio(stroke: Stroke): number {
+ const driveFrames = Math.max(
+ 1,
+ stroke.finishFrameIndex - stroke.driveStartFrameIndex,
+ );
+ const recoveryFrames = Math.max(
+ 1,
+ stroke.nextCatchFrameIndex - stroke.recoveryStartFrameIndex,
+ );
+ return recoveryFrames / driveFrames;
+}
+
+function radiansToDegrees(radians: number): number {
+ return (radians * 180) / Math.PI;
+}
diff --git a/src/lib/mocap/analysis/postureThresholds.ts b/src/lib/mocap/analysis/postureThresholds.ts
new file mode 100644
index 0000000..ceef3f5
--- /dev/null
+++ b/src/lib/mocap/analysis/postureThresholds.ts
@@ -0,0 +1,154 @@
+import type { PostureFaultType } from "./types";
+
+export type PostureThresholdVersion = "V1" | `V${number}`;
+
+export interface PostureThresholdBands {
+ rounded_back_at_catch: {
+ warningBelowDeg: number;
+ criticalBelowDeg: number;
+ };
+ early_arm_bend: {
+ infoBeforeLegsCompleteFrames: number;
+ warningBeforeLegsCompleteFrames: number;
+ };
+ back_opens_before_legs_drive: {
+ warningTorsoOpensBeforeLegsFrames: number;
+ };
+ excessive_layback: {
+ infoAboveDeg: number;
+ warningAboveDeg: number;
+ };
+ slow_recovery_ratio: {
+ warningAboveRatio: number;
+ criticalAboveRatio: number;
+ };
+}
+
+export interface VersionedPostureThresholds {
+ version: PostureThresholdVersion;
+ thresholds: PostureThresholdBands;
+}
+
+export interface UserPostureThresholdSettings extends VersionedPostureThresholds {
+ userOverridden: boolean;
+}
+
+export const POSTURE_FAULT_CATALOG_V1: readonly PostureFaultType[] = [
+ "rounded_back_at_catch",
+ "early_arm_bend",
+ "back_opens_before_legs_drive",
+ "excessive_layback",
+ "slow_recovery_ratio",
+];
+
+export const postureThresholdsV1: VersionedPostureThresholds = {
+ version: "V1",
+ thresholds: {
+ // British Rowing Technique: catch/drive keeps the back straight and leaning
+ // forward; CONTEXT.md fixes the v1 warning/critical bands at 30/20 deg.
+ rounded_back_at_catch: {
+ warningBelowDeg: 30,
+ criticalBelowDeg: 20,
+ },
+ // Concept2 Indoor Rowing Technique: drive sequence is legs, body, then arms;
+ // early elbow flexion before leg extension is therefore treated conservatively.
+ early_arm_bend: {
+ infoBeforeLegsCompleteFrames: 1,
+ warningBeforeLegsCompleteFrames: 4,
+ },
+ // British Rowing and Concept2 both teach the drive as legs before body swing;
+ // any torso opening before the leg signal starts is a v1 warning.
+ back_opens_before_legs_drive: {
+ warningTorsoOpensBeforeLegsFrames: 1,
+ },
+ // Concept2 finish guidance says the upper body leans back slightly; British
+ // Rowing calls leaning too far back a recovery-delaying fault.
+ excessive_layback: {
+ infoAboveDeg: 30,
+ warningAboveDeg: 45,
+ },
+ // Concept2 frames recovery as the rest/preparation phase; ratios beyond 2.5x
+ // are deliberately conservative v1 flags for very slow recoveries.
+ slow_recovery_ratio: {
+ warningAboveRatio: 2.5,
+ criticalAboveRatio: 3.5,
+ },
+ },
+};
+
+export function defaultPostureThresholdSettings(
+ defaults: VersionedPostureThresholds = postureThresholdsV1,
+): UserPostureThresholdSettings {
+ return {
+ version: defaults.version,
+ thresholds: cloneThresholds(defaults.thresholds),
+ userOverridden: false,
+ };
+}
+
+export function migratePostureThresholdSettings(
+ stored: unknown,
+ defaults: VersionedPostureThresholds = postureThresholdsV1,
+): UserPostureThresholdSettings {
+ if (!isUserPostureThresholdSettings(stored)) {
+ return defaultPostureThresholdSettings(defaults);
+ }
+
+ if (stored.version !== defaults.version && !stored.userOverridden) {
+ return defaultPostureThresholdSettings(defaults);
+ }
+
+ return {
+ version: stored.version,
+ thresholds: cloneThresholds(stored.thresholds),
+ userOverridden: stored.userOverridden,
+ };
+}
+
+function isUserPostureThresholdSettings(
+ value: unknown,
+): value is UserPostureThresholdSettings {
+ if (!value || typeof value !== "object") return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.version === "string" &&
+ typeof candidate.userOverridden === "boolean" &&
+ isPostureThresholdBands(candidate.thresholds)
+ );
+}
+
+function isPostureThresholdBands(
+ value: unknown,
+): value is PostureThresholdBands {
+ if (!value || typeof value !== "object") return false;
+ const t = value as PostureThresholdBands;
+ return (
+ isFiniteNumber(t.rounded_back_at_catch?.warningBelowDeg) &&
+ isFiniteNumber(t.rounded_back_at_catch?.criticalBelowDeg) &&
+ isFiniteNumber(t.early_arm_bend?.infoBeforeLegsCompleteFrames) &&
+ isFiniteNumber(t.early_arm_bend?.warningBeforeLegsCompleteFrames) &&
+ isFiniteNumber(
+ t.back_opens_before_legs_drive?.warningTorsoOpensBeforeLegsFrames,
+ ) &&
+ isFiniteNumber(t.excessive_layback?.infoAboveDeg) &&
+ isFiniteNumber(t.excessive_layback?.warningAboveDeg) &&
+ isFiniteNumber(t.slow_recovery_ratio?.warningAboveRatio) &&
+ isFiniteNumber(t.slow_recovery_ratio?.criticalAboveRatio)
+ );
+}
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function cloneThresholds(thresholds: PostureThresholdBands): PostureThresholdBands {
+ return {
+ rounded_back_at_catch: { ...thresholds.rounded_back_at_catch },
+ early_arm_bend: { ...thresholds.early_arm_bend },
+ back_opens_before_legs_drive: {
+ ...thresholds.back_opens_before_legs_drive,
+ },
+ excessive_layback: { ...thresholds.excessive_layback },
+ slow_recovery_ratio: { ...thresholds.slow_recovery_ratio },
+ };
+}
diff --git a/src/lib/mocap/analysis/strokePhaseSegmenter.ts b/src/lib/mocap/analysis/strokePhaseSegmenter.ts
new file mode 100644
index 0000000..0385e63
--- /dev/null
+++ b/src/lib/mocap/analysis/strokePhaseSegmenter.ts
@@ -0,0 +1,160 @@
+import {
+ getPosePoint,
+ landmarkSide,
+ type PoseFrameStream,
+ type PoseLandmarkName,
+ type Stroke,
+} from "./types";
+
+interface SignalPoint {
+ frameIndex: number;
+ value: number;
+ tracked: boolean;
+}
+
+export function StrokePhaseSegmenter(stream: PoseFrameStream): Stroke[] {
+ if (stream.frames.length < 3) return [];
+
+ const signal = smoothSignal(buildHipKneeDistanceSignal(stream), stream.fps);
+ const catches = findCatchCandidates(signal, stream.fps);
+ const strokes: Stroke[] = [];
+
+ for (let i = 0; i < catches.length - 1; i++) {
+ const catchFrameIndex = catches[i];
+ const nextCatchFrameIndex = catches[i + 1];
+ if (nextCatchFrameIndex - catchFrameIndex < 3) continue;
+
+ const finishFrameIndex = maxSignalFrame(
+ signal,
+ catchFrameIndex,
+ nextCatchFrameIndex,
+ );
+ if (finishFrameIndex <= catchFrameIndex) continue;
+
+ const recoveryStartFrameIndex = Math.min(
+ finishFrameIndex + 1,
+ nextCatchFrameIndex,
+ );
+ const trackedFrames = signal
+ .slice(catchFrameIndex, nextCatchFrameIndex + 1)
+ .filter((p) => p.tracked).length;
+
+ strokes.push({
+ strokeIndex: strokes.length,
+ segmentationSource: "pose-segmented",
+ catchFrameIndex,
+ driveStartFrameIndex: catchFrameIndex,
+ finishFrameIndex,
+ recoveryStartFrameIndex,
+ nextCatchFrameIndex,
+ confidence: trackedFrames / (nextCatchFrameIndex - catchFrameIndex + 1),
+ });
+ }
+
+ return strokes;
+}
+
+function buildHipKneeDistanceSignal(stream: PoseFrameStream): SignalPoint[] {
+ const side = landmarkSide(stream.capturePerspective);
+ const hipName = `${side}Hip` as PoseLandmarkName;
+ const kneeName = `${side}Knee` as PoseLandmarkName;
+
+ return stream.frames.map((frame, frameIndex) => {
+ const hip = getPosePoint(frame, hipName);
+ const knee = getPosePoint(frame, kneeName);
+ if (!hip || !knee || hip.confidence < 0.25 || knee.confidence < 0.25) {
+ return { frameIndex, value: Number.NaN, tracked: false };
+ }
+ const dx = hip.x - knee.x;
+ const dy = hip.y - knee.y;
+ return {
+ frameIndex,
+ value: Math.hypot(dx, dy),
+ tracked: true,
+ };
+ });
+}
+
+function smoothSignal(signal: SignalPoint[], fps: number): SignalPoint[] {
+ const radius = Math.max(1, Math.round(fps * 0.06));
+ return signal.map((point, i) => {
+ let total = 0;
+ let count = 0;
+ for (
+ let j = Math.max(0, i - radius);
+ j <= Math.min(signal.length - 1, i + radius);
+ j++
+ ) {
+ const value = signal[j].value;
+ if (Number.isFinite(value)) {
+ total += value;
+ count++;
+ }
+ }
+ return {
+ frameIndex: point.frameIndex,
+ value: count > 0 ? total / count : point.value,
+ tracked: point.tracked,
+ };
+ });
+}
+
+function findCatchCandidates(signal: SignalPoint[], fps: number): number[] {
+ const values = signal
+ .map((p) => p.value)
+ .filter((value) => Number.isFinite(value));
+ if (values.length < 3) return [];
+
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const range = Math.max(0.0001, max - min);
+ const lowThreshold = min + range * 0.35;
+ const minGap = Math.max(4, Math.round(fps * 0.4));
+ const catches: number[] = [];
+
+ for (let i = 0; i < signal.length; i++) {
+ const prev = signal[Math.max(0, i - 1)]?.value;
+ const cur = signal[i].value;
+ const next = signal[Math.min(signal.length - 1, i + 1)]?.value;
+ if (!Number.isFinite(cur) || cur > lowThreshold) continue;
+
+ const isEndpointMinimum =
+ (i === 0 && Number.isFinite(next) && cur <= next) ||
+ (i === signal.length - 1 && Number.isFinite(prev) && cur <= prev);
+ const isInteriorMinimum =
+ i > 0 &&
+ i < signal.length - 1 &&
+ Number.isFinite(prev) &&
+ Number.isFinite(next) &&
+ cur <= prev &&
+ cur <= next &&
+ (cur < prev || cur < next);
+
+ if (!isEndpointMinimum && !isInteriorMinimum) continue;
+ const last = catches[catches.length - 1];
+ if (last === undefined || i - last >= minGap) {
+ catches.push(i);
+ } else if (cur < signal[last].value) {
+ catches[catches.length - 1] = i;
+ }
+ }
+
+ return catches;
+}
+
+function maxSignalFrame(
+ signal: SignalPoint[],
+ startFrame: number,
+ endFrame: number,
+): number {
+ let maxFrame = startFrame;
+ let maxValue = -Infinity;
+ for (let i = startFrame; i <= endFrame; i++) {
+ const value = signal[i]?.value;
+ if (Number.isFinite(value) && value > maxValue) {
+ maxValue = value;
+ maxFrame = i;
+ }
+ }
+ return maxFrame;
+}
diff --git a/src/lib/mocap/analysis/types.ts b/src/lib/mocap/analysis/types.ts
new file mode 100644
index 0000000..d8ff2ae
--- /dev/null
+++ b/src/lib/mocap/analysis/types.ts
@@ -0,0 +1,144 @@
+export type CapturePerspective = "side-left" | "side-right" | "sidecar-3d";
+
+export type StrokeSegmentationSource = "pose-segmented" | "csv-aligned";
+
+export type PostureFaultType =
+ | "rounded_back_at_catch"
+ | "early_arm_bend"
+ | "back_opens_before_legs_drive"
+ | "excessive_layback"
+ | "slow_recovery_ratio";
+
+export type FaultSeverity = "info" | "warning" | "critical";
+
+export type PoseLandmarkName =
+ | "leftShoulder"
+ | "rightShoulder"
+ | "leftElbow"
+ | "rightElbow"
+ | "leftWrist"
+ | "rightWrist"
+ | "leftHip"
+ | "rightHip"
+ | "leftKnee"
+ | "rightKnee"
+ | "leftAnkle"
+ | "rightAnkle";
+
+export const POSE_LANDMARK_INDEX: Record = {
+ leftShoulder: 11,
+ rightShoulder: 12,
+ leftElbow: 13,
+ rightElbow: 14,
+ leftWrist: 15,
+ rightWrist: 16,
+ leftHip: 23,
+ rightHip: 24,
+ leftKnee: 25,
+ rightKnee: 26,
+ leftAnkle: 27,
+ rightAnkle: 28,
+};
+
+export interface PosePoint {
+ x: number;
+ y: number;
+ confidence: number;
+}
+
+export type PoseKeypoints =
+ | readonly PosePoint[]
+ | Partial>;
+
+export interface PoseAnalysisFrame {
+ timestampMs: number;
+ keypoints: PoseKeypoints;
+ qualityFlags?: number;
+}
+
+export interface PoseFrameStream {
+ fps: number;
+ capturePerspective: CapturePerspective;
+ frames: readonly PoseAnalysisFrame[];
+}
+
+export interface Stroke {
+ strokeIndex: number;
+ segmentationSource: StrokeSegmentationSource;
+ catchFrameIndex: number;
+ driveStartFrameIndex: number;
+ finishFrameIndex: number;
+ recoveryStartFrameIndex: number;
+ nextCatchFrameIndex: number;
+ confidence: number;
+}
+
+export interface Calibration {
+ capturePerspective: CapturePerspective;
+ catchFrame?: PoseAnalysisFrame;
+ finishFrame?: PoseAnalysisFrame;
+}
+
+export interface UnavailableMetric {
+ available: false;
+ reason: "requires-sidecar-3d" | "insufficient-tracking";
+}
+
+export interface AvailableMetric {
+ available: true;
+ value: T;
+}
+
+export type MaybeMetric = AvailableMetric | UnavailableMetric;
+
+export interface PostureMetrics {
+ strokeIndex: number;
+ segmentationSource: StrokeSegmentationSource;
+ backAngleAtCatchDeg: number;
+ backAngleAtFinishDeg: number;
+ laybackAngleDeg: number;
+ hipKneeOpeningOffsetFrames: number | null;
+ armBendOnsetFrameIndex: number | null;
+ legExtensionCompleteFrameIndex: number | null;
+ armBendBeforeLegsCompleteFrames: number | null;
+ recoveryDriveRatio: number;
+ leftRightAsymmetry: MaybeMetric;
+ shinVerticalAtCatchDeg: MaybeMetric;
+ kneeTrackDeviation: MaybeMetric;
+}
+
+export interface PostureFault {
+ strokeIndex: number;
+ faultType: PostureFaultType;
+ severity: FaultSeverity;
+ phase: "catch" | "drive" | "finish" | "recovery";
+ evidence: {
+ metric: keyof PostureMetrics | "armBendBeforeLegsCompleteFrames";
+ value: number;
+ threshold: number;
+ frameIndex?: number;
+ };
+}
+
+export function getPosePoint(
+ frame: PoseAnalysisFrame,
+ name: PoseLandmarkName,
+): PosePoint | null {
+ const keypoints = frame.keypoints;
+ if (isPosePointArray(keypoints)) {
+ const point = keypoints[POSE_LANDMARK_INDEX[name]];
+ return point?.confidence > 0 ? point : null;
+ }
+ const point = keypoints[name];
+ return point?.confidence && point.confidence > 0 ? point : null;
+}
+
+function isPosePointArray(keypoints: PoseKeypoints): keypoints is readonly PosePoint[] {
+ return Array.isArray(keypoints);
+}
+
+export function landmarkSide(
+ perspective: CapturePerspective,
+): "left" | "right" {
+ return perspective === "side-left" ? "left" : "right";
+}
diff --git a/src/lib/mocap/browserPoseSource.ts b/src/lib/mocap/browserPoseSource.ts
new file mode 100644
index 0000000..ee7bdd6
--- /dev/null
+++ b/src/lib/mocap/browserPoseSource.ts
@@ -0,0 +1,222 @@
+/**
+ * BrowserPoseSource: main-thread orchestration for the pose worker.
+ *
+ * Owns the worker, the upload queue, and the per-frame ImageBitmap pipeline.
+ * Frames flow: → grabFrame() → postMessage(bitmap) → worker → encoded
+ * PoseFrame bytes → upload queue → POST /api/mocap/sessions/:id/pose.
+ */
+
+import { BYTES_PER_FRAME_V1 } from "./poseFrameStream";
+
+export const POSE_MODEL_URL =
+ process.env.NEXT_PUBLIC_MOCAP_POSE_MODEL_URL ??
+ "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task";
+
+export const MEDIAPIPE_WASM_BASE =
+ process.env.NEXT_PUBLIC_MOCAP_MEDIAPIPE_WASM_BASE ??
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
+
+export type PoseSourceStatus =
+ | "idle"
+ | "loading"
+ | "ready"
+ | "capturing"
+ | "stopping"
+ | "stopped"
+ | "error";
+
+export interface PoseSourceOptions {
+ sessionId: string;
+ videoEl: HTMLVideoElement;
+ flushBytes?: number;
+ flushIntervalMs?: number;
+ onStatus?: (s: PoseSourceStatus, detail?: string) => void;
+ onFrame?: (info: { framesEncoded: number; landmarkCount: number }) => void;
+ onError?: (err: Error) => void;
+}
+
+export class BrowserPoseSource {
+ private worker: Worker | null = null;
+ private pendingChunks: Uint8Array[] = [];
+ private pendingBytes = 0;
+ private flushTimer: ReturnType | null = null;
+ private uploadInflight: Promise = Promise.resolve();
+ private framesEncoded = 0;
+ private capturing = false;
+ private rvfcHandle: number | null = null;
+ private status: PoseSourceStatus = "idle";
+ private readonly flushBytes: number;
+ private readonly flushIntervalMs: number;
+
+ constructor(private readonly opts: PoseSourceOptions) {
+ this.flushBytes = opts.flushBytes ?? BYTES_PER_FRAME_V1 * 12;
+ this.flushIntervalMs = opts.flushIntervalMs ?? 500;
+ }
+
+ get framesCaptured(): number {
+ return this.framesEncoded;
+ }
+
+ async init(): Promise {
+ this.setStatus("loading");
+ this.worker = new Worker(new URL("./poseWorker.ts", import.meta.url), {
+ type: "module",
+ });
+ await new Promise((resolve, reject) => {
+ const onMessage = (event: MessageEvent) => {
+ const msg = event.data;
+ if (msg?.type === "ready") {
+ this.worker!.removeEventListener("message", onMessage);
+ this.worker!.removeEventListener("error", onError);
+ this.worker!.addEventListener("message", this.handleWorkerMessage);
+ resolve();
+ } else if (msg?.type === "error") {
+ this.worker!.removeEventListener("message", onMessage);
+ this.worker!.removeEventListener("error", onError);
+ reject(new Error(msg.message));
+ }
+ };
+ const onError = (event: ErrorEvent) => {
+ this.worker!.removeEventListener("message", onMessage);
+ this.worker!.removeEventListener("error", onError);
+ reject(new Error(event.message ?? "Worker init failed"));
+ };
+ this.worker!.addEventListener("message", onMessage);
+ this.worker!.addEventListener("error", onError);
+ this.worker!.postMessage({
+ type: "init",
+ wasmBaseUrl: MEDIAPIPE_WASM_BASE,
+ modelAssetUrl: POSE_MODEL_URL,
+ startTimeMs: performance.now(),
+ });
+ });
+ this.setStatus("ready");
+ }
+
+ start(): void {
+ if (!this.worker) throw new Error("PoseSource not initialised");
+ this.capturing = true;
+ this.setStatus("capturing");
+ this.flushTimer = setInterval(() => this.flush(false), this.flushIntervalMs);
+ this.scheduleFrame();
+ }
+
+ async stop(): Promise {
+ this.capturing = false;
+ this.setStatus("stopping");
+ if (this.rvfcHandle !== null && "cancelVideoFrameCallback" in this.opts.videoEl) {
+ (this.opts.videoEl as HTMLVideoElement).cancelVideoFrameCallback(
+ this.rvfcHandle,
+ );
+ this.rvfcHandle = null;
+ }
+ if (this.flushTimer) {
+ clearInterval(this.flushTimer);
+ this.flushTimer = null;
+ }
+ await this.flush(true);
+ await this.uploadInflight;
+ this.worker?.postMessage({ type: "close" });
+ this.worker?.terminate();
+ this.worker = null;
+ this.setStatus("stopped");
+ }
+
+ private scheduleFrame(): void {
+ if (!this.capturing) return;
+ const video = this.opts.videoEl;
+ if (typeof video.requestVideoFrameCallback === "function") {
+ this.rvfcHandle = video.requestVideoFrameCallback((now) => {
+ this.handleFrame(now);
+ this.scheduleFrame();
+ });
+ } else {
+ requestAnimationFrame(() => {
+ this.handleFrame(performance.now());
+ this.scheduleFrame();
+ });
+ }
+ }
+
+ private async handleFrame(timestampMs: number): Promise {
+ const video = this.opts.videoEl;
+ if (!this.worker || !this.capturing || video.readyState < 2) return;
+ try {
+ const bitmap = await createImageBitmap(video);
+ this.worker.postMessage(
+ { type: "frame", bitmap, timestampMs },
+ [bitmap],
+ );
+ } catch (err) {
+ this.opts.onError?.(
+ err instanceof Error ? err : new Error(String(err)),
+ );
+ }
+ }
+
+ private handleWorkerMessage = (event: MessageEvent) => {
+ const msg = event.data;
+ if (msg?.type === "frame") {
+ this.framesEncoded = msg.framesEncoded;
+ this.pendingChunks.push(new Uint8Array(msg.bytes));
+ this.pendingBytes += (msg.bytes as ArrayBuffer).byteLength;
+ this.opts.onFrame?.({
+ framesEncoded: msg.framesEncoded,
+ landmarkCount: msg.landmarkCount,
+ });
+ if (this.pendingBytes >= this.flushBytes) {
+ this.flush(false);
+ }
+ } else if (msg?.type === "error") {
+ const err = new Error(msg.message);
+ this.opts.onError?.(err);
+ this.setStatus("error", msg.message);
+ }
+ };
+
+ private async flush(final: boolean): Promise {
+ if (this.pendingChunks.length === 0) {
+ if (final) await this.uploadInflight;
+ return;
+ }
+ const chunks = this.pendingChunks;
+ const total = this.pendingBytes;
+ this.pendingChunks = [];
+ this.pendingBytes = 0;
+ const buf = new Uint8Array(total);
+ let off = 0;
+ for (const c of chunks) {
+ buf.set(c, off);
+ off += c.byteLength;
+ }
+ this.uploadInflight = this.uploadInflight.then(() =>
+ this.upload(buf).catch((err) => {
+ this.opts.onError?.(
+ err instanceof Error ? err : new Error(String(err)),
+ );
+ }),
+ );
+ if (final) await this.uploadInflight;
+ }
+
+ private async upload(buf: Uint8Array): Promise {
+ const res = await fetch(
+ `/api/mocap/sessions/${this.opts.sessionId}/pose`,
+ {
+ method: "POST",
+ body: new Blob([buf as BlobPart], {
+ type: "application/octet-stream",
+ }),
+ headers: { "Content-Type": "application/octet-stream" },
+ },
+ );
+ if (!res.ok) {
+ throw new Error(`Pose upload failed: ${res.status}`);
+ }
+ }
+
+ private setStatus(s: PoseSourceStatus, detail?: string): void {
+ this.status = s;
+ this.opts.onStatus?.(s, detail);
+ }
+}
diff --git a/src/lib/mocap/poseWorker.ts b/src/lib/mocap/poseWorker.ts
new file mode 100644
index 0000000..44f57ef
--- /dev/null
+++ b/src/lib/mocap/poseWorker.ts
@@ -0,0 +1,173 @@
+///
+/**
+ * BrowserPoseSource Web Worker.
+ *
+ * Receives ImageBitmap frames from the main thread, runs MediaPipe Pose
+ * Landmarker, posts back encoded PoseFrame bytes (BYTES_PER_FRAME_V1 each).
+ * Main thread is responsible for capturing frames and uploading bytes.
+ */
+import {
+ FilesetResolver,
+ PoseLandmarker,
+ type NormalizedLandmark,
+} from "@mediapipe/tasks-vision";
+import {
+ encodeFrame,
+ KEYPOINTS_PER_FRAME_V1,
+ QUALITY_FLAG,
+ type PoseFrame,
+} from "./poseFrameStream";
+
+type InitMessage = {
+ type: "init";
+ wasmBaseUrl: string;
+ modelAssetUrl: string;
+ startTimeMs: number;
+};
+
+type FrameMessage = {
+ type: "frame";
+ bitmap: ImageBitmap;
+ timestampMs: number;
+};
+
+type CloseMessage = { type: "close" };
+
+type WorkerInbound = InitMessage | FrameMessage | CloseMessage;
+
+type ReadyOut = { type: "ready" };
+type FrameOut = {
+ type: "frame";
+ bytes: ArrayBuffer;
+ framesEncoded: number;
+ timestampMs: number;
+ landmarkCount: number;
+};
+type ErrorOut = { type: "error"; message: string };
+
+let landmarker: PoseLandmarker | null = null;
+let captureStartMs = 0;
+let framesEncoded = 0;
+let busy = false;
+
+function buildKeypointsFromLandmarks(
+ landmarks: NormalizedLandmark[],
+): { keypoints: Float32Array; lowConfidence: boolean } {
+ const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ let lowConfidence = false;
+ let lowConfidenceCount = 0;
+ const limit = Math.min(landmarks.length, KEYPOINTS_PER_FRAME_V1);
+ for (let i = 0; i < limit; i++) {
+ const lm = landmarks[i];
+ const visibility = lm.visibility ?? 0;
+ keypoints[i * 3 + 0] = lm.x;
+ keypoints[i * 3 + 1] = lm.y;
+ keypoints[i * 3 + 2] = visibility;
+ if (visibility < 0.4) lowConfidenceCount++;
+ }
+ if (lowConfidenceCount > KEYPOINTS_PER_FRAME_V1 * 0.3) {
+ lowConfidence = true;
+ }
+ return { keypoints, lowConfidence };
+}
+
+async function init(msg: InitMessage): Promise {
+ const fileset = await FilesetResolver.forVisionTasks(msg.wasmBaseUrl);
+ landmarker = await PoseLandmarker.createFromOptions(fileset, {
+ baseOptions: {
+ modelAssetPath: msg.modelAssetUrl,
+ delegate: "GPU",
+ },
+ runningMode: "VIDEO",
+ numPoses: 1,
+ minPoseDetectionConfidence: 0.5,
+ minPosePresenceConfidence: 0.5,
+ minTrackingConfidence: 0.5,
+ });
+ captureStartMs = msg.startTimeMs;
+ framesEncoded = 0;
+ const out: ReadyOut = { type: "ready" };
+ postMessage(out);
+}
+
+function processFrame(msg: FrameMessage): void {
+ if (!landmarker) {
+ msg.bitmap.close();
+ return;
+ }
+ if (busy) {
+ // Drop frame to keep up. Keeps fps stable under load.
+ msg.bitmap.close();
+ return;
+ }
+ busy = true;
+ try {
+ const result = landmarker.detectForVideo(msg.bitmap, msg.timestampMs);
+ const landmarks = result.landmarks?.[0] ?? [];
+
+ let qualityFlags = 0;
+ let keypoints: Float32Array;
+ if (landmarks.length === 0) {
+ keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ qualityFlags |= QUALITY_FLAG.OUT_OF_FRAME;
+ } else {
+ const built = buildKeypointsFromLandmarks(landmarks);
+ keypoints = built.keypoints;
+ if (built.lowConfidence) qualityFlags |= QUALITY_FLAG.LOW_CONFIDENCE;
+ }
+
+ const frame: PoseFrame = {
+ timestampMs: msg.timestampMs - captureStartMs,
+ keypoints,
+ qualityFlags,
+ };
+ const bytes = encodeFrame(frame);
+ framesEncoded += 1;
+
+ const buf = new ArrayBuffer(bytes.byteLength);
+ new Uint8Array(buf).set(bytes);
+ const out: FrameOut = {
+ type: "frame",
+ bytes: buf,
+ framesEncoded,
+ timestampMs: frame.timestampMs,
+ landmarkCount: landmarks.length,
+ };
+ postMessage(out, [buf]);
+ } catch (err) {
+ const out: ErrorOut = {
+ type: "error",
+ message: err instanceof Error ? err.message : String(err),
+ };
+ postMessage(out);
+ } finally {
+ msg.bitmap.close();
+ busy = false;
+ }
+}
+
+function close(): void {
+ landmarker?.close();
+ landmarker = null;
+}
+
+self.onmessage = (event: MessageEvent) => {
+ const msg = event.data;
+ switch (msg.type) {
+ case "init":
+ init(msg).catch((err) => {
+ const out: ErrorOut = {
+ type: "error",
+ message: err instanceof Error ? err.message : String(err),
+ };
+ postMessage(out);
+ });
+ break;
+ case "frame":
+ processFrame(msg);
+ break;
+ case "close":
+ close();
+ break;
+ }
+};
diff --git a/src/lib/mocap/storage.ts b/src/lib/mocap/storage.ts
index e4601b1..17140c9 100644
--- a/src/lib/mocap/storage.ts
+++ b/src/lib/mocap/storage.ts
@@ -7,6 +7,7 @@ export interface MocapStorage {
videoPath(userId: string, sessionId: string): string;
poseStreamPath(userId: string, sessionId: string): string;
appendBytes(storagePath: string, bytes: Uint8Array): Promise;
+ writeAt(storagePath: string, bytes: Uint8Array, offset: number): Promise;
read(storagePath: string, range?: ByteRange): Promise;
size(storagePath: string): Promise;
exists(storagePath: string): Promise;
@@ -41,6 +42,21 @@ class LocalDiskStorage implements MocapStorage {
await fs.appendFile(abs, bytes);
}
+ async writeAt(
+ storagePath: string,
+ bytes: Uint8Array,
+ offset: number,
+ ): Promise {
+ const abs = this.resolve(storagePath);
+ await fs.mkdir(path.dirname(abs), { recursive: true });
+ const fh = await fs.open(abs, "r+");
+ try {
+ await fh.write(bytes, 0, bytes.byteLength, offset);
+ } finally {
+ await fh.close();
+ }
+ }
+
async read(storagePath: string, range?: ByteRange): Promise {
const abs = this.resolve(storagePath);
if (!range) {
diff --git a/src/lib/mocap/videoUploader.ts b/src/lib/mocap/videoUploader.ts
new file mode 100644
index 0000000..bf60180
--- /dev/null
+++ b/src/lib/mocap/videoUploader.ts
@@ -0,0 +1,45 @@
+/**
+ * VideoUploader: serialises MediaRecorder chunks for append-upload to the
+ * mocap video endpoint. Guarantees in-order POSTs even if the recorder fires
+ * `dataavailable` faster than uploads complete, and lets stop() await drain.
+ */
+
+export class VideoUploader {
+ private queue: Promise = Promise.resolve();
+ private bytesUploaded = 0;
+
+ constructor(
+ private readonly sessionId: string,
+ private readonly onError?: (err: Error) => void,
+ ) {}
+
+ get totalBytes(): number {
+ return this.bytesUploaded;
+ }
+
+ enqueue(chunk: Blob): void {
+ if (chunk.size === 0) return;
+ this.queue = this.queue.then(async () => {
+ try {
+ const res = await fetch(
+ `/api/mocap/sessions/${this.sessionId}/video`,
+ {
+ method: "POST",
+ body: chunk,
+ headers: { "Content-Type": "application/octet-stream" },
+ },
+ );
+ if (!res.ok) {
+ throw new Error(`Video upload failed: ${res.status}`);
+ }
+ this.bytesUploaded += chunk.size;
+ } catch (err) {
+ this.onError?.(err instanceof Error ? err : new Error(String(err)));
+ }
+ });
+ }
+
+ async drain(): Promise {
+ await this.queue;
+ }
+}
diff --git a/tests/e2e/mocap-capture.spec.ts b/tests/e2e/mocap-capture.spec.ts
new file mode 100644
index 0000000..b98709d
--- /dev/null
+++ b/tests/e2e/mocap-capture.spec.ts
@@ -0,0 +1,38 @@
+/**
+ * Mocap capture-and-persist smoke test.
+ *
+ * Verifies the route loads and the camera permission is NOT requested before
+ * the user clicks Start (acceptance: "Camera permission prompt fires only when
+ * user clicks Start mocap session, not on page load").
+ *
+ * The full capture round-trip (worker init → MediaPipe load → MediaRecorder →
+ * finalize) requires an authenticated session and reachable model/wasm assets.
+ * Wire that variant in CI once the auth fixture lands; for now this spec
+ * keeps the contract regression-proof on the UI surface.
+ */
+import { test, expect } from "@playwright/test";
+
+test("capture page loads without prompting for camera", async ({ page }) => {
+ let mediaRequested = false;
+ await page.exposeFunction("__markMediaRequested", () => {
+ mediaRequested = true;
+ });
+ await page.addInitScript(() => {
+ const orig = navigator.mediaDevices?.getUserMedia?.bind(
+ navigator.mediaDevices,
+ );
+ if (orig) {
+ navigator.mediaDevices.getUserMedia = (...args) => {
+ // @ts-expect-error injected fn
+ window.__markMediaRequested?.();
+ return orig(...args);
+ };
+ }
+ });
+
+ await page.goto("/mocap");
+ await expect(page.getByText("Motion capture session")).toBeVisible();
+ await expect(page.getByTestId("mocap-start")).toBeVisible();
+ await expect(page.getByTestId("mocap-recording-indicator")).toHaveCount(0);
+ expect(mediaRequested).toBe(false);
+});
diff --git a/tests/fixtures/mocap/asymmetric-side-unavailable.json b/tests/fixtures/mocap/asymmetric-side-unavailable.json
new file mode 100644
index 0000000..f7ab011
--- /dev/null
+++ b/tests/fixtures/mocap/asymmetric-side-unavailable.json
@@ -0,0 +1,1549 @@
+{
+ "name": "asymmetric-side-unavailable",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.39358,
+ "y": -0.75915,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.70358,
+ "y": -0.7191500000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -1.02358,
+ "y": -0.67915,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.36,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.5800000000000001,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.34498,
+ "y": -0.79112,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.65498,
+ "y": -0.75112,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.97498,
+ "y": -0.71112,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.41000000000000003,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.63,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.29460000000000003,
+ "y": -0.8202,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.6046,
+ "y": -0.7802,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.9246000000000001,
+ "y": -0.7402,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.56,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.78,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.24262,
+ "y": -0.8463099999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.55262,
+ "y": -0.8063100000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.87262,
+ "y": -0.76631,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.76,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.98,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.18921,
+ "y": -0.8693500000000001,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.49921000000000004,
+ "y": -0.82935,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.81921,
+ "y": -0.78935,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.9600000000000001,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.18,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.49454,
+ "y": -0.88924,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.18454,
+ "y": -0.84924,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.13546000000000002,
+ "y": -0.8092400000000001,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 1.1099999999999999,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.3299999999999998,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.43882,
+ "y": -0.9059299999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.12882,
+ "y": -0.8659300000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.05882000000000001,
+ "y": -0.49593,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 1.16,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.38,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.5055700000000001,
+ "y": -0.8855200000000001,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.19557,
+ "y": -0.84552,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.12557000000000001,
+ "y": -0.47552000000000005,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.98,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.2,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.57073,
+ "y": -0.8605,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.26073,
+ "y": -0.8205,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.19073,
+ "y": -0.4505,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.76,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.98,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.63399,
+ "y": -0.83101,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.32399,
+ "y": -0.79101,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.25399,
+ "y": -0.42101,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.58,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.8,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.6950400000000001,
+ "y": -0.7971699999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.38504,
+ "y": -0.75717,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.31504,
+ "y": -0.38717,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.44000000000000006,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.6599999999999999,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.7535799999999999,
+ "y": -0.75915,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.44358,
+ "y": -0.7191500000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.37358,
+ "y": -0.34914999999999996,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.38,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.6,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.34498,
+ "y": -0.79112,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.65498,
+ "y": -0.75112,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.97498,
+ "y": -0.71112,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.41000000000000003,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.63,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.29460000000000003,
+ "y": -0.8202,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.6046,
+ "y": -0.7802,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.9246000000000001,
+ "y": -0.7402,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.56,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.78,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.24262,
+ "y": -0.8463099999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.55262,
+ "y": -0.8063100000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.87262,
+ "y": -0.76631,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.76,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.98,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": -0.18921,
+ "y": -0.8693500000000001,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": -0.49921000000000004,
+ "y": -0.82935,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.81921,
+ "y": -0.78935,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.9600000000000001,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.18,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.49454,
+ "y": -0.88924,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.18454,
+ "y": -0.84924,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": -0.13546000000000002,
+ "y": -0.8092400000000001,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 1.1099999999999999,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.3299999999999998,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.43882,
+ "y": -0.9059299999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.12882,
+ "y": -0.8659300000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.05882000000000001,
+ "y": -0.49593,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 1.16,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.38,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.5055700000000001,
+ "y": -0.8855200000000001,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.19557,
+ "y": -0.84552,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.12557000000000001,
+ "y": -0.47552000000000005,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.98,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 1.2,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.57073,
+ "y": -0.8605,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.26073,
+ "y": -0.8205,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.19073,
+ "y": -0.4505,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.76,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.98,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.63399,
+ "y": -0.83101,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.32399,
+ "y": -0.79101,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.25399,
+ "y": -0.42101,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.58,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.8,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.6950400000000001,
+ "y": -0.7971699999999999,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.38504,
+ "y": -0.75717,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.31504,
+ "y": -0.38717,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.44000000000000006,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.6599999999999999,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ },
+ "leftShoulder": {
+ "x": 0.7535799999999999,
+ "y": -0.75915,
+ "confidence": 0.98
+ },
+ "leftElbow": {
+ "x": 0.44358,
+ "y": -0.7191500000000001,
+ "confidence": 0.98
+ },
+ "leftWrist": {
+ "x": 0.37358,
+ "y": -0.34914999999999996,
+ "confidence": 0.98
+ },
+ "leftHip": {
+ "x": 0.08,
+ "y": 0.04,
+ "confidence": 0.98
+ },
+ "leftKnee": {
+ "x": 0.38,
+ "y": 0.05,
+ "confidence": 0.98
+ },
+ "leftAnkle": {
+ "x": 0.6,
+ "y": 0.49,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": []
+ }
+}
diff --git a/tests/fixtures/mocap/back-opens-before-legs.json b/tests/fixtures/mocap/back-opens-before-legs.json
new file mode 100644
index 0000000..f7b5e74
--- /dev/null
+++ b/tests/fixtures/mocap/back-opens-before-legs.json
@@ -0,0 +1,864 @@
+{
+ "name": "back-opens-before-legs",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.46947,
+ "y": -0.88295,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.81947,
+ "y": -0.86295,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.16947,
+ "y": -0.84295,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.40674,
+ "y": -0.91355,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.75674,
+ "y": -0.89355,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.10674,
+ "y": -0.87355,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.34202,
+ "y": -0.93969,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.69202,
+ "y": -0.91969,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.04202,
+ "y": -0.89969,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.30902,
+ "y": -0.95106,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.65902,
+ "y": -0.93106,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.00902,
+ "y": -0.91106,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.27564,
+ "y": -0.96126,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.07436,
+ "y": -0.94126,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.42436,
+ "y": -0.92126,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": [
+ {
+ "faultType": "back_opens_before_legs_drive",
+ "severity": "warning"
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/mocap/clean-reference.json b/tests/fixtures/mocap/clean-reference.json
new file mode 100644
index 0000000..d499daa
--- /dev/null
+++ b/tests/fixtures/mocap/clean-reference.json
@@ -0,0 +1,859 @@
+{
+ "name": "clean-reference",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": []
+ }
+}
diff --git a/tests/fixtures/mocap/early-arm-bend.json b/tests/fixtures/mocap/early-arm-bend.json
new file mode 100644
index 0000000..ab8d153
--- /dev/null
+++ b/tests/fixtures/mocap/early-arm-bend.json
@@ -0,0 +1,864 @@
+{
+ "name": "early-arm-bend",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.97498,
+ "y": -0.48112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.9246,
+ "y": -0.5102,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.87262,
+ "y": -0.53631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.81921,
+ "y": -0.55935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.13546,
+ "y": -0.57924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": [
+ {
+ "faultType": "early_arm_bend",
+ "severity": "warning"
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/mocap/excessive-layback.json b/tests/fixtures/mocap/excessive-layback.json
new file mode 100644
index 0000000..42834bf
--- /dev/null
+++ b/tests/fixtures/mocap/excessive-layback.json
@@ -0,0 +1,864 @@
+{
+ "name": "excessive-layback",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.62024,
+ "y": -0.78442,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.97024,
+ "y": -0.76442,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.32024,
+ "y": -0.74442,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.6648,
+ "y": -0.74703,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.0148,
+ "y": -0.72703,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.3648,
+ "y": -0.70703,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.70711,
+ "y": -0.70711,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.05711,
+ "y": -0.68711,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.40711,
+ "y": -0.66711,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.74703,
+ "y": -0.6648,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.09703,
+ "y": -0.6448,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.44703,
+ "y": -0.6248,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.78442,
+ "y": -0.62024,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.43442,
+ "y": -0.60024,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.08442,
+ "y": -0.58024,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.81915,
+ "y": -0.57358,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.46915,
+ "y": -0.55358,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.36915,
+ "y": -0.20358,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.77715,
+ "y": -0.62932,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.42715,
+ "y": -0.60932,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.32715,
+ "y": -0.25932,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.73135,
+ "y": -0.682,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.38135,
+ "y": -0.662,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.28135,
+ "y": -0.312,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.682,
+ "y": -0.73135,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.332,
+ "y": -0.71135,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.232,
+ "y": -0.36135,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.62932,
+ "y": -0.77715,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.27932,
+ "y": -0.75715,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.17932,
+ "y": -0.40715,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 35,
+ "laybackAngleDeg": 55
+ },
+ "faults": [
+ {
+ "faultType": "excessive_layback",
+ "severity": "warning"
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/mocap/lost-tracking.json b/tests/fixtures/mocap/lost-tracking.json
new file mode 100644
index 0000000..229ace8
--- /dev/null
+++ b/tests/fixtures/mocap/lost-tracking.json
@@ -0,0 +1,859 @@
+{
+ "name": "lost-tracking",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.05
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.05
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.05
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.05
+ }
+ },
+ "qualityFlags": 10
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.05
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.05
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.05
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.05
+ }
+ },
+ "qualityFlags": 10
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.05
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.05
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.05
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.05
+ }
+ },
+ "qualityFlags": 10
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.05
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.05
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.05
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.05
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.05
+ }
+ },
+ "qualityFlags": 10
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": []
+ }
+}
diff --git a/tests/fixtures/mocap/rounded-back-critical.json b/tests/fixtures/mocap/rounded-back-critical.json
new file mode 100644
index 0000000..639cf91
--- /dev/null
+++ b/tests/fixtures/mocap/rounded-back-critical.json
@@ -0,0 +1,864 @@
+{
+ "name": "rounded-back-critical",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.95106,
+ "y": -0.30902,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.30106,
+ "y": -0.28902,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.65106,
+ "y": -0.26902,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.95106,
+ "y": -0.30902,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.30106,
+ "y": -0.28902,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.65106,
+ "y": -0.26902,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.79864,
+ "y": -0.60182,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.14864,
+ "y": -0.58182,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.49864,
+ "y": -0.56182,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.68835,
+ "y": -0.72537,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -1.03835,
+ "y": -0.70537,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.38835,
+ "y": -0.68537,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.55919,
+ "y": -0.82904,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.90919,
+ "y": -0.80904,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.25919,
+ "y": -0.78904,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.41469,
+ "y": -0.90996,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.06469,
+ "y": -0.88996,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.28531,
+ "y": -0.86996,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.44464,
+ "y": -0.89571,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.09464,
+ "y": -0.87571,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.00536,
+ "y": -0.52571,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.61291,
+ "y": -0.79016,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.26291,
+ "y": -0.77016,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.16291,
+ "y": -0.42016,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.757,
+ "y": -0.65342,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.407,
+ "y": -0.63342,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.307,
+ "y": -0.28342,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.87121,
+ "y": -0.4909,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.52121,
+ "y": -0.4709,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.42121,
+ "y": -0.1209,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.95106,
+ "y": -0.30902,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.60106,
+ "y": -0.28902,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.50106,
+ "y": 0.06098,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 11
+ },
+ {
+ "catchFrameIndex": 11,
+ "finishFrameIndex": 17,
+ "nextCatchFrameIndex": 22
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 18,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": [
+ {
+ "faultType": "rounded_back_at_catch",
+ "severity": "critical"
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/mocap/slow-recovery-critical.json b/tests/fixtures/mocap/slow-recovery-critical.json
new file mode 100644
index 0000000..2649e61
--- /dev/null
+++ b/tests/fixtures/mocap/slow-recovery-critical.json
@@ -0,0 +1,1548 @@
+{
+ "name": "slow-recovery-critical",
+ "stream": {
+ "fps": 12,
+ "capturePerspective": "side-right",
+ "frames": [
+ {
+ "timestampMs": 0,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.92358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.27358,
+ "y": -0.77915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 83.33333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 166.66666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 416.66666666666663,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.27284,
+ "y": -0.96206,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.07716,
+ "y": -0.94206,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.17716,
+ "y": -0.59206,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.98,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.18,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 666.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.2868,
+ "y": -0.95799,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.0632,
+ "y": -0.93799,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.1632,
+ "y": -0.58799,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.30071,
+ "y": -0.95372,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.04929,
+ "y": -0.93372,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.14929,
+ "y": -0.58372,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.92,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.12,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.13546,
+ "y": -0.57924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.89,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.09,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 916.6666666666666,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32832,
+ "y": -0.94457,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02168,
+ "y": -0.92457,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12168,
+ "y": -0.57457,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.86,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.06,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.34202,
+ "y": -0.93969,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.00798,
+ "y": -0.91969,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.10798,
+ "y": -0.56969,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1083.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.35565,
+ "y": -0.93462,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.00565,
+ "y": -0.91462,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.09435,
+ "y": -0.56462,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.78,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.98,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.01921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.08079,
+ "y": -0.55935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.74,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.94,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.38268,
+ "y": -0.92388,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.03268,
+ "y": -0.90388,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.06732,
+ "y": -0.55388,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.7,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.9,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1333.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39608,
+ "y": -0.91822,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04608,
+ "y": -0.89822,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05392,
+ "y": -0.54822,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.66,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.86,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.40939,
+ "y": -0.91236,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.05939,
+ "y": -0.89236,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.04061,
+ "y": -0.54236,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.62,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.82,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.07262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.02738,
+ "y": -0.53631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.58,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.78,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1583.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.43575,
+ "y": -0.90007,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.08575,
+ "y": -0.88007,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.01425,
+ "y": -0.53007,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.54,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.74,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.4488,
+ "y": -0.89363,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.0988,
+ "y": -0.87363,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.0012,
+ "y": -0.52363,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.5,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.7,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.46175,
+ "y": -0.88701,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.11175,
+ "y": -0.86701,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.01175,
+ "y": -0.51701,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.46,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.66,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1833.3333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.1246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.0246,
+ "y": -0.5102,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 1916.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.48735,
+ "y": -0.87321,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.13735,
+ "y": -0.85321,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.03735,
+ "y": -0.50321,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.38,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.58,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.5,
+ "y": -0.86603,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.15,
+ "y": -0.84603,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.05,
+ "y": -0.49603,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.34,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.54,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2083.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51254,
+ "y": -0.85866,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16254,
+ "y": -0.83866,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06254,
+ "y": -0.48866,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.3,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.5,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.17498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.07498,
+ "y": -0.48112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.27,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.47,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.5373,
+ "y": -0.84339,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.1873,
+ "y": -0.82339,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.0873,
+ "y": -0.47339,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.24,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.44,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2333.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.54951,
+ "y": -0.83549,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.19951,
+ "y": -0.81549,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.09951,
+ "y": -0.46549,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.5616,
+ "y": -0.82741,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.2116,
+ "y": -0.80741,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.1116,
+ "y": -0.45741,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.21,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.41,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2500,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.2,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.4,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2583.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.52498,
+ "y": -0.85112,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.87498,
+ "y": -0.83112,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.22498,
+ "y": -0.81112,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.25,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.45,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2666.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.4746,
+ "y": -0.8802,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.8246,
+ "y": -0.8602,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.1746,
+ "y": -0.8402,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.4,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.6,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2750,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.42262,
+ "y": -0.90631,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.77262,
+ "y": -0.88631,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.12262,
+ "y": -0.86631,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2833.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": -0.36921,
+ "y": -0.92935,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.71921,
+ "y": -0.90935,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -1.06921,
+ "y": -0.88935,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.8,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 2916.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.31454,
+ "y": -0.94924,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.03546,
+ "y": -0.92924,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.38546,
+ "y": -0.90924,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.95,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.15,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3000,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.25882,
+ "y": -0.96593,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.09118,
+ "y": -0.94593,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.19118,
+ "y": -0.59593,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 1,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.2,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3083.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.32557,
+ "y": -0.94552,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": -0.02443,
+ "y": -0.92552,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.12443,
+ "y": -0.57552,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.82,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 1.02,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3166.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.39073,
+ "y": -0.9205,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.04073,
+ "y": -0.9005,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": -0.05927,
+ "y": -0.5505,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.6,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.8,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3250,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.45399,
+ "y": -0.89101,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.10399,
+ "y": -0.87101,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.00399,
+ "y": -0.52101,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.42,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.62,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3333.333333333333,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.51504,
+ "y": -0.85717,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.16504,
+ "y": -0.83717,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.06504,
+ "y": -0.48717,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.28,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.48,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ },
+ {
+ "timestampMs": 3416.6666666666665,
+ "keypoints": {
+ "rightShoulder": {
+ "x": 0.57358,
+ "y": -0.81915,
+ "confidence": 0.98
+ },
+ "rightElbow": {
+ "x": 0.22358,
+ "y": -0.79915,
+ "confidence": 0.98
+ },
+ "rightWrist": {
+ "x": 0.12358,
+ "y": -0.44915,
+ "confidence": 0.98
+ },
+ "rightHip": {
+ "x": 0,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightKnee": {
+ "x": 0.22,
+ "y": 0,
+ "confidence": 0.98
+ },
+ "rightAnkle": {
+ "x": 0.42,
+ "y": 0.42,
+ "confidence": 0.98
+ }
+ },
+ "qualityFlags": 0
+ }
+ ]
+ },
+ "expected": {
+ "strokeCount": 2,
+ "boundaries": [
+ {
+ "catchFrameIndex": 0,
+ "finishFrameIndex": 6,
+ "nextCatchFrameIndex": 30
+ },
+ {
+ "catchFrameIndex": 30,
+ "finishFrameIndex": 36,
+ "nextCatchFrameIndex": 41
+ }
+ ],
+ "metrics": {
+ "strokeIndex": 0,
+ "backAngleAtCatchDeg": 55,
+ "backAngleAtFinishDeg": 75,
+ "laybackAngleDeg": 15
+ },
+ "faults": [
+ {
+ "faultType": "slow_recovery_ratio",
+ "severity": "critical"
+ }
+ ]
+ }
+}
diff --git a/tests/mocapAnalysis.test.ts b/tests/mocapAnalysis.test.ts
new file mode 100644
index 0000000..811d453
--- /dev/null
+++ b/tests/mocapAnalysis.test.ts
@@ -0,0 +1,195 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ POSTURE_FAULT_CATALOG_V1,
+ PostureFaultDetector,
+ PostureMetricsCalculator,
+ StrokePhaseSegmenter,
+ migratePostureThresholdSettings,
+ postureThresholdsV1,
+ type PoseFrameStream,
+ type PostureFaultType,
+} from "../src/lib/mocap/analysis";
+
+interface Fixture {
+ name: string;
+ stream: PoseFrameStream;
+ expected: {
+ strokeCount: number;
+ boundaries: Array<{
+ catchFrameIndex: number;
+ finishFrameIndex: number;
+ nextCatchFrameIndex: number;
+ }>;
+ metrics?: {
+ strokeIndex: number;
+ backAngleAtCatchDeg: number;
+ backAngleAtFinishDeg: number;
+ laybackAngleDeg: number;
+ };
+ faults: Array<{
+ faultType: PostureFaultType;
+ severity: "info" | "warning" | "critical";
+ }>;
+ };
+}
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const fixturesDir = path.join(here, "fixtures", "mocap");
+
+function loadFixture(fileName: string): Fixture {
+ return JSON.parse(
+ readFileSync(path.join(fixturesDir, fileName), "utf8"),
+ ) as Fixture;
+}
+
+function loadFixtures(): Fixture[] {
+ return readdirSync(fixturesDir)
+ .filter((fileName) => fileName.endsWith(".json"))
+ .sort()
+ .map(loadFixture);
+}
+
+test("stroke segmenter returns exact counts and expected phase boundaries", () => {
+ for (const fixture of loadFixtures()) {
+ const strokes = StrokePhaseSegmenter(fixture.stream);
+ assert.equal(strokes.length, fixture.expected.strokeCount, fixture.name);
+
+ for (const [i, expected] of fixture.expected.boundaries.entries()) {
+ const actual = strokes[i];
+ assert.ok(actual, `${fixture.name} stroke ${i} missing`);
+ assertWithin(actual.catchFrameIndex, expected.catchFrameIndex, 2);
+ assertWithin(actual.finishFrameIndex, expected.finishFrameIndex, 2);
+ assertWithin(actual.nextCatchFrameIndex, expected.nextCatchFrameIndex, 2);
+ assert.equal(actual.segmentationSource, "pose-segmented");
+ }
+ }
+});
+
+test("posture metrics match fixture ground truth angles", () => {
+ for (const fixture of loadFixtures()) {
+ if (!fixture.expected.metrics) continue;
+ const strokes = StrokePhaseSegmenter(fixture.stream);
+ const expected = fixture.expected.metrics;
+ const metrics = PostureMetricsCalculator(
+ fixture.stream,
+ strokes[expected.strokeIndex],
+ );
+
+ assertWithin(
+ metrics.backAngleAtCatchDeg,
+ expected.backAngleAtCatchDeg,
+ 2,
+ );
+ assertWithin(
+ metrics.backAngleAtFinishDeg,
+ expected.backAngleAtFinishDeg,
+ 2,
+ );
+ assertWithin(metrics.laybackAngleDeg, expected.laybackAngleDeg, 2);
+ assert.equal(metrics.leftRightAsymmetry.available, false);
+ assert.equal(metrics.shinVerticalAtCatchDeg.available, false);
+ assert.equal(metrics.kneeTrackDeviation.available, false);
+ }
+});
+
+test("fault detector produces zero faults on the clean reference fixture", () => {
+ const fixture = loadFixture("clean-reference.json");
+ const strokes = StrokePhaseSegmenter(fixture.stream);
+ const allFaults = strokes.flatMap((stroke) =>
+ PostureFaultDetector(PostureMetricsCalculator(fixture.stream, stroke)),
+ );
+ assert.deepEqual(allFaults, []);
+});
+
+test("crafted fault fixtures trigger exactly the expected v1 fault", () => {
+ for (const fixture of loadFixtures()) {
+ if (fixture.name === "clean-reference") continue;
+
+ const strokes = StrokePhaseSegmenter(fixture.stream);
+ const faults = PostureFaultDetector(
+ PostureMetricsCalculator(fixture.stream, strokes[0]),
+ ).map((fault) => ({
+ faultType: fault.faultType,
+ severity: fault.severity,
+ }));
+
+ assert.deepEqual(faults, fixture.expected.faults, fixture.name);
+ }
+});
+
+test("fault detector never emits outside the v1 catalog", () => {
+ const catalog = new Set(POSTURE_FAULT_CATALOG_V1);
+ for (const fixture of loadFixtures()) {
+ const strokes = StrokePhaseSegmenter(fixture.stream);
+ const faults = strokes.flatMap((stroke) =>
+ PostureFaultDetector(PostureMetricsCalculator(fixture.stream, stroke)),
+ );
+ for (const fault of faults) {
+ assert.ok(catalog.has(fault.faultType), fault.faultType);
+ }
+ }
+});
+
+test("threshold migration updates defaults but preserves user overrides", () => {
+ const v2Defaults = {
+ version: "V2" as const,
+ thresholds: {
+ ...postureThresholdsV1.thresholds,
+ rounded_back_at_catch: {
+ warningBelowDeg: 35,
+ criticalBelowDeg: 25,
+ },
+ },
+ };
+ const stored = {
+ version: "V1" as const,
+ thresholds: postureThresholdsV1.thresholds,
+ userOverridden: false,
+ };
+ const migrated = migratePostureThresholdSettings(stored, v2Defaults);
+ assert.equal(migrated.version, "V2");
+ assert.equal(migrated.thresholds.rounded_back_at_catch.warningBelowDeg, 35);
+
+ const overridden = migratePostureThresholdSettings(
+ {
+ ...stored,
+ userOverridden: true,
+ thresholds: {
+ ...postureThresholdsV1.thresholds,
+ rounded_back_at_catch: {
+ warningBelowDeg: 10,
+ criticalBelowDeg: 5,
+ },
+ },
+ },
+ v2Defaults,
+ );
+ assert.equal(overridden.version, "V1");
+ assert.equal(
+ overridden.thresholds.rounded_back_at_catch.warningBelowDeg,
+ 10,
+ );
+});
+
+test("analysis modules remain pure TypeScript with no I/O imports", () => {
+ const analysisDir = path.join(here, "..", "src", "lib", "mocap", "analysis");
+ for (const fileName of readdirSync(analysisDir)) {
+ if (!fileName.endsWith(".ts")) continue;
+ const src = readFileSync(path.join(analysisDir, fileName), "utf8");
+ assert.doesNotMatch(src, /from\s+["'](?:node:)?fs["']/);
+ assert.doesNotMatch(src, /from\s+["']@\/lib\/db\/prisma["']/);
+ assert.doesNotMatch(src, /\bfetch\s*\(/);
+ assert.doesNotMatch(src, /\bwindow\b|\bdocument\b|\bnavigator\b/);
+ }
+});
+
+function assertWithin(actual: number, expected: number, tolerance: number): void {
+ assert.ok(
+ Math.abs(actual - expected) <= tolerance,
+ `expected ${actual} within ${tolerance} of ${expected}`,
+ );
+}
diff --git a/tests/poseFrameStream.test.ts b/tests/poseFrameStream.test.ts
new file mode 100644
index 0000000..20148d8
--- /dev/null
+++ b/tests/poseFrameStream.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Unit tests for the PoseFrameStream binary codec.
+ *
+ * Run with: npx tsx --test tests/poseFrameStream.test.ts
+ *
+ * Covers issue #9 acceptance: pose stream blob round-trips (random access by
+ * frame index = byte-range read at frameByteOffset(i)) and reader rejects
+ * unknown keypointSchemaVersion explicitly.
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ BYTES_PER_FRAME_V1,
+ HEADER_SIZE,
+ KEYPOINTS_PER_FRAME_V1,
+ PoseStreamFormatError,
+ QUALITY_FLAG,
+ decodeFrame,
+ decodeHeader,
+ encodeFrame,
+ encodeHeader,
+ frameByteOffset,
+ framesFromBlobSize,
+ type PoseFrame,
+} from "../src/lib/mocap/poseFrameStream";
+
+function makeFrame(seed: number, qualityFlags = 0): PoseFrame {
+ const kp = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ for (let i = 0; i < kp.length; i++) {
+ kp[i] = Math.fround(Math.sin((seed + i) * 0.1));
+ }
+ return {
+ timestampMs: seed * 33.333,
+ keypoints: kp,
+ qualityFlags,
+ };
+}
+
+function buildBlob(fps: number, frames: PoseFrame[]): Uint8Array {
+ const header = encodeHeader({ fps, frameCount: frames.length });
+ const buf = new Uint8Array(HEADER_SIZE + frames.length * BYTES_PER_FRAME_V1);
+ buf.set(header, 0);
+ for (let i = 0; i < frames.length; i++) {
+ buf.set(encodeFrame(frames[i]), HEADER_SIZE + i * BYTES_PER_FRAME_V1);
+ }
+ return buf;
+}
+
+test("header round-trips fps + frameCount + schema version", () => {
+ const buf = encodeHeader({ fps: 30, frameCount: 1234 });
+ const h = decodeHeader(buf);
+ assert.equal(h.fps, 30);
+ assert.equal(h.frameCount, 1234);
+ assert.equal(h.keypointsPerFrame, KEYPOINTS_PER_FRAME_V1);
+ assert.equal(h.bytesPerFrame, BYTES_PER_FRAME_V1);
+});
+
+test("frame round-trips bit-exact", () => {
+ const f = makeFrame(7, QUALITY_FLAG.LOW_CONFIDENCE | QUALITY_FLAG.LOW_LIGHT);
+ const bytes = encodeFrame(f);
+ assert.equal(bytes.byteLength, BYTES_PER_FRAME_V1);
+ const decoded = decodeFrame(bytes);
+ assert.equal(decoded.timestampMs, Math.fround(f.timestampMs));
+ assert.equal(decoded.qualityFlags, f.qualityFlags);
+ for (let i = 0; i < f.keypoints.length; i++) {
+ assert.equal(decoded.keypoints[i], f.keypoints[i]);
+ }
+});
+
+test("byte-range read by frame index returns identical frame data", () => {
+ const N = 50;
+ const frames = Array.from({ length: N }, (_, i) => makeFrame(i));
+ const blob = buildBlob(24, frames);
+
+ for (const idx of [0, 1, 17, N - 1]) {
+ const start = frameByteOffset(idx);
+ const slice = blob.subarray(start, start + BYTES_PER_FRAME_V1);
+ const decoded = decodeFrame(slice);
+ const expected = frames[idx];
+ assert.equal(decoded.timestampMs, Math.fround(expected.timestampMs));
+ for (let i = 0; i < expected.keypoints.length; i++) {
+ assert.equal(decoded.keypoints[i], expected.keypoints[i]);
+ }
+ }
+});
+
+test("framesFromBlobSize derives count from open-ended blob", () => {
+ const frames = Array.from({ length: 7 }, (_, i) => makeFrame(i));
+ const blob = buildBlob(30, frames);
+ assert.equal(framesFromBlobSize(blob.byteLength), 7);
+ assert.equal(framesFromBlobSize(HEADER_SIZE), 0);
+ assert.equal(framesFromBlobSize(0), 0);
+});
+
+test("reader rejects unknown keypointSchemaVersion", () => {
+ const buf = encodeHeader({ fps: 30, frameCount: 0 });
+ // Corrupt schema version field (bytes 6-7 LE)
+ buf[6] = 99;
+ buf[7] = 0;
+ assert.throws(() => decodeHeader(buf), PoseStreamFormatError);
+});
+
+test("reader rejects bad magic bytes", () => {
+ const buf = encodeHeader({ fps: 30, frameCount: 0 });
+ buf[0] = 0x00;
+ assert.throws(() => decodeHeader(buf), PoseStreamFormatError);
+});
+
+test("encodeFrame rejects wrong-length keypoint array", () => {
+ const bad: PoseFrame = {
+ timestampMs: 0,
+ keypoints: new Float32Array(10),
+ qualityFlags: 0,
+ };
+ assert.throws(() => encodeFrame(bad), PoseStreamFormatError);
+});
From dc90cdb7d96b82a33a528b74f1e96a735b2290a4 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 12:19:29 +0200
Subject: [PATCH 05/29] Implement mocap capture persistence
---
.../0002-defer-sidecar-contract-to-phase-2.md | 2 +-
.../0003-browser-side-analysis-pipeline.md | 11 +-
docs/prd-mocap-posture.md | 11 +-
package-lock.json | 85 ++++++++++++++
package.json | 1 +
.../api/mocap/sessions/[id]/finalize/route.ts | 40 ++-----
.../mocap/sessions/[id]/pose-stream/route.ts | 44 +++++++
src/app/api/mocap/sessions/[id]/pose/route.ts | 53 +--------
src/app/api/mocap/sessions/route.ts | 5 +-
src/app/mocap/page.tsx | 20 +++-
src/lib/mocap/browserPoseSource.ts | 4 +-
src/lib/mocap/capturePersistence.ts | 66 +++++++++++
src/lib/mocap/storage.ts | 94 +++++++++++++--
tests/mocapCapturePersistence.test.ts | 108 ++++++++++++++++++
14 files changed, 439 insertions(+), 105 deletions(-)
create mode 100644 src/lib/mocap/capturePersistence.ts
create mode 100644 tests/mocapCapturePersistence.test.ts
diff --git a/docs/adr/0002-defer-sidecar-contract-to-phase-2.md b/docs/adr/0002-defer-sidecar-contract-to-phase-2.md
index 065dbe9..9a63404 100644
--- a/docs/adr/0002-defer-sidecar-contract-to-phase-2.md
+++ b/docs/adr/0002-defer-sidecar-contract-to-phase-2.md
@@ -39,7 +39,7 @@ Treat the sidecar as not-yet-existent. Phase 2 will design its contract against
**Neutral**
-- Anything in the PRD's `### API contracts` section that is sidecar-specific (sidecar local URL, sidecar health-check) is out of v1 scope. The browser API (`POST /api/mocap/sessions`, `GET /api/mocap/sessions/:id`, `POST /api/mocap/sessions/:id/reanalyze`, `POST /api/mocap/sessions/:id/link/:rowingSessionId`, `DELETE /api/mocap/sessions/:id`, `WebSocket /api/mocap/live`) stays.
+- Anything in the PRD's `### API contracts` section that is sidecar-specific (sidecar local URL, sidecar health-check) is out of v1 scope. The browser API (`POST /api/mocap/sessions`, `POST|GET /api/mocap/sessions/:id/pose-stream`, `POST /api/mocap/sessions/:id/video`, `POST /api/mocap/sessions/:id/finalize`, `GET /api/mocap/sessions/:id`, `POST /api/mocap/sessions/:id/reanalyze`, `POST /api/mocap/sessions/:id/link/:rowingSessionId`, `DELETE /api/mocap/sessions/:id`) stays.
## Alternatives considered
diff --git a/docs/adr/0003-browser-side-analysis-pipeline.md b/docs/adr/0003-browser-side-analysis-pipeline.md
index b8a47c4..6a15496 100644
--- a/docs/adr/0003-browser-side-analysis-pipeline.md
+++ b/docs/adr/0003-browser-side-analysis-pipeline.md
@@ -24,7 +24,13 @@ Run the full analysis pipeline (segmenter, metrics calculator, fault detector, c
Pose inference runs in a **Web Worker with OffscreenCanvas** using MediaPipe Tasks (WASM build). WebGPU acceleration is future-opt-in, not v1 default.
-`WebSocket /api/mocap/live` is for **persistence only**: the browser streams frames to the server, the server appends them to the session's blob storage. The server does not run the analysis pipeline during live capture and does not emit faults or cues over the WebSocket.
+Live persistence uses short HTTP chunk uploads, not a long-lived WebSocket:
+
+- `POST /api/mocap/sessions/:id/pose-stream` appends whole encoded `PoseFrameStream` frames to the pose blob.
+- `POST /api/mocap/sessions/:id/video` appends `MediaRecorder` video chunks to the video blob.
+- `POST /api/mocap/sessions/:id/finalize` patches the pose header `frameCount` and flips the session from `capturing` to `ready`.
+
+The server does not run the analysis pipeline during live capture and does not emit faults or cues over the upload transport.
The same pipeline code runs server-side on demand for **re-analysis** — `POST /api/mocap/sessions/:id/reanalyze` reads the blob, runs the pure pipeline, rewrites derived rows. That's the only server-side execution path.
@@ -36,11 +42,13 @@ The same pipeline code runs server-side on demand for **re-analysis** — `POST
- Privacy posture matches the PRD: faults and cues are computed locally; pose data only leaves the browser when the user has opted into persistence (and never reaches a third-party AI service unless `cloudAIEnabled` is set).
- The pipeline is one codebase. Pure functions over data structures are portable: same code runs in a Web Worker live and on a Vercel Function for re-analysis.
- If a user denies upload permission or the network drops, live coaching keeps working — the browser has everything it needs.
+- HTTP chunk uploads fit the Vercel deployment target without introducing a third-party socket provider. Chunks are independently retriable, and finalize can validate that only complete pose frames reached storage.
**Negative**
- Bundle size grows by the size of the analysis pipeline (segmenter, metrics, fault detector, default thresholds, coaching cue text). Mitigated by: pipeline is pure logic, no large model weights; lazy-loaded on the mocap route, not the dashboard.
- Re-analysis on the server requires a runtime that can execute the same TypeScript modules. Vercel Functions on Node.js handle this; no isomorphic concerns for pure code.
+- The upload transport is less "live" than a persistent socket. v1 does not need server-to-client capture messages because live cues are computed in-browser.
- If the pipeline ever needs heavy compute (ML model inference for fault detection — explicitly out of v1 scope per PRD), the browser path becomes constrained. At that point, this decision can be revisited for the heavy-compute branch only; the rule-based v1 path stays browser-side.
**Neutral**
@@ -50,5 +58,6 @@ The same pipeline code runs server-side on demand for **re-analysis** — `POST
## Alternatives considered
- **Server-side analysis with live WebSocket fault stream (the PRD's draft).** Rejected for the latency and privacy reasons above.
+- **Persistence-only WebSocket (`/api/mocap/live`).** Rejected for v1 because the deployment target does not provide arbitrary long-lived WebSocket handling without another provider, and the server has no live messages to send back.
- **Hybrid: client computes, server validates.** Rejected as duplicate work — server has nothing to validate against. The client's frames are ground truth.
- **Pose inference on the server (upload video, server runs MediaPipe).** Rejected: kills live latency, defeats the local-first privacy stance, and adds GPU/CPU server cost for compute the client can do.
diff --git a/docs/prd-mocap-posture.md b/docs/prd-mocap-posture.md
index cd46238..236b074 100644
--- a/docs/prd-mocap-posture.md
+++ b/docs/prd-mocap-posture.md
@@ -63,7 +63,7 @@ Pose data is stored raw (keypoints per frame) and aligned to existing `RowingSes
- **PostureMetricsCalculator** — pure function: `Stroke → PostureMetrics`. Computes: back angle at catch, back angle at finish, layback angle, shin vertical at catch, hip-knee opening offset (drive sequence), arm-bend onset frame, left-right asymmetry index, knee track deviation.
- **PostureFaultDetector** — pure function: `PostureMetrics + thresholds → PostureFault[]`. Severity levels: info / warning / critical. Rule-based v1; pluggable so ML model can replace later.
- **CoachingAdvisor** — `PostureFault[] + session history → CoachingCue[]`. Rule-based default cues; cloud AI augmentation behind existing `cloudAI` gate.
-- **PostureSessionRepository** — Prisma-hidden read/write of `MocapSession`, `PoseFrame`, `StrokePostureMetric`, `PostureFault`.
+- **PostureSessionRepository** — Prisma-hidden read/write of `MocapSession`, `StrokePostureMetric`, `PostureFault`, plus byte-range access to the stored `PoseFrameStream` blob.
### Modified modules
@@ -89,7 +89,7 @@ Pose data is stored raw (keypoints per frame) and aligned to existing `RowingSes
- **Pose source abstraction is the deep boundary.** Browser MediaPipe and freemocap sidecar both produce identical `PoseFrameStream` shape. All downstream analysis is source-agnostic. This is the core deepening play.
- **Analysis is pure.** `StrokePhaseSegmenter`, `PostureMetricsCalculator`, `PostureFaultDetector` are pure functions over data structures. No I/O, no DB. Tested with fixture frame streams.
- **Live and replay share the pipeline.** Live mode runs the same segmenter/metrics/detector incrementally as frames arrive; replay runs them on the stored stream. No duplicate logic.
-- **Storage contract.** Raw `PoseFrame` rows are large but enable full replay + re-analysis when rules improve. Stored in Postgres (JSONB columns); video file stored on existing storage backend (`storage/` dir or Vercel Blob in deployed env). User may purge raw frames per session if storage pressure rises (derived metrics retained).
+- **Storage contract.** Raw pose data is stored as one binary `PoseFrameStream` blob per `MocapSession`, alongside the video file on the same storage backend (`storage/` dir or Vercel Blob in deployed env). Postgres stores the `MocapSession` row and derived rows only.
- **Sidecar contract.** freemocap sidecar is an opt-in local Docker service. Communicates via WebSocket on localhost. Versioned schema. App degrades cleanly if sidecar is offline.
- **Privacy.** Video + pose data are user-scoped, never sent to cloud unless cloud-AI is explicitly enabled in `UserSettings.cloudAIEnabled`. Coaching cues by default run on local rules.
- **Frame budget.** Browser path targets ≥ 24 fps on a mid-tier laptop. Heavier work (full re-analysis, summaries) deferred to post-session. Mobile falls back to record-only when CPU is insufficient.
@@ -101,10 +101,13 @@ Pose data is stored raw (keypoints per frame) and aligned to existing `RowingSes
- `POST /api/mocap/sessions` — create new mocap session (browser uploads chunked video + pose stream, or sidecar streams directly).
- `GET /api/mocap/sessions/:id` — full mocap detail incl. metrics + faults + frame index.
+- `POST /api/mocap/sessions/:id/pose-stream` — append complete encoded pose-frame chunks to the session's `PoseFrameStream` blob. Server validates chunk boundaries but runs no analysis.
+- `GET /api/mocap/sessions/:id/pose-stream` — byte-range reads from the session's `PoseFrameStream` blob for replay and re-analysis.
+- `POST /api/mocap/sessions/:id/video` — append recorded video chunks to the same storage backend.
+- `POST /api/mocap/sessions/:id/finalize` — finalize the pose header frame count and transition `capturing` → `ready`.
- `POST /api/mocap/sessions/:id/reanalyze` — re-run pipeline with current rules.
- `POST /api/mocap/sessions/:id/link/:rowingSessionId` — attach mocap to existing CSV session.
- `DELETE /api/mocap/sessions/:id` — cascade delete frames, metrics, faults, video.
-- WebSocket `/api/mocap/live` — live capture stream from browser; server emits incremental faults / cues.
- Sidecar local URL configurable in settings; health-check endpoint required.
## Testing Decisions
@@ -152,7 +155,7 @@ This section reflects the outcome of `/grill-with-docs` against this PRD. Where
- **ADR-0001** — raw `PoseFrameStream` is stored as one binary blob per `MocapSession` alongside the video, not as Postgres JSONB rows. The `PoseFrame` Prisma model from `### Schema additions` is dropped; replaced by `MocapSession.poseStreamPath`.
- **ADR-0002** — sidecar contract is deferred to Phase 2. v1 `PoseFrameStream` shape is browser-2D only (`{x, y, confidence}` per keypoint, plus quality flags). No Docker image, no WebSocket sidecar protocol, no health-check API in v1.
-- **ADR-0003** — analysis pipeline runs in the browser (Web Worker, MediaPipe Tasks WASM). `WebSocket /api/mocap/live` is for persistence only; server does not emit faults during live capture. Server-side execution is for `POST /api/mocap/sessions/:id/reanalyze` only.
+- **ADR-0003** — analysis pipeline runs in the browser (Web Worker, MediaPipe Tasks WASM). Live persistence uses HTTP chunk uploads (`pose-stream`, `video`, `finalize`); the server does not emit faults during live capture. Server-side execution is for `POST /api/mocap/sessions/:id/reanalyze` only.
- **ADR-0004** — cloud-AI mocap payload is `PostureFault` summary (tier 3) by default; per-stroke metrics (tier 2) opt-in via `UserSettings.mocapDetailedAIShare`; raw frames (tier 1) never cross to cloud.
### Domain terms (see `CONTEXT.md`)
diff --git a/package-lock.json b/package-lock.json
index ee957d1..8729439 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.36.0",
+ "@vercel/blob": "^2.3.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -4525,6 +4526,22 @@
"uncrypto": "^0.1.3"
}
},
+ "node_modules/@vercel/blob": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.3.3.tgz",
+ "integrity": "sha512-MtD7VLo6hU07eHR7bmk5SIMD290q574UaNYTe46qeyRT+hWrCy26CoAqfd7PnIefVXvRehRZBzukxuTO9iGTVg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async-retry": "^1.3.3",
+ "is-buffer": "^2.0.5",
+ "is-node-process": "^1.2.0",
+ "throttleit": "^2.1.0",
+ "undici": "^6.23.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -4787,6 +4804,24 @@
"node": ">= 0.4"
}
},
+ "node_modules/async-retry": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
+ "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
+ "license": "MIT",
+ "dependencies": {
+ "retry": "0.13.1"
+ }
+ },
+ "node_modules/async-retry/node_modules/retry": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -7301,6 +7336,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-buffer": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
+ "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
@@ -7493,6 +7551,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "license": "MIT"
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -11286,6 +11350,18 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/throttleit": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
+ "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -11617,6 +11693,15 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
diff --git a/package.json b/package.json
index db64f5d..d86b7fc 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.36.0",
+ "@vercel/blob": "^2.3.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
index cfe2973..7f2996e 100644
--- a/src/app/api/mocap/sessions/[id]/finalize/route.ts
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -4,17 +4,11 @@ import { z } from "zod";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db/prisma";
import { getMocapStorage } from "@/lib/mocap/storage";
-import {
- BYTES_PER_FRAME_V1,
- HEADER_SIZE,
- framesFromBlobSize,
-} from "@/lib/mocap/poseFrameStream";
+import { finalizePoseStreamBlob } from "@/lib/mocap/capturePersistence";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
-const FRAME_COUNT_OFFSET = 16;
-
const Body = z.object({
durationSec: z.number().nonnegative().max(60 * 60 * 8),
qualityScore: z.number().min(0).max(1).optional(),
@@ -64,35 +58,15 @@ export async function POST(
const storage = getMocapStorage();
- let poseSize = 0;
+ let finalized: Awaited>;
try {
- poseSize = await storage.size(row.poseStreamPath);
- } catch {
- return NextResponse.json(
- { error: "Pose stream missing" },
- { status: 500 },
- );
- }
- if (poseSize < HEADER_SIZE) {
- return NextResponse.json(
- { error: "Pose stream truncated below header" },
- { status: 500 },
- );
- }
- const trailing = (poseSize - HEADER_SIZE) % BYTES_PER_FRAME_V1;
- if (trailing !== 0) {
+ finalized = await finalizePoseStreamBlob(storage, row.poseStreamPath);
+ } catch (err) {
return NextResponse.json(
- {
- error: `Pose stream has ${trailing} trailing bytes (corrupt)`,
- },
+ { error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
- const frameCount = framesFromBlobSize(poseSize);
-
- const headerPatch = new Uint8Array(4);
- new DataView(headerPatch.buffer).setUint32(0, frameCount, true);
- await storage.writeAt(row.poseStreamPath, headerPatch, FRAME_COUNT_OFFSET);
const updated = await prisma.mocapSession.update({
where: { id: row.id },
@@ -107,7 +81,7 @@ export async function POST(
id: updated.id,
status: updated.status,
durationSec: updated.durationSec,
- frameCount,
- poseStreamBytes: poseSize,
+ frameCount: finalized.frameCount,
+ poseStreamBytes: finalized.poseStreamBytes,
});
}
diff --git a/src/app/api/mocap/sessions/[id]/pose-stream/route.ts b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
index ab0618e..c91dcc9 100644
--- a/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
+++ b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
@@ -2,11 +2,55 @@ import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db/prisma";
+import { appendPoseFrames } from "@/lib/mocap/capturePersistence";
import { getMocapStorage } from "@/lib/mocap/storage";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { id: true, status: true, poseStreamPath: true },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (row.status !== "capturing") {
+ return NextResponse.json(
+ { error: `Session not capturing (status=${row.status})` },
+ { status: 409 },
+ );
+ }
+
+ const buf = new Uint8Array(await req.arrayBuffer());
+ const storage = getMocapStorage();
+ try {
+ const framesAppended = await appendPoseFrames(
+ storage,
+ row.poseStreamPath,
+ buf,
+ );
+ return NextResponse.json({ appended: framesAppended });
+ } catch (err) {
+ return NextResponse.json(
+ {
+ error: err instanceof Error ? err.message : String(err),
+ },
+ { status: 400 },
+ );
+ }
+}
+
function parseRange(
header: string | null,
totalSize: number,
diff --git a/src/app/api/mocap/sessions/[id]/pose/route.ts b/src/app/api/mocap/sessions/[id]/pose/route.ts
index c263381..80a8bd7 100644
--- a/src/app/api/mocap/sessions/[id]/pose/route.ts
+++ b/src/app/api/mocap/sessions/[id]/pose/route.ts
@@ -1,53 +1,6 @@
-import { NextResponse } from "next/server";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/lib/auth";
-import { prisma } from "@/lib/db/prisma";
-import { getMocapStorage } from "@/lib/mocap/storage";
-import { BYTES_PER_FRAME_V1 } from "@/lib/mocap/poseFrameStream";
-
+// Compatibility alias for the original upload route. New clients should use
+// /api/mocap/sessions/:id/pose-stream.
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
-export async function POST(
- req: Request,
- { params }: { params: Promise<{ id: string }> },
-) {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const { id } = await params;
- const row = await prisma.mocapSession.findFirst({
- where: { id, userId: session.user.id },
- select: { id: true, status: true, poseStreamPath: true },
- });
- if (!row) {
- return NextResponse.json({ error: "Not found" }, { status: 404 });
- }
- if (row.status !== "capturing") {
- return NextResponse.json(
- { error: `Session not capturing (status=${row.status})` },
- { status: 409 },
- );
- }
-
- const buf = new Uint8Array(await req.arrayBuffer());
- if (buf.byteLength === 0) {
- return NextResponse.json({ appended: 0 });
- }
- if (buf.byteLength % BYTES_PER_FRAME_V1 !== 0) {
- return NextResponse.json(
- {
- error: `Body length ${buf.byteLength} not multiple of frame size ${BYTES_PER_FRAME_V1}`,
- },
- { status: 400 },
- );
- }
-
- const storage = getMocapStorage();
- await storage.appendBytes(row.poseStreamPath, buf);
-
- const framesAppended = buf.byteLength / BYTES_PER_FRAME_V1;
- return NextResponse.json({ appended: framesAppended });
-}
+export { POST } from "../pose-stream/route";
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
index d340a4a..6be9151 100644
--- a/src/app/api/mocap/sessions/route.ts
+++ b/src/app/api/mocap/sessions/route.ts
@@ -4,7 +4,7 @@ import { z } from "zod";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db/prisma";
import { getMocapStorage } from "@/lib/mocap/storage";
-import { encodeHeader } from "@/lib/mocap/poseFrameStream";
+import { initializePoseStreamBlob } from "@/lib/mocap/capturePersistence";
const CreateBody = z.object({
source: z.enum(["browser"]),
@@ -54,8 +54,7 @@ export async function POST(req: Request) {
});
try {
- const header = encodeHeader({ fps: body.captureFps });
- await storage.appendBytes(created.poseStreamPath, header);
+ await initializePoseStreamBlob(storage, created.poseStreamPath, body.captureFps);
} catch (err) {
await prisma.mocapSession.delete({ where: { id: created.id } }).catch(() => {});
return NextResponse.json(
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index b42f787..a9b085d 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -232,17 +232,29 @@ export default function MocapCapturePage() {
useEffect(() => {
if (state.kind !== "capturing") return;
- const onUnload = () => {
+ const sessionId = state.sessionId;
+ const onPageHide = () => {
try {
recorderRef.current?.requestData?.();
recorderRef.current?.stop();
} catch {
// ignore
}
+ try {
+ const durationSec = (Date.now() - startedAtRef.current) / 1000;
+ navigator.sendBeacon?.(
+ `/api/mocap/sessions/${sessionId}/finalize`,
+ new Blob([JSON.stringify({ durationSec })], {
+ type: "application/json",
+ }),
+ );
+ } catch {
+ // ignore
+ }
};
- window.addEventListener("beforeunload", onUnload);
- return () => window.removeEventListener("beforeunload", onUnload);
- }, [state.kind]);
+ window.addEventListener("pagehide", onPageHide);
+ return () => window.removeEventListener("pagehide", onPageHide);
+ }, [state]);
return (
diff --git a/src/lib/mocap/browserPoseSource.ts b/src/lib/mocap/browserPoseSource.ts
index ee7bdd6..5ccd5af 100644
--- a/src/lib/mocap/browserPoseSource.ts
+++ b/src/lib/mocap/browserPoseSource.ts
@@ -3,7 +3,7 @@
*
* Owns the worker, the upload queue, and the per-frame ImageBitmap pipeline.
* Frames flow:
→ grabFrame() → postMessage(bitmap) → worker → encoded
- * PoseFrame bytes → upload queue → POST /api/mocap/sessions/:id/pose.
+ * PoseFrame bytes → upload queue → POST /api/mocap/sessions/:id/pose-stream.
*/
import { BYTES_PER_FRAME_V1 } from "./poseFrameStream";
@@ -201,7 +201,7 @@ export class BrowserPoseSource {
private async upload(buf: Uint8Array): Promise {
const res = await fetch(
- `/api/mocap/sessions/${this.opts.sessionId}/pose`,
+ `/api/mocap/sessions/${this.opts.sessionId}/pose-stream`,
{
method: "POST",
body: new Blob([buf as BlobPart], {
diff --git a/src/lib/mocap/capturePersistence.ts b/src/lib/mocap/capturePersistence.ts
new file mode 100644
index 0000000..86343ef
--- /dev/null
+++ b/src/lib/mocap/capturePersistence.ts
@@ -0,0 +1,66 @@
+import {
+ BYTES_PER_FRAME_V1,
+ HEADER_SIZE,
+ framesFromBlobSize,
+ encodeHeader,
+} from "./poseFrameStream";
+import type { MocapStorage } from "./storage";
+
+const FRAME_COUNT_OFFSET = 16;
+
+export interface FinalizedPoseStream {
+ frameCount: number;
+ poseStreamBytes: number;
+}
+
+export function validatePoseFrameChunk(bytes: Uint8Array): number {
+ if (bytes.byteLength === 0) return 0;
+ if (bytes.byteLength % BYTES_PER_FRAME_V1 !== 0) {
+ throw new Error(
+ `Body length ${bytes.byteLength} not multiple of frame size ${BYTES_PER_FRAME_V1}`,
+ );
+ }
+ return bytes.byteLength / BYTES_PER_FRAME_V1;
+}
+
+export async function initializePoseStreamBlob(
+ storage: MocapStorage,
+ poseStreamPath: string,
+ fps: number,
+): Promise {
+ await storage.appendBytes(poseStreamPath, encodeHeader({ fps }));
+}
+
+export async function appendPoseFrames(
+ storage: MocapStorage,
+ poseStreamPath: string,
+ bytes: Uint8Array,
+): Promise {
+ const framesAppended = validatePoseFrameChunk(bytes);
+ if (framesAppended > 0) {
+ await storage.appendBytes(poseStreamPath, bytes);
+ }
+ return framesAppended;
+}
+
+export async function finalizePoseStreamBlob(
+ storage: MocapStorage,
+ poseStreamPath: string,
+): Promise {
+ const poseStreamBytes = await storage.size(poseStreamPath);
+ if (poseStreamBytes < HEADER_SIZE) {
+ throw new Error("Pose stream truncated below header");
+ }
+
+ const trailing = (poseStreamBytes - HEADER_SIZE) % BYTES_PER_FRAME_V1;
+ if (trailing !== 0) {
+ throw new Error(`Pose stream has ${trailing} trailing bytes (corrupt)`);
+ }
+
+ const frameCount = framesFromBlobSize(poseStreamBytes);
+ const headerPatch = new Uint8Array(4);
+ new DataView(headerPatch.buffer).setUint32(0, frameCount, true);
+ await storage.writeAt(poseStreamPath, headerPatch, FRAME_COUNT_OFFSET);
+
+ return { frameCount, poseStreamBytes };
+}
diff --git a/src/lib/mocap/storage.ts b/src/lib/mocap/storage.ts
index 17140c9..7a48036 100644
--- a/src/lib/mocap/storage.ts
+++ b/src/lib/mocap/storage.ts
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import path from "node:path";
+import { del, head, list, put } from "@vercel/blob";
export type ByteRange = { start: number; end?: number };
@@ -16,7 +17,7 @@ export interface MocapStorage {
const MOCAP_ROOT = "mocap";
-class LocalDiskStorage implements MocapStorage {
+export class LocalDiskStorage implements MocapStorage {
constructor(private readonly root: string) {}
videoPath(userId: string, sessionId: string): string {
@@ -108,6 +109,85 @@ class LocalDiskStorage implements MocapStorage {
}
}
+class VercelBlobStorage implements MocapStorage {
+ videoPath(userId: string, sessionId: string): string {
+ return path.posix.join(MOCAP_ROOT, userId, sessionId, "video.webm");
+ }
+
+ poseStreamPath(userId: string, sessionId: string): string {
+ return path.posix.join(MOCAP_ROOT, userId, sessionId, "pose-stream.bin");
+ }
+
+ async appendBytes(storagePath: string, bytes: Uint8Array): Promise {
+ const existing = await this.readIfExists(storagePath);
+ const combined = new Uint8Array(existing.byteLength + bytes.byteLength);
+ combined.set(existing, 0);
+ combined.set(bytes, existing.byteLength);
+ await this.putBytes(storagePath, combined);
+ }
+
+ async writeAt(
+ storagePath: string,
+ bytes: Uint8Array,
+ offset: number,
+ ): Promise {
+ const existing = await this.read(storagePath);
+ if (offset < 0 || offset + bytes.byteLength > existing.byteLength) {
+ throw new Error(`writeAt outside blob bounds for ${storagePath}`);
+ }
+ const updated = new Uint8Array(existing);
+ updated.set(bytes, offset);
+ await this.putBytes(storagePath, updated);
+ }
+
+ async read(storagePath: string, range?: ByteRange): Promise {
+ const meta = await head(storagePath);
+ const headers: HeadersInit = {};
+ if (range) {
+ const end = range.end === undefined ? "" : String(range.end - 1);
+ headers.Range = `bytes=${range.start}-${end}`;
+ }
+ const res = await fetch(meta.url, { headers, cache: "no-store" });
+ if (!res.ok && res.status !== 206) {
+ throw new Error(`Failed to read blob ${storagePath}: ${res.status}`);
+ }
+ return new Uint8Array(await res.arrayBuffer());
+ }
+
+ async size(storagePath: string): Promise {
+ const meta = await head(storagePath);
+ return meta.size;
+ }
+
+ async exists(storagePath: string): Promise {
+ try {
+ await head(storagePath);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async delete(storagePath: string): Promise {
+ const blobs = await list({ prefix: storagePath, limit: 1 });
+ if (blobs.blobs.length === 0) return;
+ await del(storagePath);
+ }
+
+ private async readIfExists(storagePath: string): Promise {
+ if (!(await this.exists(storagePath))) return new Uint8Array(0);
+ return this.read(storagePath);
+ }
+
+ private async putBytes(storagePath: string, bytes: Uint8Array): Promise {
+ await put(storagePath, Buffer.from(bytes), {
+ access: "private",
+ allowOverwrite: true,
+ contentType: "application/octet-stream",
+ });
+ }
+}
+
let instance: MocapStorage | null = null;
export function getMocapStorage(): MocapStorage {
@@ -122,10 +202,10 @@ export function getMocapStorage(): MocapStorage {
return instance;
}
- // Vercel Blob backend: deferred. Add @vercel/blob dependency and a
- // VercelBlobStorage class implementing MocapStorage. Append uses overwrite
- // via put({allowOverwrite: true}); byte-range read via fetch with Range header.
- throw new Error(
- `MOCAP_STORAGE_BACKEND="${backend}" not implemented. Set MOCAP_STORAGE_BACKEND=local for now.`,
- );
+ if (backend === "vercel-blob") {
+ instance = new VercelBlobStorage();
+ return instance;
+ }
+
+ throw new Error(`Unknown MOCAP_STORAGE_BACKEND="${backend}"`);
}
diff --git a/tests/mocapCapturePersistence.test.ts b/tests/mocapCapturePersistence.test.ts
new file mode 100644
index 0000000..3703944
--- /dev/null
+++ b/tests/mocapCapturePersistence.test.ts
@@ -0,0 +1,108 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import {
+ decodeFrame,
+ decodeHeader,
+ encodeFrame,
+ frameByteOffset,
+ KEYPOINTS_PER_FRAME_V1,
+ type PoseFrame,
+} from "../src/lib/mocap/poseFrameStream";
+import {
+ appendPoseFrames,
+ finalizePoseStreamBlob,
+ initializePoseStreamBlob,
+ validatePoseFrameChunk,
+} from "../src/lib/mocap/capturePersistence";
+import { LocalDiskStorage } from "../src/lib/mocap/storage";
+
+test("capture persistence writes, finalizes, and byte-range reads identical pose frames", async () => {
+ const { storage, cleanup } = await makeStorage();
+ try {
+ const posePath = storage.poseStreamPath("user-1", "session-1");
+ const frames = [makeFrame(0), makeFrame(1), makeFrame(2)];
+ const frameBytes = concat(frames.map(encodeFrame));
+
+ await initializePoseStreamBlob(storage, posePath, 30);
+ assert.equal(await appendPoseFrames(storage, posePath, frameBytes), 3);
+ const finalized = await finalizePoseStreamBlob(storage, posePath);
+ assert.equal(finalized.frameCount, 3);
+
+ const header = decodeHeader(await storage.read(posePath, { start: 0, end: 32 }));
+ assert.equal(header.keypointSchemaVersion, 1);
+ assert.equal(header.frameCount, 3);
+
+ const secondFrameBytes = await storage.read(posePath, {
+ start: frameByteOffset(1),
+ end: frameByteOffset(2),
+ });
+ const secondFrame = decodeFrame(secondFrameBytes);
+ assert.equal(secondFrame.timestampMs, Math.fround(frames[1].timestampMs));
+ assert.equal(secondFrame.qualityFlags, frames[1].qualityFlags);
+ for (let i = 0; i < frames[1].keypoints.length; i++) {
+ assert.equal(secondFrame.keypoints[i], frames[1].keypoints[i]);
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test("capture persistence rejects partial-frame chunks before storage append", () => {
+ const bad = new Uint8Array(17);
+ assert.throws(
+ () => validatePoseFrameChunk(bad),
+ /not multiple of frame size/,
+ );
+});
+
+test("finalize rejects corrupted blobs with partial trailing frame bytes", async () => {
+ const { storage, cleanup } = await makeStorage();
+ try {
+ const posePath = storage.poseStreamPath("user-1", "session-corrupt");
+ await initializePoseStreamBlob(storage, posePath, 30);
+ await storage.appendBytes(posePath, new Uint8Array(17));
+ await assert.rejects(
+ () => finalizePoseStreamBlob(storage, posePath),
+ /trailing bytes/,
+ );
+ } finally {
+ await cleanup();
+ }
+});
+
+async function makeStorage(): Promise<{
+ storage: LocalDiskStorage;
+ cleanup: () => Promise;
+}> {
+ const root = await mkdtemp(path.join(os.tmpdir(), "mocap-storage-"));
+ return {
+ storage: new LocalDiskStorage(root),
+ cleanup: () => rm(root, { recursive: true, force: true }),
+ };
+}
+
+function makeFrame(seed: number): PoseFrame {
+ const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ for (let i = 0; i < keypoints.length; i++) {
+ keypoints[i] = Math.fround(seed + i / 100);
+ }
+ return {
+ timestampMs: seed * 33.333,
+ keypoints,
+ qualityFlags: seed,
+ };
+}
+
+function concat(chunks: Uint8Array[]): Uint8Array {
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return out;
+}
From 302659f9607f6241a4dbae9445d29c6ad9145fac Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 12:32:45 +0200
Subject: [PATCH 06/29] Add mocap calibration and threshold settings
---
.../migration.sql | 2 +
.../migration.sql | 4 +
prisma/schema.prisma | 4 +
.../api/mocap/sessions/[id]/finalize/route.ts | 2 +
src/app/api/mocap/sessions/route.ts | 40 +-
src/app/api/settings/route.ts | 2 +
src/app/mocap/page.tsx | 413 +++++++++++++++++-
src/app/settings/page.tsx | 273 +++++++++++-
.../mocap/analysis/poseFrameStreamAdapter.ts | 82 ++++
src/lib/mocap/analysis/postureThresholds.ts | 139 +++++-
src/lib/mocap/browserPoseSource.ts | 41 +-
src/lib/mocap/poseWorker.ts | 33 +-
src/lib/settings.ts | 73 +++-
src/lib/validations/settings.ts | 66 +++
tests/e2e/mocap-capture.spec.ts | 2 +
tests/mocapAnalysis.test.ts | 38 ++
tests/poseFrameStreamAdapter.test.ts | 113 +++++
17 files changed, 1284 insertions(+), 43 deletions(-)
create mode 100644 prisma/migrations/20260508124500_add_posture_threshold_settings/migration.sql
create mode 100644 prisma/migrations/20260508143000_add_mocap_calibration_quality/migration.sql
create mode 100644 src/lib/mocap/analysis/poseFrameStreamAdapter.ts
create mode 100644 tests/poseFrameStreamAdapter.test.ts
diff --git a/prisma/migrations/20260508124500_add_posture_threshold_settings/migration.sql b/prisma/migrations/20260508124500_add_posture_threshold_settings/migration.sql
new file mode 100644
index 0000000..11f4b91
--- /dev/null
+++ b/prisma/migrations/20260508124500_add_posture_threshold_settings/migration.sql
@@ -0,0 +1,2 @@
+-- Add posture threshold settings for mocap fault tuning.
+ALTER TABLE "UserSettings" ADD COLUMN "postureThresholds" JSONB;
diff --git a/prisma/migrations/20260508143000_add_mocap_calibration_quality/migration.sql b/prisma/migrations/20260508143000_add_mocap_calibration_quality/migration.sql
new file mode 100644
index 0000000..c877543
--- /dev/null
+++ b/prisma/migrations/20260508143000_add_mocap_calibration_quality/migration.sql
@@ -0,0 +1,4 @@
+ALTER TABLE "MocapSession"
+ADD COLUMN "calibrationCatchFrame" JSONB,
+ADD COLUMN "calibrationFinishFrame" JSONB,
+ADD COLUMN "qualityFlags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ab6ad87..32abc89 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -191,8 +191,11 @@ model MocapSession {
captureModelVersion String
capturePerspective String
captureFps Float
+ calibrationCatchFrame Json?
+ calibrationFinishFrame Json?
durationSec Float @default(0)
qualityScore Float?
+ qualityFlags String[] @default([])
status String @default("capturing")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -471,6 +474,7 @@ model UserSettings {
customPromptsAi Json?
userProfileContext String? @db.Text
userProfileRawInput String? @db.Text
+ postureThresholds Json?
dashboardSettings Json?
sessionsViewSettings Json?
sessionAnalysisSettings Json?
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
index 7f2996e..f96d3b9 100644
--- a/src/app/api/mocap/sessions/[id]/finalize/route.ts
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -12,6 +12,7 @@ export const runtime = "nodejs";
const Body = z.object({
durationSec: z.number().nonnegative().max(60 * 60 * 8),
qualityScore: z.number().min(0).max(1).optional(),
+ qualityFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
});
export async function POST(
@@ -74,6 +75,7 @@ export async function POST(
status: "ready",
durationSec: body.durationSec,
qualityScore: body.qualityScore ?? null,
+ qualityFlags: body.qualityFlags ?? [],
},
});
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
index 6be9151..76c07e5 100644
--- a/src/app/api/mocap/sessions/route.ts
+++ b/src/app/api/mocap/sessions/route.ts
@@ -6,13 +6,43 @@ import { prisma } from "@/lib/db/prisma";
import { getMocapStorage } from "@/lib/mocap/storage";
import { initializePoseStreamBlob } from "@/lib/mocap/capturePersistence";
-const CreateBody = z.object({
- source: z.enum(["browser"]),
- captureModelVersion: z.string().min(1).max(120),
+const CalibrationFrame = z.object({
+ pose: z.enum(["catch", "finish"]),
+ capturedAt: z.string().datetime(),
capturePerspective: z.enum(["side-left", "side-right"]),
- captureFps: z.number().positive().max(240),
+ videoWidth: z.number().int().nonnegative(),
+ videoHeight: z.number().int().nonnegative(),
+ meanKeypointConfidence: z.number().min(0).max(1),
+ trackedKeypointCount: z.number().int().nonnegative().max(33),
+ qualityFlags: z.number().int().nonnegative(),
+ poseFrameBase64: z.string().min(1),
});
+const CreateBody = z
+ .object({
+ source: z.enum(["browser"]),
+ captureModelVersion: z.string().min(1).max(120),
+ capturePerspective: z.enum(["side-left", "side-right"]),
+ captureFps: z.number().positive().max(240),
+ calibrationCatchFrame: CalibrationFrame.extend({
+ pose: z.literal("catch"),
+ }),
+ calibrationFinishFrame: CalibrationFrame.extend({
+ pose: z.literal("finish"),
+ }),
+ })
+ .superRefine((body, ctx) => {
+ for (const field of ["calibrationCatchFrame", "calibrationFinishFrame"] as const) {
+ if (body[field].capturePerspective !== body.capturePerspective) {
+ ctx.addIssue({
+ code: "custom",
+ path: [field, "capturePerspective"],
+ message: "Calibration perspective must match capturePerspective",
+ });
+ }
+ }
+ });
+
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
@@ -40,6 +70,8 @@ export async function POST(req: Request) {
captureModelVersion: body.captureModelVersion,
capturePerspective: body.capturePerspective,
captureFps: body.captureFps,
+ calibrationCatchFrame: body.calibrationCatchFrame,
+ calibrationFinishFrame: body.calibrationFinishFrame,
videoStoragePath: "pending",
poseStreamPath: "pending",
status: "capturing",
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index 0337863..41850a9 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -42,6 +42,7 @@ export async function GET() {
insightsRevision: 0,
userProfileContext: null,
userProfileRawInput: null,
+ postureThresholds: null,
aiConfig: null,
customPromptsAi: null,
}
@@ -134,6 +135,7 @@ export async function POST(req: Request) {
// User profile context
if (settingsData.userProfileContext !== undefined) updateData.userProfileContext = settingsData.userProfileContext;
if (settingsData.userProfileRawInput !== undefined) updateData.userProfileRawInput = settingsData.userProfileRawInput;
+ if (settingsData.postureThresholds !== undefined) updateData.postureThresholds = settingsData.postureThresholds;
// Dashboard and view settings
if (settingsData.dashboardSettings !== undefined) updateData.dashboardSettings = settingsData.dashboardSettings;
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index a9b085d..bc09e4e 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -13,11 +13,15 @@ import {
BrowserPoseSource,
type PoseSourceStatus,
} from "@/lib/mocap/browserPoseSource";
+import { QUALITY_FLAG } from "@/lib/mocap/poseFrameStream";
import { VideoUploader } from "@/lib/mocap/videoUploader";
const CAPTURE_FPS = 30;
const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
const VIDEO_TIMESLICE_MS = 1000;
+const MIN_TRACKED_KEYPOINTS = 20;
+const MIN_MEAN_CONFIDENCE = 0.5;
+const DEGRADED_FRAME_MS = 2000;
type CaptureState =
| { kind: "idle" }
@@ -36,24 +40,78 @@ type CaptureState =
sessionId: string;
durationSec: number;
frameCount: number;
- }
+ }
| { kind: "error"; message: string };
+type CalibrationPose = "catch" | "finish";
+
+type CalibrationFrame = {
+ pose: CalibrationPose;
+ capturedAt: string;
+ capturePerspective: "side-left" | "side-right";
+ videoWidth: number;
+ videoHeight: number;
+ meanKeypointConfidence: number;
+ trackedKeypointCount: number;
+ qualityFlags: number;
+ poseFrameBase64: string;
+};
+
+type CalibrationState =
+ | { kind: "idle"; hint?: string }
+ | {
+ kind: "starting";
+ catchFrame?: CalibrationFrame;
+ finishFrame?: CalibrationFrame;
+ hint?: string;
+ }
+ | {
+ kind: "ready";
+ catchFrame?: CalibrationFrame;
+ finishFrame?: CalibrationFrame;
+ hint?: string;
+ };
+
+type PoseQuality = {
+ trackedKeypointCount: number;
+ meanConfidence: number;
+ qualityFlags: number;
+ landmarkCount: number;
+ poseFrameBase64: string;
+};
+
+const EMPTY_QUALITY: PoseQuality = {
+ trackedKeypointCount: 0,
+ meanConfidence: 0,
+ qualityFlags: 0,
+ landmarkCount: 0,
+ poseFrameBase64: "",
+};
+
export default function MocapCapturePage() {
const videoRef = useRef(null);
const streamRef = useRef(null);
const recorderRef = useRef(null);
const uploaderRef = useRef(null);
const sourceRef = useRef(null);
+ const calibrationSourceRef = useRef(null);
const startedAtRef = useRef(0);
+ const degradedSinceRef = useRef(null);
+ const latestPoseFrameRef = useRef(EMPTY_QUALITY);
const [state, setState] = useState({ kind: "idle" });
+ const [calibration, setCalibration] = useState({
+ kind: "idle",
+ });
const [framesEncoded, setFramesEncoded] = useState(0);
const [poseStatus, setPoseStatus] = useState("idle");
const [perspective, setPerspective] = useState<"side-left" | "side-right">(
"side-right",
);
const [elapsedSec, setElapsedSec] = useState(0);
+ const [quality, setQuality] = useState(EMPTY_QUALITY);
+ const [framingDegraded, setFramingDegraded] = useState(false);
+ const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
useEffect(() => {
if (state.kind !== "capturing") return;
@@ -63,7 +121,49 @@ export default function MocapCapturePage() {
return () => clearInterval(t);
}, [state.kind]);
+ const handlePoseFrame = useCallback(
+ (
+ info: PoseQuality & {
+ framesEncoded: number;
+ },
+ monitorDegradedFraming: boolean,
+ ) => {
+ const nextQuality: PoseQuality = {
+ trackedKeypointCount: info.trackedKeypointCount,
+ meanConfidence: info.meanConfidence,
+ qualityFlags: info.qualityFlags,
+ landmarkCount: info.landmarkCount,
+ poseFrameBase64: info.poseFrameBase64,
+ };
+ latestPoseFrameRef.current = nextQuality;
+ setFramesEncoded(info.framesEncoded);
+ setQuality(nextQuality);
+
+ if (!monitorDegradedFraming) return;
+
+ const degraded = isDegradedFraming(nextQuality);
+ if (!degraded) {
+ degradedSinceRef.current = null;
+ setFramingDegraded(false);
+ return;
+ }
+
+ const now = Date.now();
+ degradedSinceRef.current ??= now;
+ if (now - degradedSinceRef.current >= DEGRADED_FRAME_MS) {
+ setFramingDegraded(true);
+ setSessionQualityFlags((flags) =>
+ flags.includes("framing-degraded")
+ ? flags
+ : [...flags, "framing-degraded"],
+ );
+ }
+ },
+ [],
+ );
+
const teardown = useCallback(async () => {
+ calibrationSourceRef.current = null;
sourceRef.current = null;
recorderRef.current = null;
uploaderRef.current = null;
@@ -85,6 +185,11 @@ export default function MocapCapturePage() {
} catch {
// ignore
}
+ try {
+ await calibrationSourceRef.current?.stop();
+ } catch {
+ // ignore
+ }
try {
recorderRef.current?.stop();
} catch {
@@ -102,18 +207,115 @@ export default function MocapCapturePage() {
[teardown],
);
+ const startCalibration = useCallback(async () => {
+ setCalibration({ kind: "starting" });
+ setPoseStatus("idle");
+ setFramesEncoded(0);
+ setQuality(EMPTY_QUALITY);
+ latestPoseFrameRef.current = EMPTY_QUALITY;
+ try {
+ if (!streamRef.current) {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { width: 1280, height: 720, frameRate: CAPTURE_FPS },
+ audio: false,
+ });
+ streamRef.current = stream;
+ }
+ const video = videoRef.current!;
+ video.srcObject = streamRef.current;
+ await video.play();
+
+ await calibrationSourceRef.current?.stop().catch(() => {});
+ const source = new BrowserPoseSource({
+ videoEl: video,
+ uploadPoseStream: false,
+ onStatus: (s) => setPoseStatus(s),
+ onFrame: (info) => handlePoseFrame(info, false),
+ onError: (err) => {
+ setCalibration({ kind: "idle", hint: err.message });
+ },
+ });
+ calibrationSourceRef.current = source;
+ await source.init();
+ source.start();
+ setCalibration({ kind: "ready" });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setCalibration({ kind: "idle", hint: message });
+ await teardown();
+ }
+ }, [handlePoseFrame, teardown]);
+
+ const captureCalibrationFrame = useCallback(
+ (pose: CalibrationPose) => {
+ const latest = latestPoseFrameRef.current;
+ if (!isCameraReadyForCapture(latest)) {
+ setCalibration((current) => ({
+ ...current,
+ hint:
+ "Move the rower and erg fully into the side view, then hold still for a second.",
+ }));
+ return;
+ }
+
+ const video = videoRef.current;
+ const frame: CalibrationFrame = {
+ pose,
+ capturedAt: new Date().toISOString(),
+ capturePerspective: perspective,
+ videoWidth: video?.videoWidth ?? 0,
+ videoHeight: video?.videoHeight ?? 0,
+ meanKeypointConfidence: latest.meanConfidence,
+ trackedKeypointCount: latest.trackedKeypointCount,
+ qualityFlags: latest.qualityFlags,
+ poseFrameBase64: latest.poseFrameBase64,
+ };
+
+ setCalibration((current) => ({
+ kind: "ready",
+ catchFrame:
+ pose === "catch"
+ ? frame
+ : "catchFrame" in current
+ ? current.catchFrame
+ : undefined,
+ finishFrame:
+ pose === "finish"
+ ? frame
+ : "finishFrame" in current
+ ? current.finishFrame
+ : undefined,
+ }));
+ },
+ [perspective],
+ );
+
const start = useCallback(async () => {
+ const calibrationFrames = getCalibrationFrames(calibration);
+ if (!calibrationFrames || !isCameraReadyForCapture(latestPoseFrameRef.current)) {
+ setCalibration((current) => ({
+ ...current,
+ hint:
+ "Complete catch and finish calibration with the rower and erg fully in frame before recording.",
+ }));
+ return;
+ }
+
setState({ kind: "starting" });
let sessionId: string | undefined;
try {
- const stream = await navigator.mediaDevices.getUserMedia({
- video: { width: 1280, height: 720, frameRate: CAPTURE_FPS },
- audio: false,
- });
+ const stream =
+ streamRef.current ??
+ (await navigator.mediaDevices.getUserMedia({
+ video: { width: 1280, height: 720, frameRate: CAPTURE_FPS },
+ audio: false,
+ }));
streamRef.current = stream;
const video = videoRef.current!;
video.srcObject = stream;
await video.play();
+ await calibrationSourceRef.current?.stop();
+ calibrationSourceRef.current = null;
const createRes = await fetch("/api/mocap/sessions", {
method: "POST",
@@ -123,6 +325,8 @@ export default function MocapCapturePage() {
captureModelVersion: CAPTURE_MODEL_VERSION,
capturePerspective: perspective,
captureFps: CAPTURE_FPS,
+ calibrationCatchFrame: calibrationFrames.catchFrame,
+ calibrationFinishFrame: calibrationFrames.finishFrame,
}),
});
if (!createRes.ok) {
@@ -157,7 +361,7 @@ export default function MocapCapturePage() {
sessionId,
videoEl: video,
onStatus: (s) => setPoseStatus(s),
- onFrame: (info) => setFramesEncoded(info.framesEncoded),
+ onFrame: (info) => handlePoseFrame(info, true),
onError: (err) => handleError(err, sessionId),
});
sourceRef.current = source;
@@ -168,6 +372,9 @@ export default function MocapCapturePage() {
startedAtRef.current = Date.now();
setElapsedSec(0);
setFramesEncoded(0);
+ setFramingDegraded(false);
+ setSessionQualityFlags([]);
+ degradedSinceRef.current = null;
setState({
kind: "capturing",
sessionId,
@@ -176,7 +383,7 @@ export default function MocapCapturePage() {
} catch (err) {
await handleError(err, sessionId);
}
- }, [handleError, perspective]);
+ }, [calibration, handleError, handlePoseFrame, perspective]);
const stop = useCallback(async () => {
if (state.kind !== "capturing") return;
@@ -199,7 +406,11 @@ export default function MocapCapturePage() {
{
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ durationSec }),
+ body: JSON.stringify({
+ durationSec,
+ qualityScore: qualityScoreFor(latestPoseFrameRef.current),
+ qualityFlags: sessionQualityFlags,
+ }),
},
);
if (!finalizeRes.ok) {
@@ -211,6 +422,7 @@ export default function MocapCapturePage() {
frameCount: number;
} = await finalizeRes.json();
await teardown();
+ setCalibration({ kind: "idle" });
setState({
kind: "done",
sessionId: finalized.id,
@@ -220,11 +432,12 @@ export default function MocapCapturePage() {
} catch (err) {
await handleError(err, sessionId);
}
- }, [state, handleError, teardown]);
+ }, [state, handleError, sessionQualityFlags, teardown]);
useEffect(() => {
return () => {
sourceRef.current?.stop().catch(() => {});
+ calibrationSourceRef.current?.stop().catch(() => {});
recorderRef.current?.stop();
teardown();
};
@@ -244,9 +457,18 @@ export default function MocapCapturePage() {
const durationSec = (Date.now() - startedAtRef.current) / 1000;
navigator.sendBeacon?.(
`/api/mocap/sessions/${sessionId}/finalize`,
- new Blob([JSON.stringify({ durationSec })], {
- type: "application/json",
- }),
+ new Blob(
+ [
+ JSON.stringify({
+ durationSec,
+ qualityScore: qualityScoreFor(latestPoseFrameRef.current),
+ qualityFlags: sessionQualityFlags,
+ }),
+ ],
+ {
+ type: "application/json",
+ },
+ ),
);
} catch {
// ignore
@@ -254,7 +476,24 @@ export default function MocapCapturePage() {
};
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
- }, [state]);
+ }, [state, sessionQualityFlags]);
+
+ const calibrationFrames = getCalibrationFrames(calibration);
+ const nextCalibrationPose: CalibrationPose | null = !(
+ "catchFrame" in calibration && calibration.catchFrame
+ )
+ ? "catch"
+ : !("finishFrame" in calibration && calibration.finishFrame)
+ ? "finish"
+ : null;
+ const cameraReady = isCameraReadyForCapture(quality);
+ const canRecord =
+ state.kind !== "capturing" &&
+ state.kind !== "starting" &&
+ state.kind !== "stopping" &&
+ calibration.kind === "ready" &&
+ Boolean(calibrationFrames) &&
+ cameraReady;
return (
@@ -276,14 +515,54 @@ export default function MocapCapturePage() {
onChange={(e) =>
setPerspective(e.target.value as "side-left" | "side-right")
}
- disabled={state.kind !== "idle" && state.kind !== "done"}
+ disabled={
+ calibration.kind !== "idle" ||
+ (state.kind !== "idle" && state.kind !== "done")
+ }
>
Side (right toward camera)
Side (left toward camera)
+ {calibration.kind === "idle" &&
+ (state.kind === "idle" || state.kind === "done") ? (
+
+ Start calibration
+
+ ) : null}
+ {calibration.kind === "starting" ? (
+
+ Calibrating…
+
+ ) : null}
+ {calibration.kind === "ready" && nextCalibrationPose ? (
+ captureCalibrationFrame(nextCalibrationPose)}
+ disabled={!cameraReady}
+ data-testid={`mocap-capture-${nextCalibrationPose}`}
+ >
+ Capture {nextCalibrationPose}
+
+ ) : null}
+ {calibration.kind === "ready" &&
+ (state.kind === "idle" || state.kind === "done") ? (
+
+ Recalibrate
+
+ ) : null}
{state.kind === "idle" || state.kind === "done" ? (
-
+
Start mocap session
) : null}
@@ -308,6 +587,29 @@ export default function MocapCapturePage() {
) : null}
+
+
+
+
+
+
+ {calibration.hint ? (
+
+ {calibration.hint}
+
+ ) : null}
+
+ {framingDegraded ? (
+
+ Framing degraded. Check lighting and keep the rower fully in the
+ side view.
+
+ ) : null}
+
@@ -339,6 +651,15 @@ export default function MocapCapturePage() {
: "0.0"
}
/>
+
+
+
{state.kind === "done" ? (
@@ -376,6 +697,68 @@ function Stat({ label, value }: { label: string; value: string }) {
);
}
+function CalibrationStep({ label, done }: { label: string; done: boolean }) {
+ return (
+
+
{label}
+
+ {done ? "Ready" : "Needed"}
+
+
+ );
+}
+
+function getCalibrationFrames(
+ calibration: CalibrationState,
+): { catchFrame: CalibrationFrame; finishFrame: CalibrationFrame } | null {
+ if (
+ "catchFrame" in calibration &&
+ calibration.catchFrame &&
+ "finishFrame" in calibration &&
+ calibration.finishFrame
+ ) {
+ return {
+ catchFrame: calibration.catchFrame,
+ finishFrame: calibration.finishFrame,
+ };
+ }
+ return null;
+}
+
+function isCameraReadyForCapture(quality: PoseQuality): boolean {
+ return (
+ quality.trackedKeypointCount >= MIN_TRACKED_KEYPOINTS &&
+ quality.meanConfidence >= MIN_MEAN_CONFIDENCE &&
+ (quality.qualityFlags & QUALITY_FLAG.OUT_OF_FRAME) === 0
+ );
+}
+
+function isDegradedFraming(quality: PoseQuality): boolean {
+ return (
+ quality.trackedKeypointCount < MIN_TRACKED_KEYPOINTS ||
+ quality.meanConfidence < MIN_MEAN_CONFIDENCE ||
+ (quality.qualityFlags &
+ (QUALITY_FLAG.OUT_OF_FRAME | QUALITY_FLAG.LOW_CONFIDENCE)) !==
+ 0
+ );
+}
+
+function qualityScoreFor(quality: PoseQuality): number {
+ const trackedRatio = quality.trackedKeypointCount / 33;
+ return Math.max(0, Math.min(1, quality.meanConfidence * trackedRatio));
+}
+
+function qualityFlagLabel(quality: PoseQuality): string {
+ const labels = [];
+ if ((quality.qualityFlags & QUALITY_FLAG.OUT_OF_FRAME) !== 0) {
+ labels.push("out");
+ }
+ if ((quality.qualityFlags & QUALITY_FLAG.LOW_CONFIDENCE) !== 0) {
+ labels.push("low");
+ }
+ return labels.length > 0 ? labels.join(", ") : "ok";
+}
+
function pickRecorderMime(): string {
const candidates = [
"video/webm;codecs=vp9",
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 8e5f74c..052b7c5 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, type ReactNode } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -19,6 +19,13 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { settings, Settings, UserPreferences, DataManagement, TrainingSettings, NotificationSettings, PrivacySettings, AISettings, SmartRowSettings } from '@/lib/settings';
+import {
+ defaultPostureThresholdSettings,
+ postureThresholdsV1,
+ thresholdBandsEqual,
+ validatePostureThresholdBands,
+ type PostureThresholdBands,
+} from '@/lib/mocap/analysis/postureThresholds';
import { cloudAI } from '@/lib/cloudAI';
import { deleteAllInsightsFromDB } from '@/lib/dataSync';
import { useAIInsights } from '@/hooks/useAIInsights';
@@ -85,6 +92,7 @@ type SettingsCategory =
| 'notificationSettings'
| 'privacySettings'
| 'smartRowSettings'
+ | 'mocapSettings'
| 'aiSettings';
export default function SettingsPage() {
@@ -350,6 +358,9 @@ export default function SettingsPage() {
case 'smartRowSettings':
settings.updateSmartRowSettings(updates);
break;
+ case 'mocapSettings':
+ settings.updateMocapSettings(updates);
+ break;
case 'aiSettings':
settings.updateAISettings(updates);
break;
@@ -458,6 +469,7 @@ export default function SettingsPage() {
{ id: 'trainingSettings', name: 'Training Settings', icon: Target, description: 'Training zones, goals, and preferences' },
{ id: 'notificationSettings', name: 'Notifications', icon: Bell, description: 'Alerts and reminders' },
{ id: 'privacySettings', name: 'Privacy', icon: Shield, description: 'Data sharing and privacy controls' },
+ { id: 'mocapSettings', name: 'Mocap', icon: Target, description: 'Posture analysis thresholds' },
{ id: 'aiSettings', name: 'AI Settings', icon: Brain, description: 'Configure AI assistant, training plans, achievement generation, etc.' }
];
@@ -1077,6 +1089,235 @@ export default function SettingsPage() {
);
+ const savePostureThresholds = (thresholds: PostureThresholdBands) => {
+ const validation = validatePostureThresholdBands(thresholds);
+ if (!validation.valid) {
+ setErrorMessage(validation.errors[0] || 'Invalid posture threshold');
+ return;
+ }
+
+ saveSettings('mocapSettings', {
+ postureThresholds: {
+ version: postureThresholdsV1.version,
+ thresholds,
+ userOverridden: !thresholdBandsEqual(
+ thresholds,
+ postureThresholdsV1.thresholds
+ ),
+ },
+ postureThresholdWarning: null,
+ });
+ };
+
+ const updatePostureThreshold = (
+ faultKey: keyof PostureThresholdBands,
+ fieldKey: string,
+ value: number
+ ) => {
+ if (!settingsData?.mocapSettings) return;
+ const thresholds = settingsData.mocapSettings.postureThresholds.thresholds;
+ const next = {
+ ...thresholds,
+ [faultKey]: {
+ ...thresholds[faultKey],
+ [fieldKey]: value,
+ },
+ } as PostureThresholdBands;
+ savePostureThresholds(next);
+ };
+
+ const resetPostureFault = (faultKey: keyof PostureThresholdBands) => {
+ if (!settingsData?.mocapSettings) return;
+ const thresholds = settingsData.mocapSettings.postureThresholds.thresholds;
+ savePostureThresholds({
+ ...thresholds,
+ [faultKey]: { ...postureThresholdsV1.thresholds[faultKey] },
+ } as PostureThresholdBands);
+ };
+
+ const resetAllPostureThresholds = () => {
+ saveSettings('mocapSettings', {
+ postureThresholds: defaultPostureThresholdSettings(),
+ postureThresholdWarning: null,
+ });
+ };
+
+ const renderMocapSettings = () => {
+ const active = settingsData.mocapSettings.postureThresholds;
+ const thresholds = active.thresholds;
+ const validation = validatePostureThresholdBands(thresholds);
+
+ const field = (
+ faultKey: keyof PostureThresholdBands,
+ fieldKey: string,
+ label: string,
+ value: number,
+ step = 1
+ ) => (
+
+ {label}
+
+ updatePostureThreshold(
+ faultKey,
+ fieldKey,
+ Number(e.target.value)
+ )
+ }
+ className="mt-1"
+ />
+
+ );
+
+ return (
+
+ {settingsData.mocapSettings.postureThresholdWarning && (
+
+
+
+ {settingsData.mocapSettings.postureThresholdWarning}
+
+
+ )}
+
+ {!validation.valid && (
+
+
+ {validation.errors.join(' ')}
+
+ )}
+
+
+
+
+
+ Posture Fault Thresholds
+
+ Tune the five v1 mocap fault rules used by analysis.
+
+
+
+
+ {active.userOverridden ? 'Custom' : 'Defaults'}
+
+
+
+ Reset all
+
+
+
+
+
+ resetPostureFault('rounded_back_at_catch')}
+ >
+
+ {field(
+ 'rounded_back_at_catch',
+ 'warningBelowDeg',
+ 'Warning below degrees',
+ thresholds.rounded_back_at_catch.warningBelowDeg
+ )}
+ {field(
+ 'rounded_back_at_catch',
+ 'criticalBelowDeg',
+ 'Critical below degrees',
+ thresholds.rounded_back_at_catch.criticalBelowDeg
+ )}
+
+
+
+ resetPostureFault('early_arm_bend')}
+ >
+
+ {field(
+ 'early_arm_bend',
+ 'infoBeforeLegsCompleteFrames',
+ 'Info at frames early',
+ thresholds.early_arm_bend.infoBeforeLegsCompleteFrames
+ )}
+ {field(
+ 'early_arm_bend',
+ 'warningBeforeLegsCompleteFrames',
+ 'Warning at frames early',
+ thresholds.early_arm_bend.warningBeforeLegsCompleteFrames
+ )}
+
+
+
+ resetPostureFault('back_opens_before_legs_drive')}
+ >
+ {field(
+ 'back_opens_before_legs_drive',
+ 'warningTorsoOpensBeforeLegsFrames',
+ 'Warning at frames early',
+ thresholds.back_opens_before_legs_drive
+ .warningTorsoOpensBeforeLegsFrames
+ )}
+
+
+ resetPostureFault('excessive_layback')}
+ >
+
+ {field(
+ 'excessive_layback',
+ 'infoAboveDeg',
+ 'Info above degrees',
+ thresholds.excessive_layback.infoAboveDeg
+ )}
+ {field(
+ 'excessive_layback',
+ 'warningAboveDeg',
+ 'Warning above degrees',
+ thresholds.excessive_layback.warningAboveDeg
+ )}
+
+
+
+ resetPostureFault('slow_recovery_ratio')}
+ >
+
+ {field(
+ 'slow_recovery_ratio',
+ 'warningAboveRatio',
+ 'Warning above ratio',
+ thresholds.slow_recovery_ratio.warningAboveRatio,
+ 0.1
+ )}
+ {field(
+ 'slow_recovery_ratio',
+ 'criticalAboveRatio',
+ 'Critical above ratio',
+ thresholds.slow_recovery_ratio.criticalAboveRatio,
+ 0.1
+ )}
+
+
+
+
+
+ );
+ };
+
const renderAISettings = () => {
// ✅ No hooks here - moved to component level
// Conditional logic is safe now
@@ -2424,6 +2665,8 @@ export default function SettingsPage() {
return renderNotificationSettings();
case 'privacySettings':
return renderPrivacySettings();
+ case 'mocapSettings':
+ return renderMocapSettings();
case 'aiSettings':
return renderAISettings();
default:
@@ -2573,3 +2816,31 @@ export default function SettingsPage() {
);
}
+
+function ThresholdCard({
+ title,
+ description,
+ children,
+ onReset,
+}: {
+ title: string;
+ description: string;
+ children: ReactNode;
+ onReset: () => void;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+
+ Reset {title}
+
+
+ {children}
+
+ );
+}
diff --git a/src/lib/mocap/analysis/poseFrameStreamAdapter.ts b/src/lib/mocap/analysis/poseFrameStreamAdapter.ts
new file mode 100644
index 0000000..2987484
--- /dev/null
+++ b/src/lib/mocap/analysis/poseFrameStreamAdapter.ts
@@ -0,0 +1,82 @@
+import {
+ BYTES_PER_FRAME_V1,
+ HEADER_SIZE,
+ KEYPOINTS_PER_FRAME_V1,
+ OPEN_FRAME_COUNT,
+ PoseStreamFormatError,
+ decodeFrame,
+ decodeHeader,
+} from "../poseFrameStream";
+import type {
+ CapturePerspective,
+ PoseAnalysisFrame,
+ PoseFrameStream,
+ PosePoint,
+} from "./types";
+
+export function adaptPoseFrameStreamBlob(
+ blob: Uint8Array,
+ capturePerspective: CapturePerspective,
+): PoseFrameStream {
+ return adaptPoseFrameStreamBytes(
+ blob.subarray(0, HEADER_SIZE),
+ blob.subarray(HEADER_SIZE),
+ capturePerspective,
+ );
+}
+
+export function adaptPoseFrameStreamBytes(
+ headerBytes: Uint8Array,
+ packedFrames: Uint8Array,
+ capturePerspective: CapturePerspective,
+): PoseFrameStream {
+ const header = decodeHeader(headerBytes);
+ if (
+ header.keypointsPerFrame !== KEYPOINTS_PER_FRAME_V1 ||
+ header.bytesPerFrame !== BYTES_PER_FRAME_V1
+ ) {
+ throw new PoseStreamFormatError("PoseFrameStream header does not match v1 frame layout");
+ }
+
+ if (packedFrames.byteLength % header.bytesPerFrame !== 0) {
+ throw new PoseStreamFormatError(
+ `Packed frames length ${packedFrames.byteLength} is not a multiple of frame size ${header.bytesPerFrame}`,
+ );
+ }
+
+ const frameCount = packedFrames.byteLength / header.bytesPerFrame;
+ if (header.frameCount !== OPEN_FRAME_COUNT && header.frameCount !== frameCount) {
+ throw new PoseStreamFormatError(
+ `Header frameCount ${header.frameCount} does not match packed frame count ${frameCount}`,
+ );
+ }
+
+ return {
+ fps: header.fps,
+ capturePerspective,
+ frames: Array.from({ length: frameCount }, (_, frameIndex) =>
+ adaptFrame(packedFrames, frameIndex * header.bytesPerFrame),
+ ),
+ };
+}
+
+function adaptFrame(bytes: Uint8Array, offset: number): PoseAnalysisFrame {
+ const frame = decodeFrame(bytes, offset);
+ return {
+ timestampMs: frame.timestampMs,
+ keypoints: keypointTripletsToPosePoints(frame.keypoints),
+ qualityFlags: frame.qualityFlags,
+ };
+}
+
+function keypointTripletsToPosePoints(keypoints: Float32Array): PosePoint[] {
+ const points: PosePoint[] = [];
+ for (let i = 0; i < keypoints.length; i += 3) {
+ points.push({
+ x: keypoints[i],
+ y: keypoints[i + 1],
+ confidence: keypoints[i + 2],
+ });
+ }
+ return points;
+}
diff --git a/src/lib/mocap/analysis/postureThresholds.ts b/src/lib/mocap/analysis/postureThresholds.ts
index ceef3f5..b72a757 100644
--- a/src/lib/mocap/analysis/postureThresholds.ts
+++ b/src/lib/mocap/analysis/postureThresholds.ts
@@ -33,6 +33,11 @@ export interface UserPostureThresholdSettings extends VersionedPostureThresholds
userOverridden: boolean;
}
+export interface ResolvedPostureThresholdSettings {
+ settings: UserPostureThresholdSettings;
+ warning: string | null;
+}
+
export const POSTURE_FAULT_CATALOG_V1: readonly PostureFaultType[] = [
"rounded_back_at_catch",
"early_arm_bend",
@@ -90,22 +95,112 @@ export function migratePostureThresholdSettings(
stored: unknown,
defaults: VersionedPostureThresholds = postureThresholdsV1,
): UserPostureThresholdSettings {
+ return resolvePostureThresholdSettings(stored, defaults).settings;
+}
+
+export function resolvePostureThresholdSettings(
+ stored: unknown,
+ defaults: VersionedPostureThresholds = postureThresholdsV1,
+): ResolvedPostureThresholdSettings {
if (!isUserPostureThresholdSettings(stored)) {
- return defaultPostureThresholdSettings(defaults);
+ return {
+ settings: defaultPostureThresholdSettings(defaults),
+ warning:
+ stored === null || stored === undefined
+ ? null
+ : "Saved posture thresholds were malformed and defaults are active.",
+ };
}
if (stored.version !== defaults.version && !stored.userOverridden) {
- return defaultPostureThresholdSettings(defaults);
+ return {
+ settings: defaultPostureThresholdSettings(defaults),
+ warning: null,
+ };
+ }
+
+ const validation = validatePostureThresholdBands(stored.thresholds);
+ if (!validation.valid) {
+ return {
+ settings: defaultPostureThresholdSettings(defaults),
+ warning:
+ "Saved posture thresholds were invalid and defaults are active.",
+ };
}
return {
- version: stored.version,
- thresholds: cloneThresholds(stored.thresholds),
- userOverridden: stored.userOverridden,
+ settings: {
+ version: stored.version,
+ thresholds: cloneThresholds(stored.thresholds),
+ userOverridden: stored.userOverridden,
+ },
+ warning: null,
};
}
-function isUserPostureThresholdSettings(
+export function validatePostureThresholdBands(
+ thresholds: PostureThresholdBands,
+): { valid: true } | { valid: false; errors: string[] } {
+ const errors: string[] = [];
+
+ if (
+ thresholds.rounded_back_at_catch.criticalBelowDeg >=
+ thresholds.rounded_back_at_catch.warningBelowDeg
+ ) {
+ errors.push("Rounded-back critical angle must be below warning angle.");
+ }
+ if (
+ thresholds.early_arm_bend.infoBeforeLegsCompleteFrames >
+ thresholds.early_arm_bend.warningBeforeLegsCompleteFrames
+ ) {
+ errors.push("Early-arm-bend info frame count must be at or below warning.");
+ }
+ if (
+ thresholds.excessive_layback.infoAboveDeg >
+ thresholds.excessive_layback.warningAboveDeg
+ ) {
+ errors.push("Excessive-layback info angle must be at or below warning.");
+ }
+ if (
+ thresholds.slow_recovery_ratio.warningAboveRatio >=
+ thresholds.slow_recovery_ratio.criticalAboveRatio
+ ) {
+ errors.push("Slow-recovery warning ratio must be below critical ratio.");
+ }
+
+ for (const [key, value] of Object.entries(flattenThresholds(thresholds))) {
+ if (!Number.isFinite(value) || value < 0) {
+ errors.push(`${key} must be a non-negative number.`);
+ }
+ }
+
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
+}
+
+export function thresholdBandsEqual(
+ a: PostureThresholdBands,
+ b: PostureThresholdBands,
+): boolean {
+ const flatA = flattenThresholds(a);
+ const flatB = flattenThresholds(b);
+ return Object.keys(flatA).every((key) => flatA[key] === flatB[key]);
+}
+
+export function cloneThresholds(
+ thresholds: PostureThresholdBands,
+): PostureThresholdBands {
+ return {
+ rounded_back_at_catch: { ...thresholds.rounded_back_at_catch },
+ early_arm_bend: { ...thresholds.early_arm_bend },
+ back_opens_before_legs_drive: {
+ ...thresholds.back_opens_before_legs_drive,
+ },
+ excessive_layback: { ...thresholds.excessive_layback },
+ slow_recovery_ratio: { ...thresholds.slow_recovery_ratio },
+ };
+}
+
+export function isUserPostureThresholdSettings(
value: unknown,
): value is UserPostureThresholdSettings {
if (!value || typeof value !== "object") return false;
@@ -113,7 +208,8 @@ function isUserPostureThresholdSettings(
return (
typeof candidate.version === "string" &&
typeof candidate.userOverridden === "boolean" &&
- isPostureThresholdBands(candidate.thresholds)
+ isPostureThresholdBands(candidate.thresholds) &&
+ validatePostureThresholdBands(candidate.thresholds).valid
);
}
@@ -141,14 +237,27 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
-function cloneThresholds(thresholds: PostureThresholdBands): PostureThresholdBands {
+function flattenThresholds(
+ thresholds: PostureThresholdBands,
+): Record {
return {
- rounded_back_at_catch: { ...thresholds.rounded_back_at_catch },
- early_arm_bend: { ...thresholds.early_arm_bend },
- back_opens_before_legs_drive: {
- ...thresholds.back_opens_before_legs_drive,
- },
- excessive_layback: { ...thresholds.excessive_layback },
- slow_recovery_ratio: { ...thresholds.slow_recovery_ratio },
+ "rounded_back_at_catch.warningBelowDeg":
+ thresholds.rounded_back_at_catch.warningBelowDeg,
+ "rounded_back_at_catch.criticalBelowDeg":
+ thresholds.rounded_back_at_catch.criticalBelowDeg,
+ "early_arm_bend.infoBeforeLegsCompleteFrames":
+ thresholds.early_arm_bend.infoBeforeLegsCompleteFrames,
+ "early_arm_bend.warningBeforeLegsCompleteFrames":
+ thresholds.early_arm_bend.warningBeforeLegsCompleteFrames,
+ "back_opens_before_legs_drive.warningTorsoOpensBeforeLegsFrames":
+ thresholds.back_opens_before_legs_drive
+ .warningTorsoOpensBeforeLegsFrames,
+ "excessive_layback.infoAboveDeg": thresholds.excessive_layback.infoAboveDeg,
+ "excessive_layback.warningAboveDeg":
+ thresholds.excessive_layback.warningAboveDeg,
+ "slow_recovery_ratio.warningAboveRatio":
+ thresholds.slow_recovery_ratio.warningAboveRatio,
+ "slow_recovery_ratio.criticalAboveRatio":
+ thresholds.slow_recovery_ratio.criticalAboveRatio,
};
}
diff --git a/src/lib/mocap/browserPoseSource.ts b/src/lib/mocap/browserPoseSource.ts
index 5ccd5af..168f475 100644
--- a/src/lib/mocap/browserPoseSource.ts
+++ b/src/lib/mocap/browserPoseSource.ts
@@ -26,12 +26,20 @@ export type PoseSourceStatus =
| "error";
export interface PoseSourceOptions {
- sessionId: string;
+ sessionId?: string;
videoEl: HTMLVideoElement;
+ uploadPoseStream?: boolean;
flushBytes?: number;
flushIntervalMs?: number;
onStatus?: (s: PoseSourceStatus, detail?: string) => void;
- onFrame?: (info: { framesEncoded: number; landmarkCount: number }) => void;
+ onFrame?: (info: {
+ framesEncoded: number;
+ landmarkCount: number;
+ trackedKeypointCount: number;
+ meanConfidence: number;
+ qualityFlags: number;
+ poseFrameBase64: string;
+ }) => void;
onError?: (err: Error) => void;
}
@@ -47,10 +55,15 @@ export class BrowserPoseSource {
private status: PoseSourceStatus = "idle";
private readonly flushBytes: number;
private readonly flushIntervalMs: number;
+ private readonly uploadPoseStream: boolean;
constructor(private readonly opts: PoseSourceOptions) {
this.flushBytes = opts.flushBytes ?? BYTES_PER_FRAME_V1 * 12;
this.flushIntervalMs = opts.flushIntervalMs ?? 500;
+ this.uploadPoseStream = opts.uploadPoseStream ?? true;
+ if (this.uploadPoseStream && !opts.sessionId) {
+ throw new Error("sessionId is required when pose stream upload is enabled");
+ }
}
get framesCaptured(): number {
@@ -158,13 +171,20 @@ export class BrowserPoseSource {
const msg = event.data;
if (msg?.type === "frame") {
this.framesEncoded = msg.framesEncoded;
- this.pendingChunks.push(new Uint8Array(msg.bytes));
- this.pendingBytes += (msg.bytes as ArrayBuffer).byteLength;
+ const bytes = new Uint8Array(msg.bytes);
+ if (this.uploadPoseStream) {
+ this.pendingChunks.push(bytes);
+ this.pendingBytes += bytes.byteLength;
+ }
this.opts.onFrame?.({
framesEncoded: msg.framesEncoded,
landmarkCount: msg.landmarkCount,
+ trackedKeypointCount: msg.trackedKeypointCount,
+ meanConfidence: msg.meanConfidence,
+ qualityFlags: msg.qualityFlags,
+ poseFrameBase64: bytesToBase64(bytes),
});
- if (this.pendingBytes >= this.flushBytes) {
+ if (this.uploadPoseStream && this.pendingBytes >= this.flushBytes) {
this.flush(false);
}
} else if (msg?.type === "error") {
@@ -200,6 +220,9 @@ export class BrowserPoseSource {
}
private async upload(buf: Uint8Array): Promise {
+ if (!this.opts.sessionId) {
+ throw new Error("Cannot upload pose stream without a session id");
+ }
const res = await fetch(
`/api/mocap/sessions/${this.opts.sessionId}/pose-stream`,
{
@@ -220,3 +243,11 @@ export class BrowserPoseSource {
this.opts.onStatus?.(s, detail);
}
}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
diff --git a/src/lib/mocap/poseWorker.ts b/src/lib/mocap/poseWorker.ts
index 44f57ef..e71ac42 100644
--- a/src/lib/mocap/poseWorker.ts
+++ b/src/lib/mocap/poseWorker.ts
@@ -42,6 +42,9 @@ type FrameOut = {
framesEncoded: number;
timestampMs: number;
landmarkCount: number;
+ trackedKeypointCount: number;
+ meanConfidence: number;
+ qualityFlags: number;
};
type ErrorOut = { type: "error"; message: string };
@@ -52,10 +55,19 @@ let busy = false;
function buildKeypointsFromLandmarks(
landmarks: NormalizedLandmark[],
-): { keypoints: Float32Array; lowConfidence: boolean } {
+): {
+ keypoints: Float32Array;
+ lowConfidence: boolean;
+ meanConfidence: number;
+ trackedKeypointCount: number;
+ outOfFrame: boolean;
+} {
const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
let lowConfidence = false;
let lowConfidenceCount = 0;
+ let confidenceTotal = 0;
+ let trackedKeypointCount = 0;
+ let outOfFrameCount = 0;
const limit = Math.min(landmarks.length, KEYPOINTS_PER_FRAME_V1);
for (let i = 0; i < limit; i++) {
const lm = landmarks[i];
@@ -63,12 +75,21 @@ function buildKeypointsFromLandmarks(
keypoints[i * 3 + 0] = lm.x;
keypoints[i * 3 + 1] = lm.y;
keypoints[i * 3 + 2] = visibility;
+ confidenceTotal += visibility;
+ if (visibility >= 0.4) trackedKeypointCount++;
if (visibility < 0.4) lowConfidenceCount++;
+ if (lm.x < 0 || lm.x > 1 || lm.y < 0 || lm.y > 1) outOfFrameCount++;
}
if (lowConfidenceCount > KEYPOINTS_PER_FRAME_V1 * 0.3) {
lowConfidence = true;
}
- return { keypoints, lowConfidence };
+ return {
+ keypoints,
+ lowConfidence,
+ meanConfidence: confidenceTotal / KEYPOINTS_PER_FRAME_V1,
+ trackedKeypointCount,
+ outOfFrame: outOfFrameCount > KEYPOINTS_PER_FRAME_V1 * 0.2,
+ };
}
async function init(msg: InitMessage): Promise {
@@ -107,13 +128,18 @@ function processFrame(msg: FrameMessage): void {
let qualityFlags = 0;
let keypoints: Float32Array;
+ let meanConfidence = 0;
+ let trackedKeypointCount = 0;
if (landmarks.length === 0) {
keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
qualityFlags |= QUALITY_FLAG.OUT_OF_FRAME;
} else {
const built = buildKeypointsFromLandmarks(landmarks);
keypoints = built.keypoints;
+ meanConfidence = built.meanConfidence;
+ trackedKeypointCount = built.trackedKeypointCount;
if (built.lowConfidence) qualityFlags |= QUALITY_FLAG.LOW_CONFIDENCE;
+ if (built.outOfFrame) qualityFlags |= QUALITY_FLAG.OUT_OF_FRAME;
}
const frame: PoseFrame = {
@@ -132,6 +158,9 @@ function processFrame(msg: FrameMessage): void {
framesEncoded,
timestampMs: frame.timestampMs,
landmarkCount: landmarks.length,
+ trackedKeypointCount,
+ meanConfidence,
+ qualityFlags,
};
postMessage(out, [buf]);
} catch (err) {
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 07ad7f5..0d4f736 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -1,4 +1,9 @@
import { DEFAULT_AWARD_SUGGESTIONS_PROMPT } from '@/lib/aiPromptDefaults';
+import {
+ defaultPostureThresholdSettings,
+ resolvePostureThresholdSettings,
+ type UserPostureThresholdSettings
+} from '@/lib/mocap/analysis/postureThresholds';
export const AI_TEXT_MODELS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano'] as const;
@@ -100,6 +105,11 @@ export interface SmartRowSettings {
lastSync: Date | null;
}
+export interface MocapSettings {
+ postureThresholds: UserPostureThresholdSettings;
+ postureThresholdWarning: string | null;
+}
+
export interface UseCaseConfig {
reasoning: 'none' | 'low' | 'medium' | 'high';
verbosity: 'low' | 'medium' | 'high';
@@ -147,6 +157,7 @@ export interface Settings {
notificationSettings: NotificationSettings;
privacySettings: PrivacySettings;
smartRowSettings: SmartRowSettings;
+ mocapSettings: MocapSettings;
aiSettings: AISettings;
version: string; // For migration purposes
updatedAt: Date;
@@ -155,7 +166,7 @@ export interface Settings {
export class SettingsService {
private static instance: SettingsService;
private readonly STORAGE_KEY = 'rowing_app_settings';
- private readonly CURRENT_VERSION = '1.6.0'; // GPT-5.5 and GPT Image 2 model refresh
+ private readonly CURRENT_VERSION = '1.7.0'; // Mocap posture threshold settings
private dbInitialized = false;
private initPromise: Promise | null = null;
private syncTimeout: NodeJS.Timeout | null = null;
@@ -228,6 +239,10 @@ export class SettingsService {
password: '',
lastSync: null
},
+ mocapSettings: {
+ postureThresholds: defaultPostureThresholdSettings(),
+ postureThresholdWarning: null
+ },
aiSettings: {
openaiApiKey: '',
cloudAIEnabled: false,
@@ -434,6 +449,13 @@ Be specific and actionable. Only include information relevant to rowing training
const dbAiConfig = dbSettings.aiConfig as Record | null;
const needsColorFieldMigration = !dbAiConfig?.achievementImageColors;
+ if (dbSettings.postureThresholds) {
+ const resolved = resolvePostureThresholdSettings(dbSettings.postureThresholds);
+ if (resolved.warning) {
+ console.warn('[SETTINGS] Invalid postureThresholds from DB:', resolved.warning);
+ }
+ }
+
// Transform DB settings to app format and cache in localStorage
const appSettings = this.transformDBToAppSettings(dbSettings);
const migrated = this.migrateSettings(appSettings);
@@ -489,6 +511,16 @@ Be specific and actionable. Only include information relevant to rowing training
* Transform database settings format to app Settings format
*/
private transformDBToAppSettings(dbSettings: Record): Settings {
+ const resolvedPostureThresholds = resolvePostureThresholdSettings(
+ dbSettings.postureThresholds
+ );
+ if (resolvedPostureThresholds.warning) {
+ console.warn(
+ '[SETTINGS] Invalid postureThresholds from DB:',
+ resolvedPostureThresholds.warning
+ );
+ }
+
return {
userPreferences: {
theme: (dbSettings.theme as 'light' | 'dark' | 'system') || 'system',
@@ -537,6 +569,10 @@ Be specific and actionable. Only include information relevant to rowing training
privacySettings: this.defaultSettings.privacySettings,
// SmartRow credentials are NOT synced to DB - kept local only like API key
smartRowSettings: this.defaultSettings.smartRowSettings,
+ mocapSettings: {
+ postureThresholds: resolvedPostureThresholds.settings,
+ postureThresholdWarning: resolvedPostureThresholds.warning
+ },
aiSettings: {
...this.defaultSettings.aiSettings,
cloudAIEnabled: (dbSettings.cloudAIEnabled as boolean) || false,
@@ -610,6 +646,7 @@ Be specific and actionable. Only include information relevant to rowing training
maxTokens: settings.aiSettings.maxTokens,
userProfileContext: settings.aiSettings.userProfileContext,
userProfileRawInput: settings.aiSettings.userProfileRawInput,
+ postureThresholds: settings.mocapSettings.postureThresholds,
aiConfig: {
chat: settings.aiSettings.chat,
insights: settings.aiSettings.insights,
@@ -700,6 +737,10 @@ Be specific and actionable. Only include information relevant to rowing training
return this.getSettings().smartRowSettings;
}
+ getMocapSettings(): MocapSettings {
+ return this.getSettings().mocapSettings;
+ }
+
getAISettings(): AISettings {
return this.getSettings().aiSettings;
}
@@ -747,6 +788,20 @@ Be specific and actionable. Only include information relevant to rowing training
this.saveSettings(settings);
}
+ updateMocapSettings(updates: Partial): void {
+ const settings = this.getSettings();
+ settings.mocapSettings = {
+ ...settings.mocapSettings,
+ ...updates,
+ postureThresholdWarning:
+ updates.postureThresholdWarning === undefined
+ ? null
+ : updates.postureThresholdWarning
+ };
+ settings.updatedAt = new Date();
+ this.saveSettings(settings);
+ }
+
updateAISettings(updates: Partial): void {
const settings = this.getSettings();
settings.aiSettings = { ...settings.aiSettings, ...updates };
@@ -970,6 +1025,22 @@ Be specific and actionable. Only include information relevant to rowing training
...migratedSettings.smartRowSettings
};
+ const resolvedPostureThresholds = resolvePostureThresholdSettings(
+ migratedSettings.mocapSettings?.postureThresholds
+ );
+ if (resolvedPostureThresholds.warning) {
+ console.warn(
+ '[SETTINGS] Invalid stored posture thresholds:',
+ resolvedPostureThresholds.warning
+ );
+ }
+ migratedSettings.mocapSettings = {
+ ...this.defaultSettings.mocapSettings,
+ ...migratedSettings.mocapSettings,
+ postureThresholds: resolvedPostureThresholds.settings,
+ postureThresholdWarning: resolvedPostureThresholds.warning
+ };
+
// Handle AI settings migration from old flat structure to new nested structure
if (settings.aiSettings) {
const oldAiSettings = settings.aiSettings as AISettings & { model?: string; temperature?: number };
diff --git a/src/lib/validations/settings.ts b/src/lib/validations/settings.ts
index fc84ea2..035708e 100644
--- a/src/lib/validations/settings.ts
+++ b/src/lib/validations/settings.ts
@@ -75,6 +75,71 @@ const customPromptsAiSchema = z.record(z.string(), z.string());
// View settings schemas (flexible JSON objects)
const viewSettingsSchema = z.record(z.string(), z.unknown());
+const postureThresholdSettingsSchema = z.object({
+ version: z.string().min(1).max(20),
+ userOverridden: z.boolean(),
+ thresholds: z.object({
+ rounded_back_at_catch: z.object({
+ warningBelowDeg: z.number().min(0).max(180),
+ criticalBelowDeg: z.number().min(0).max(180),
+ }),
+ early_arm_bend: z.object({
+ infoBeforeLegsCompleteFrames: z.number().int().min(0).max(240),
+ warningBeforeLegsCompleteFrames: z.number().int().min(0).max(240),
+ }),
+ back_opens_before_legs_drive: z.object({
+ warningTorsoOpensBeforeLegsFrames: z.number().int().min(0).max(240),
+ }),
+ excessive_layback: z.object({
+ infoAboveDeg: z.number().min(0).max(180),
+ warningAboveDeg: z.number().min(0).max(180),
+ }),
+ slow_recovery_ratio: z.object({
+ warningAboveRatio: z.number().min(0).max(20),
+ criticalAboveRatio: z.number().min(0).max(20),
+ }),
+ }),
+}).superRefine((value, ctx) => {
+ const t = value.thresholds;
+ if (
+ t.rounded_back_at_catch.criticalBelowDeg >=
+ t.rounded_back_at_catch.warningBelowDeg
+ ) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Rounded-back critical angle must be below warning angle',
+ path: ['thresholds', 'rounded_back_at_catch', 'criticalBelowDeg'],
+ });
+ }
+ if (
+ t.early_arm_bend.infoBeforeLegsCompleteFrames >
+ t.early_arm_bend.warningBeforeLegsCompleteFrames
+ ) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Early-arm-bend info frame count must be at or below warning',
+ path: ['thresholds', 'early_arm_bend', 'infoBeforeLegsCompleteFrames'],
+ });
+ }
+ if (t.excessive_layback.infoAboveDeg > t.excessive_layback.warningAboveDeg) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Excessive-layback info angle must be at or below warning',
+ path: ['thresholds', 'excessive_layback', 'infoAboveDeg'],
+ });
+ }
+ if (
+ t.slow_recovery_ratio.warningAboveRatio >=
+ t.slow_recovery_ratio.criticalAboveRatio
+ ) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Slow-recovery warning ratio must be below critical ratio',
+ path: ['thresholds', 'slow_recovery_ratio', 'warningAboveRatio'],
+ });
+ }
+});
+
/**
* Main settings update schema
* All fields are optional since the API accepts partial updates
@@ -123,6 +188,7 @@ export const settingsUpdateSchema = z.object({
// User profile context
userProfileContext: z.string().max(50000).nullable(),
userProfileRawInput: z.string().max(100000).nullable(),
+ postureThresholds: postureThresholdSettingsSchema.nullable(),
// View settings (flexible JSON)
dashboardSettings: viewSettingsSchema,
diff --git a/tests/e2e/mocap-capture.spec.ts b/tests/e2e/mocap-capture.spec.ts
index b98709d..9707fae 100644
--- a/tests/e2e/mocap-capture.spec.ts
+++ b/tests/e2e/mocap-capture.spec.ts
@@ -33,6 +33,8 @@ test("capture page loads without prompting for camera", async ({ page }) => {
await page.goto("/mocap");
await expect(page.getByText("Motion capture session")).toBeVisible();
await expect(page.getByTestId("mocap-start")).toBeVisible();
+ await expect(page.getByTestId("mocap-start")).toBeDisabled();
+ await expect(page.getByTestId("mocap-start-calibration")).toBeVisible();
await expect(page.getByTestId("mocap-recording-indicator")).toHaveCount(0);
expect(mediaRequested).toBe(false);
});
diff --git a/tests/mocapAnalysis.test.ts b/tests/mocapAnalysis.test.ts
index 811d453..27ec533 100644
--- a/tests/mocapAnalysis.test.ts
+++ b/tests/mocapAnalysis.test.ts
@@ -10,6 +10,7 @@ import {
StrokePhaseSegmenter,
migratePostureThresholdSettings,
postureThresholdsV1,
+ resolvePostureThresholdSettings,
type PoseFrameStream,
type PostureFaultType,
} from "../src/lib/mocap/analysis";
@@ -175,6 +176,43 @@ test("threshold migration updates defaults but preserves user overrides", () =>
);
});
+test("relaxed posture thresholds emit strictly fewer fixture faults", () => {
+ const fixture = loadFixture("rounded-back-critical.json");
+ const stroke = StrokePhaseSegmenter(fixture.stream)[0];
+ const metrics = PostureMetricsCalculator(fixture.stream, stroke);
+ const defaultFaults = PostureFaultDetector(metrics);
+ const relaxedFaults = PostureFaultDetector(metrics, {
+ ...postureThresholdsV1.thresholds,
+ rounded_back_at_catch: {
+ warningBelowDeg: 0,
+ criticalBelowDeg: 0,
+ },
+ });
+
+ assert.ok(defaultFaults.length > relaxedFaults.length);
+});
+
+test("malformed posture thresholds fall back to defaults with warning", () => {
+ const resolved = resolvePostureThresholdSettings({
+ version: "V1",
+ userOverridden: true,
+ thresholds: {
+ ...postureThresholdsV1.thresholds,
+ slow_recovery_ratio: {
+ warningAboveRatio: "fast",
+ criticalAboveRatio: 3.5,
+ },
+ },
+ });
+
+ assert.equal(resolved.settings.userOverridden, false);
+ assert.equal(
+ resolved.settings.thresholds.slow_recovery_ratio.warningAboveRatio,
+ postureThresholdsV1.thresholds.slow_recovery_ratio.warningAboveRatio,
+ );
+ assert.match(resolved.warning ?? "", /malformed|invalid/);
+});
+
test("analysis modules remain pure TypeScript with no I/O imports", () => {
const analysisDir = path.join(here, "..", "src", "lib", "mocap", "analysis");
for (const fileName of readdirSync(analysisDir)) {
diff --git a/tests/poseFrameStreamAdapter.test.ts b/tests/poseFrameStreamAdapter.test.ts
new file mode 100644
index 0000000..cc18408
--- /dev/null
+++ b/tests/poseFrameStreamAdapter.test.ts
@@ -0,0 +1,113 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ BYTES_PER_FRAME_V1,
+ HEADER_SIZE,
+ KEYPOINTS_PER_FRAME_V1,
+ OPEN_FRAME_COUNT,
+ PoseStreamFormatError,
+ QUALITY_FLAG,
+ encodeFrame,
+ encodeHeader,
+ type PoseFrame,
+} from "../src/lib/mocap/poseFrameStream";
+import {
+ adaptPoseFrameStreamBlob,
+ adaptPoseFrameStreamBytes,
+} from "../src/lib/mocap/analysis/poseFrameStreamAdapter";
+import type { PosePoint } from "../src/lib/mocap/analysis/types";
+
+test("adapts a full binary PoseFrameStream blob to the analysis stream shape", () => {
+ const frames = [
+ makeFrame(0, 100, QUALITY_FLAG.LOW_CONFIDENCE),
+ makeFrame(1, 133.333, QUALITY_FLAG.CAMERA_MOTION),
+ ];
+ const blob = buildBlob(29.97, frames);
+
+ const stream = adaptPoseFrameStreamBlob(blob, "side-left");
+
+ assert.equal(stream.fps, Math.fround(29.97));
+ assert.equal(stream.capturePerspective, "side-left");
+ assert.equal(stream.frames.length, 2);
+ assert.equal(stream.frames[0]?.timestampMs, Math.fround(100));
+ assert.equal(stream.frames[0]?.qualityFlags, QUALITY_FLAG.LOW_CONFIDENCE);
+ assertFrameKeypoints(stream.frames[0]?.keypoints, frames[0].keypoints);
+ assert.equal(stream.frames[1]?.timestampMs, Math.fround(133.333));
+ assert.equal(stream.frames[1]?.qualityFlags, QUALITY_FLAG.CAMERA_MOTION);
+ assertFrameKeypoints(stream.frames[1]?.keypoints, frames[1].keypoints);
+});
+
+test("adapts split header bytes and packed frame bytes with open frame count", () => {
+ const frames = [makeFrame(3, 0, 0), makeFrame(4, 33.333, 7)];
+ const header = encodeHeader({ fps: 60, frameCount: OPEN_FRAME_COUNT });
+ const packedFrames = concat(frames.map(encodeFrame));
+
+ const stream = adaptPoseFrameStreamBytes(header, packedFrames, "side-right");
+
+ assert.equal(stream.fps, 60);
+ assert.equal(stream.capturePerspective, "side-right");
+ assert.equal(stream.frames.length, frames.length);
+ assert.equal(stream.frames[1]?.timestampMs, Math.fround(33.333));
+ assertFrameKeypoints(stream.frames[1]?.keypoints, frames[1].keypoints);
+});
+
+test("rejects unknown schema through the existing binary header decoder", () => {
+ const header = encodeHeader({ fps: 30, frameCount: 0 });
+ header[6] = 99;
+
+ assert.throws(
+ () => adaptPoseFrameStreamBytes(header, new Uint8Array(), "side-left"),
+ PoseStreamFormatError,
+ );
+});
+
+test("rejects packed frames with trailing partial frame bytes", () => {
+ const header = encodeHeader({ fps: 30, frameCount: OPEN_FRAME_COUNT });
+ const packedFrames = new Uint8Array(BYTES_PER_FRAME_V1 + 1);
+
+ assert.throws(
+ () => adaptPoseFrameStreamBytes(header, packedFrames, "sidecar-3d"),
+ /not a multiple of frame size/,
+ );
+});
+
+function makeFrame(seed: number, timestampMs: number, qualityFlags: number): PoseFrame {
+ const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
+ for (let i = 0; i < KEYPOINTS_PER_FRAME_V1; i++) {
+ keypoints[i * 3] = Math.fround(seed + i / 10);
+ keypoints[i * 3 + 1] = Math.fround(seed + i / 20);
+ keypoints[i * 3 + 2] = Math.fround((i % 10) / 10);
+ }
+ return { timestampMs, keypoints, qualityFlags };
+}
+
+function buildBlob(fps: number, frames: PoseFrame[]): Uint8Array {
+ const blob = new Uint8Array(HEADER_SIZE + frames.length * BYTES_PER_FRAME_V1);
+ blob.set(encodeHeader({ fps, frameCount: frames.length }), 0);
+ blob.set(concat(frames.map(encodeFrame)), HEADER_SIZE);
+ return blob;
+}
+
+function concat(chunks: Uint8Array[]): Uint8Array {
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return out;
+}
+
+function assertFrameKeypoints(
+ actual: unknown,
+ expected: Float32Array,
+): asserts actual is readonly PosePoint[] {
+ assert.ok(Array.isArray(actual));
+ assert.equal(actual.length, KEYPOINTS_PER_FRAME_V1);
+ for (let i = 0; i < KEYPOINTS_PER_FRAME_V1; i++) {
+ assert.equal(actual[i]?.x, expected[i * 3]);
+ assert.equal(actual[i]?.y, expected[i * 3 + 1]);
+ assert.equal(actual[i]?.confidence, expected[i * 3 + 2]);
+ }
+}
From 4fdc90902b5db8e95cde63c1a623bcefe4313333 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 12:35:57 +0200
Subject: [PATCH 07/29] Persist mocap post-session analysis rows
---
.../migration.sql | 39 +++++++
prisma/schema.prisma | 43 ++++++-
.../api/mocap/sessions/[id]/finalize/route.ts | 26 ++++-
src/app/api/mocap/sessions/[id]/route.ts | 8 ++
src/lib/mocap/analysis/index.ts | 2 +
src/lib/mocap/analysis/postSessionAnalysis.ts | 106 ++++++++++++++++++
src/lib/mocap/sessionAnalysis.ts | 83 ++++++++++++++
tests/mocapAnalysis.test.ts | 23 ++++
8 files changed, 326 insertions(+), 4 deletions(-)
create mode 100644 prisma/migrations/20260508150000_add_mocap_derived_rows/migration.sql
create mode 100644 src/lib/mocap/analysis/postSessionAnalysis.ts
create mode 100644 src/lib/mocap/sessionAnalysis.ts
diff --git a/prisma/migrations/20260508150000_add_mocap_derived_rows/migration.sql b/prisma/migrations/20260508150000_add_mocap_derived_rows/migration.sql
new file mode 100644
index 0000000..45297e2
--- /dev/null
+++ b/prisma/migrations/20260508150000_add_mocap_derived_rows/migration.sql
@@ -0,0 +1,39 @@
+-- Persist post-session mocap analysis outputs.
+
+CREATE TABLE "StrokePostureMetric" (
+ "id" TEXT NOT NULL,
+ "mocapSessionId" TEXT NOT NULL,
+ "strokeIndex" INTEGER NOT NULL,
+ "phaseBoundariesJson" JSONB NOT NULL,
+ "metricsJson" JSONB NOT NULL,
+ "segmentationSource" TEXT NOT NULL,
+ "strokeDataId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "StrokePostureMetric_pkey" PRIMARY KEY ("id")
+);
+
+CREATE TABLE "PostureFault" (
+ "id" TEXT NOT NULL,
+ "mocapSessionId" TEXT NOT NULL,
+ "strokeIndex" INTEGER NOT NULL,
+ "faultType" TEXT NOT NULL,
+ "severity" TEXT NOT NULL,
+ "phase" TEXT NOT NULL,
+ "evidenceJson" JSONB NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PostureFault_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "StrokePostureMetric_mocapSessionId_strokeIndex_segmentationSource_key" ON "StrokePostureMetric"("mocapSessionId", "strokeIndex", "segmentationSource");
+CREATE INDEX "StrokePostureMetric_mocapSessionId_idx" ON "StrokePostureMetric"("mocapSessionId");
+CREATE INDEX "StrokePostureMetric_strokeDataId_idx" ON "StrokePostureMetric"("strokeDataId");
+CREATE INDEX "PostureFault_mocapSessionId_idx" ON "PostureFault"("mocapSessionId");
+CREATE INDEX "PostureFault_mocapSessionId_strokeIndex_idx" ON "PostureFault"("mocapSessionId", "strokeIndex");
+CREATE INDEX "PostureFault_faultType_severity_idx" ON "PostureFault"("faultType", "severity");
+
+ALTER TABLE "StrokePostureMetric" ADD CONSTRAINT "StrokePostureMetric_mocapSessionId_fkey" FOREIGN KEY ("mocapSessionId") REFERENCES "MocapSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "StrokePostureMetric" ADD CONSTRAINT "StrokePostureMetric_strokeDataId_fkey" FOREIGN KEY ("strokeDataId") REFERENCES "StrokeData"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "PostureFault" ADD CONSTRAINT "PostureFault_mocapSessionId_fkey" FOREIGN KEY ("mocapSessionId") REFERENCES "MocapSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 32abc89..69a3885 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -153,6 +153,7 @@ model StrokeData {
strokeLength Float?
session RowingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
+ mocapMetrics StrokePostureMetric[]
@@index([sessionId])
@@index([sessionId, strokeIndex])
@@ -200,13 +201,51 @@ model MocapSession {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- rowingSession RowingSession? @relation(fields: [rowingSessionId], references: [id], onDelete: SetNull)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ rowingSession RowingSession? @relation(fields: [rowingSessionId], references: [id], onDelete: SetNull)
+ strokePostureMetrics StrokePostureMetric[]
+ postureFaults PostureFault[]
@@index([userId])
@@index([userId, createdAt])
}
+model StrokePostureMetric {
+ id String @id @default(cuid())
+ mocapSessionId String
+ strokeIndex Int
+ phaseBoundariesJson Json
+ metricsJson Json
+ segmentationSource String
+ strokeDataId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ mocapSession MocapSession @relation(fields: [mocapSessionId], references: [id], onDelete: Cascade)
+ strokeData StrokeData? @relation(fields: [strokeDataId], references: [id], onDelete: SetNull)
+
+ @@unique([mocapSessionId, strokeIndex, segmentationSource])
+ @@index([mocapSessionId])
+ @@index([strokeDataId])
+}
+
+model PostureFault {
+ id String @id @default(cuid())
+ mocapSessionId String
+ strokeIndex Int
+ faultType String
+ severity String
+ phase String
+ evidenceJson Json
+ createdAt DateTime @default(now())
+
+ mocapSession MocapSession @relation(fields: [mocapSessionId], references: [id], onDelete: Cascade)
+
+ @@index([mocapSessionId])
+ @@index([mocapSessionId, strokeIndex])
+ @@index([faultType, severity])
+}
+
// ============================================================================
// AWARDS & ACHIEVEMENTS
// ============================================================================
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
index f96d3b9..6f5fc79 100644
--- a/src/app/api/mocap/sessions/[id]/finalize/route.ts
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db/prisma";
import { getMocapStorage } from "@/lib/mocap/storage";
import { finalizePoseStreamBlob } from "@/lib/mocap/capturePersistence";
+import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -29,9 +30,13 @@ export async function POST(
where: { id, userId: session.user.id },
select: {
id: true,
+ userId: true,
status: true,
poseStreamPath: true,
videoStoragePath: true,
+ capturePerspective: true,
+ calibrationCatchFrame: true,
+ calibrationFinishFrame: true,
},
});
if (!row) {
@@ -69,21 +74,38 @@ export async function POST(
);
}
- const updated = await prisma.mocapSession.update({
+ const analyzing = await prisma.mocapSession.update({
where: { id: row.id },
data: {
- status: "ready",
+ status: "analyzing",
durationSec: body.durationSec,
qualityScore: body.qualityScore ?? null,
qualityFlags: body.qualityFlags ?? [],
},
});
+ let analysis: Awaited>;
+ try {
+ analysis = await analyzeAndPersistMocapSession(storage, analyzing);
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
+ const updated = await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: { status: "ready" },
+ });
+
return NextResponse.json({
id: updated.id,
status: updated.status,
durationSec: updated.durationSec,
frameCount: finalized.frameCount,
poseStreamBytes: finalized.poseStreamBytes,
+ strokeMetricCount: analysis.strokeMetricCount,
+ faultCount: analysis.faultCount,
});
}
diff --git a/src/app/api/mocap/sessions/[id]/route.ts b/src/app/api/mocap/sessions/[id]/route.ts
index dbb8302..cc41a5e 100644
--- a/src/app/api/mocap/sessions/[id]/route.ts
+++ b/src/app/api/mocap/sessions/[id]/route.ts
@@ -16,6 +16,14 @@ export async function GET(
const { id } = await params;
const row = await prisma.mocapSession.findFirst({
where: { id, userId: session.user.id },
+ include: {
+ strokePostureMetrics: {
+ orderBy: { strokeIndex: "asc" },
+ },
+ postureFaults: {
+ orderBy: [{ strokeIndex: "asc" }, { createdAt: "asc" }],
+ },
+ },
});
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
diff --git a/src/lib/mocap/analysis/index.ts b/src/lib/mocap/analysis/index.ts
index 7939373..ea9df30 100644
--- a/src/lib/mocap/analysis/index.ts
+++ b/src/lib/mocap/analysis/index.ts
@@ -3,3 +3,5 @@ export * from "./strokePhaseSegmenter";
export * from "./postureMetrics";
export * from "./postureFaultDetector";
export * from "./postureThresholds";
+export * from "./poseFrameStreamAdapter";
+export * from "./postSessionAnalysis";
diff --git a/src/lib/mocap/analysis/postSessionAnalysis.ts b/src/lib/mocap/analysis/postSessionAnalysis.ts
new file mode 100644
index 0000000..82bd760
--- /dev/null
+++ b/src/lib/mocap/analysis/postSessionAnalysis.ts
@@ -0,0 +1,106 @@
+import { PostureFaultDetector } from "./postureFaultDetector";
+import { PostureMetricsCalculator } from "./postureMetrics";
+import { StrokePhaseSegmenter } from "./strokePhaseSegmenter";
+import type {
+ Calibration,
+ PoseFrameStream,
+ PostureFault,
+ PostureMetrics,
+ Stroke,
+} from "./types";
+import { postureThresholdsV1, type PostureThresholdBands } from "./postureThresholds";
+
+export interface DerivedStrokePostureMetric {
+ strokeIndex: number;
+ phaseBoundariesJson: StrokePhaseBoundariesJson;
+ metricsJson: PostureMetricsJson;
+ segmentationSource: string;
+}
+
+export interface DerivedPostureFault {
+ strokeIndex: number;
+ faultType: string;
+ severity: string;
+ phase: string;
+ evidenceJson: PostureFault["evidence"];
+}
+
+export interface PostSessionAnalysisResult {
+ metrics: DerivedStrokePostureMetric[];
+ faults: DerivedPostureFault[];
+}
+
+export interface StrokePhaseBoundariesJson {
+ catchFrameIndex: number;
+ driveStartFrameIndex: number;
+ finishFrameIndex: number;
+ recoveryStartFrameIndex: number;
+ nextCatchFrameIndex: number;
+ confidence: number;
+}
+
+export type PostureMetricsJson = Omit<
+ PostureMetrics,
+ "strokeIndex" | "segmentationSource"
+>;
+
+export function analyzePoseFrameStream(
+ stream: PoseFrameStream,
+ opts: {
+ calibration?: Calibration;
+ thresholds?: PostureThresholdBands;
+ } = {},
+): PostSessionAnalysisResult {
+ const thresholds = opts.thresholds ?? postureThresholdsV1.thresholds;
+ const strokes = StrokePhaseSegmenter(stream);
+ const metrics: DerivedStrokePostureMetric[] = [];
+ const faults: DerivedPostureFault[] = [];
+
+ for (const stroke of strokes) {
+ const postureMetrics = PostureMetricsCalculator(
+ stream,
+ stroke,
+ opts.calibration,
+ );
+ metrics.push(metricToDerivedRow(stroke, postureMetrics));
+ for (const fault of PostureFaultDetector(postureMetrics, thresholds)) {
+ faults.push(faultToDerivedRow(fault));
+ }
+ }
+
+ return { metrics, faults };
+}
+
+function metricToDerivedRow(
+ stroke: Stroke,
+ metrics: PostureMetrics,
+): DerivedStrokePostureMetric {
+ const {
+ strokeIndex,
+ segmentationSource,
+ ...metricsJson
+ } = metrics;
+ return {
+ strokeIndex,
+ segmentationSource,
+ phaseBoundariesJson: {
+ catchFrameIndex: stroke.catchFrameIndex,
+ driveStartFrameIndex: stroke.driveStartFrameIndex,
+ finishFrameIndex: stroke.finishFrameIndex,
+ recoveryStartFrameIndex: stroke.recoveryStartFrameIndex,
+ nextCatchFrameIndex: stroke.nextCatchFrameIndex,
+ confidence: stroke.confidence,
+ },
+ metricsJson,
+ };
+}
+
+function faultToDerivedRow(fault: PostureFault): DerivedPostureFault {
+ return {
+ strokeIndex: fault.strokeIndex,
+ faultType: fault.faultType,
+ severity: fault.severity,
+ phase: fault.phase,
+ evidenceJson: fault.evidence,
+ };
+}
diff --git a/src/lib/mocap/sessionAnalysis.ts b/src/lib/mocap/sessionAnalysis.ts
new file mode 100644
index 0000000..1c84548
--- /dev/null
+++ b/src/lib/mocap/sessionAnalysis.ts
@@ -0,0 +1,83 @@
+import type { Prisma } from "@prisma/client";
+import { prisma } from "@/lib/db/prisma";
+import {
+ adaptPoseFrameStreamBlob,
+ analyzePoseFrameStream,
+ resolvePostureThresholdSettings,
+ type CapturePerspective,
+} from "@/lib/mocap/analysis";
+import type { MocapStorage } from "@/lib/mocap/storage";
+
+export interface PersistedMocapAnalysisSummary {
+ strokeMetricCount: number;
+ faultCount: number;
+}
+
+type AnalyzeSessionInput = {
+ id: string;
+ userId: string;
+ poseStreamPath: string;
+ capturePerspective: string;
+ calibrationCatchFrame?: Prisma.JsonValue | null;
+ calibrationFinishFrame?: Prisma.JsonValue | null;
+};
+
+export async function analyzeAndPersistMocapSession(
+ storage: MocapStorage,
+ session: AnalyzeSessionInput,
+): Promise {
+ const [poseBlob, userSettings] = await Promise.all([
+ storage.read(session.poseStreamPath),
+ prisma.userSettings.findUnique({
+ where: { userId: session.userId },
+ select: { postureThresholds: true },
+ }),
+ ]);
+
+ const thresholds = resolvePostureThresholdSettings(
+ userSettings?.postureThresholds,
+ ).settings.thresholds;
+ const stream = adaptPoseFrameStreamBlob(
+ poseBlob,
+ session.capturePerspective as CapturePerspective,
+ );
+ const result = analyzePoseFrameStream(stream, { thresholds });
+
+ await prisma.$transaction(async (tx) => {
+ await tx.postureFault.deleteMany({
+ where: { mocapSessionId: session.id },
+ });
+ await tx.strokePostureMetric.deleteMany({
+ where: { mocapSessionId: session.id },
+ });
+ if (result.metrics.length > 0) {
+ await tx.strokePostureMetric.createMany({
+ data: result.metrics.map((metric) => ({
+ mocapSessionId: session.id,
+ strokeIndex: metric.strokeIndex,
+ phaseBoundariesJson:
+ metric.phaseBoundariesJson as unknown as Prisma.InputJsonValue,
+ metricsJson: metric.metricsJson as unknown as Prisma.InputJsonValue,
+ segmentationSource: metric.segmentationSource,
+ })),
+ });
+ }
+ if (result.faults.length > 0) {
+ await tx.postureFault.createMany({
+ data: result.faults.map((fault) => ({
+ mocapSessionId: session.id,
+ strokeIndex: fault.strokeIndex,
+ faultType: fault.faultType,
+ severity: fault.severity,
+ phase: fault.phase,
+ evidenceJson: fault.evidenceJson as unknown as Prisma.InputJsonValue,
+ })),
+ });
+ }
+ });
+
+ return {
+ strokeMetricCount: result.metrics.length,
+ faultCount: result.faults.length,
+ };
+}
diff --git a/tests/mocapAnalysis.test.ts b/tests/mocapAnalysis.test.ts
index 27ec533..ef98874 100644
--- a/tests/mocapAnalysis.test.ts
+++ b/tests/mocapAnalysis.test.ts
@@ -8,6 +8,7 @@ import {
PostureFaultDetector,
PostureMetricsCalculator,
StrokePhaseSegmenter,
+ analyzePoseFrameStream,
migratePostureThresholdSettings,
postureThresholdsV1,
resolvePostureThresholdSettings,
@@ -122,6 +123,28 @@ test("crafted fault fixtures trigger exactly the expected v1 fault", () => {
}
});
+test("post-session analysis maps strokes to derived metric and fault rows", () => {
+ const clean = loadFixture("clean-reference.json");
+ const cleanResult = analyzePoseFrameStream(clean.stream);
+ assert.equal(cleanResult.metrics.length, clean.expected.strokeCount);
+ assert.equal(cleanResult.faults.length, 0);
+ assert.equal(cleanResult.metrics[0]?.segmentationSource, "pose-segmented");
+ assert.equal(
+ cleanResult.metrics[0]?.phaseBoundariesJson.catchFrameIndex,
+ clean.expected.boundaries[0]?.catchFrameIndex,
+ );
+
+ const rounded = loadFixture("rounded-back-critical.json");
+ const roundedResult = analyzePoseFrameStream(rounded.stream);
+ assert.ok(
+ roundedResult.faults.some(
+ (fault) =>
+ fault.faultType === rounded.expected.faults[0]?.faultType &&
+ fault.severity === rounded.expected.faults[0]?.severity,
+ ),
+ );
+});
+
test("fault detector never emits outside the v1 catalog", () => {
const catalog = new Set(POSTURE_FAULT_CATALOG_V1);
for (const fixture of loadFixtures()) {
From 77603ac63922c6d6a1db2bd624eb90e4415e91e6 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 14:59:28 +0200
Subject: [PATCH 08/29] =?UTF-8?q?feat(mocap):=20implement=20issues=20#13?=
=?UTF-8?q?=E2=80=93#17=20=E2=80=94=20replay=20UI,=20lifecycle=20ops,=20li?=
=?UTF-8?q?nking,=20AI=20payload=20tiers?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- #13: session list + replay pages, video streaming with range support, skeleton overlay, timeline/fault markers, freeze-at-catch/finish
- #15: link/unlink MocapSession↔RowingSession API, csv-aligned re-analysis, auto-prompt on CSV import overlap, has-mocap badge in session list
- #16: reanalyze API, record-only capture toggle (skip analysis), delete/reanalyze buttons in session list, run-analysis CTA in replay empty state
- #17: PostureAIPayload tiers (tier 3 fault summary default, tier 2 metrics opt-in, tier 1 raw hard-walled), mocapDetailedAIShare setting, aiAnalysis.ts integration, hard-guard unit test
Co-Authored-By: Claude Sonnet 4.6
---
.../migration.sql | 2 +
prisma/schema.prisma | 1 +
.../api/mocap/sessions/[id]/finalize/route.ts | 22 +
.../[id]/link/[rowingSessionId]/route.ts | 111 +++
.../mocap/sessions/[id]/reanalyze/route.ts | 77 ++
.../api/mocap/sessions/[id]/unlink/route.ts | 87 +++
.../api/mocap/sessions/[id]/video/route.ts | 79 +++
src/app/api/mocap/sessions/route.ts | 29 +
src/app/api/sessions/list/route.ts | 3 +
src/app/mocap/page.tsx | 31 +-
src/app/mocap/sessions/[id]/page.tsx | 659 ++++++++++++++++++
src/app/mocap/sessions/page.tsx | 211 ++++++
src/app/sessions/page.tsx | 18 +-
src/lib/mocap/aiPayload.ts | 107 +++
src/lib/mocap/sessionAnalysis.ts | 79 +++
src/lib/settings.ts | 4 +
src/types/session.ts | 1 +
17 files changed, 1519 insertions(+), 2 deletions(-)
create mode 100644 prisma/migrations/20260508160000_add_mocap_detailed_ai_share/migration.sql
create mode 100644 src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/reanalyze/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/unlink/route.ts
create mode 100644 src/app/mocap/sessions/[id]/page.tsx
create mode 100644 src/app/mocap/sessions/page.tsx
create mode 100644 src/lib/mocap/aiPayload.ts
diff --git a/prisma/migrations/20260508160000_add_mocap_detailed_ai_share/migration.sql b/prisma/migrations/20260508160000_add_mocap_detailed_ai_share/migration.sql
new file mode 100644
index 0000000..7163065
--- /dev/null
+++ b/prisma/migrations/20260508160000_add_mocap_detailed_ai_share/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "UserSettings" ADD COLUMN "mocapDetailedAIShare" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 69a3885..f7a6059 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -508,6 +508,7 @@ model UserSettings {
planReminders Boolean @default(true)
adherenceAlerts Boolean @default(true)
cloudAIEnabled Boolean @default(false)
+ mocapDetailedAIShare Boolean @default(false)
maxTokens Int @default(1500)
aiConfig Json?
customPromptsAi Json?
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
index 6f5fc79..ba5f29c 100644
--- a/src/app/api/mocap/sessions/[id]/finalize/route.ts
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -14,6 +14,7 @@ const Body = z.object({
durationSec: z.number().nonnegative().max(60 * 60 * 8),
qualityScore: z.number().min(0).max(1).optional(),
qualityFlags: z.array(z.string().min(1).max(80)).max(20).optional(),
+ skipAnalysis: z.boolean().optional(),
});
export async function POST(
@@ -74,6 +75,27 @@ export async function POST(
);
}
+ if (body.skipAnalysis) {
+ const updated = await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: {
+ status: "ready",
+ durationSec: body.durationSec,
+ qualityScore: body.qualityScore ?? null,
+ qualityFlags: body.qualityFlags ?? [],
+ },
+ });
+ return NextResponse.json({
+ id: updated.id,
+ status: updated.status,
+ durationSec: updated.durationSec,
+ frameCount: finalized.frameCount,
+ poseStreamBytes: finalized.poseStreamBytes,
+ strokeMetricCount: 0,
+ faultCount: 0,
+ });
+ }
+
const analyzing = await prisma.mocapSession.update({
where: { id: row.id },
data: {
diff --git a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
new file mode 100644
index 0000000..b9d65dd
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
@@ -0,0 +1,111 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import { analyzeAndPersistMocapSessionLinked } from "@/lib/mocap/sessionAnalysis";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string; rowingSessionId: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id, rowingSessionId } = await params;
+ const userId = session.user.id;
+
+ // Validate: MocapSession belongs to user and is "ready"
+ const mocapSession = await prisma.mocapSession.findFirst({
+ where: { id, userId },
+ select: {
+ id: true,
+ userId: true,
+ status: true,
+ rowingSessionId: true,
+ poseStreamPath: true,
+ capturePerspective: true,
+ calibrationCatchFrame: true,
+ calibrationFinishFrame: true,
+ },
+ });
+
+ if (!mocapSession) {
+ return NextResponse.json({ error: "Mocap session not found" }, { status: 404 });
+ }
+
+ if (mocapSession.status !== "ready") {
+ return NextResponse.json(
+ { error: `Mocap session not ready (status=${mocapSession.status})` },
+ { status: 409 },
+ );
+ }
+
+ // Enforce 1:1 — reject if mocap session already linked
+ if (mocapSession.rowingSessionId !== null) {
+ return NextResponse.json(
+ { error: "Mocap session is already linked to a rowing session. Unlink first." },
+ { status: 409 },
+ );
+ }
+
+ // Validate: RowingSession belongs to user
+ const rowingSession = await prisma.rowingSession.findFirst({
+ where: { id: rowingSessionId, userId },
+ select: {
+ id: true,
+ mocapSession: { select: { id: true } },
+ },
+ });
+
+ if (!rowingSession) {
+ return NextResponse.json({ error: "Rowing session not found" }, { status: 404 });
+ }
+
+ // Enforce 1:1 — reject if rowing session already linked to another MocapSession
+ if (rowingSession.mocapSession !== null) {
+ return NextResponse.json(
+ { error: "Rowing session is already linked to another mocap session." },
+ { status: 409 },
+ );
+ }
+
+ // Atomically create the link and set status to "analyzing"
+ await prisma.mocapSession.update({
+ where: { id },
+ data: { rowingSessionId, status: "analyzing" },
+ });
+
+ const storage = getMocapStorage();
+
+ try {
+ await analyzeAndPersistMocapSessionLinked(storage, mocapSession, rowingSessionId);
+ } catch (err) {
+ // Roll back: clear the link and revert status
+ await prisma.mocapSession.update({
+ where: { id },
+ data: { rowingSessionId: null, status: "ready" },
+ });
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
+ const updated = await prisma.mocapSession.update({
+ where: { id },
+ data: { status: "ready" },
+ select: { id: true, rowingSessionId: true, status: true },
+ });
+
+ return NextResponse.json({
+ id: updated.id,
+ rowingSessionId: updated.rowingSessionId,
+ status: updated.status,
+ });
+}
diff --git a/src/app/api/mocap/sessions/[id]/reanalyze/route.ts b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts
new file mode 100644
index 0000000..98a93ee
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts
@@ -0,0 +1,77 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: {
+ id: true,
+ userId: true,
+ status: true,
+ poseStreamPath: true,
+ videoStoragePath: true,
+ capturePerspective: true,
+ calibrationCatchFrame: true,
+ calibrationFinishFrame: true,
+ },
+ });
+ if (!row) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (row.status !== "ready") {
+ return NextResponse.json(
+ { error: `Session not ready (status=${row.status})` },
+ { status: 409 },
+ );
+ }
+
+ await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: { status: "analyzing" },
+ });
+
+ const storage = getMocapStorage();
+
+ let analysis: Awaited>;
+ try {
+ analysis = await analyzeAndPersistMocapSession(storage, row);
+ } catch (err) {
+ // Revert status so the session stays usable
+ await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: { status: "ready" },
+ });
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
+ const updated = await prisma.mocapSession.update({
+ where: { id: row.id },
+ data: { status: "ready" },
+ });
+
+ return NextResponse.json({
+ id: updated.id,
+ status: updated.status,
+ strokeMetricCount: analysis.strokeMetricCount,
+ faultCount: analysis.faultCount,
+ });
+}
diff --git a/src/app/api/mocap/sessions/[id]/unlink/route.ts b/src/app/api/mocap/sessions/[id]/unlink/route.ts
new file mode 100644
index 0000000..8c58e42
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/unlink/route.ts
@@ -0,0 +1,87 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { getMocapStorage } from "@/lib/mocap/storage";
+import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const userId = session.user.id;
+
+ const mocapSession = await prisma.mocapSession.findFirst({
+ where: { id, userId },
+ select: {
+ id: true,
+ userId: true,
+ status: true,
+ rowingSessionId: true,
+ poseStreamPath: true,
+ capturePerspective: true,
+ calibrationCatchFrame: true,
+ calibrationFinishFrame: true,
+ },
+ });
+
+ if (!mocapSession) {
+ return NextResponse.json({ error: "Mocap session not found" }, { status: 404 });
+ }
+
+ if (mocapSession.status !== "ready") {
+ return NextResponse.json(
+ { error: `Mocap session not ready (status=${mocapSession.status})` },
+ { status: 409 },
+ );
+ }
+
+ const previousRowingSessionId = mocapSession.rowingSessionId;
+
+ // Clear the link and set status to "analyzing"
+ await prisma.mocapSession.update({
+ where: { id },
+ data: { rowingSessionId: null, status: "analyzing" },
+ });
+
+ const storage = getMocapStorage();
+
+ try {
+ // Re-run pose-segmented analysis, which sets segmentationSource = "pose-segmented"
+ // and clears strokeDataId (analyzeAndPersistMocapSession does not set strokeDataId)
+ await analyzeAndPersistMocapSession(storage, mocapSession);
+ } catch (err) {
+ // Roll back: restore the previous link and revert status
+ await prisma.mocapSession.update({
+ where: { id },
+ data: {
+ rowingSessionId: previousRowingSessionId ?? null,
+ status: "ready",
+ },
+ });
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
+ const updated = await prisma.mocapSession.update({
+ where: { id },
+ data: { status: "ready" },
+ select: { id: true, status: true },
+ });
+
+ return NextResponse.json({
+ id: updated.id,
+ status: updated.status,
+ });
+}
diff --git a/src/app/api/mocap/sessions/[id]/video/route.ts b/src/app/api/mocap/sessions/[id]/video/route.ts
index a8e3c0b..fa61714 100644
--- a/src/app/api/mocap/sessions/[id]/video/route.ts
+++ b/src/app/api/mocap/sessions/[id]/video/route.ts
@@ -7,6 +7,85 @@ import { getMocapStorage } from "@/lib/mocap/storage";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+function parseRange(
+ header: string | null,
+ totalSize: number,
+): { start: number; end: number } | null {
+ if (!header) return null;
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!m) return null;
+ const startStr = m[1];
+ const endStr = m[2];
+ if (startStr === "" && endStr === "") return null;
+ let start: number;
+ let end: number;
+ if (startStr === "") {
+ const suffix = parseInt(endStr, 10);
+ if (!Number.isFinite(suffix) || suffix <= 0) return null;
+ start = Math.max(0, totalSize - suffix);
+ end = totalSize - 1;
+ } else {
+ start = parseInt(startStr, 10);
+ end = endStr === "" ? totalSize - 1 : parseInt(endStr, 10);
+ }
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= totalSize) {
+ return null;
+ }
+ end = Math.min(end, totalSize - 1);
+ return { start, end };
+}
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const row = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { videoStoragePath: true, status: true },
+ });
+ if (!row || row.status === "capturing") {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const storage = getMocapStorage();
+ const totalSize = await storage.size(row.videoStoragePath);
+ const range = parseRange(req.headers.get("range"), totalSize);
+
+ if (!range) {
+ const bytes = await storage.read(row.videoStoragePath);
+ return new Response(new Uint8Array(bytes) as BodyInit, {
+ status: 200,
+ headers: {
+ "Content-Type": "video/webm",
+ "Content-Length": String(totalSize),
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "private, no-store",
+ },
+ });
+ }
+
+ const slice = await storage.read(row.videoStoragePath, {
+ start: range.start,
+ end: range.end + 1,
+ });
+ return new Response(new Uint8Array(slice) as BodyInit, {
+ status: 206,
+ headers: {
+ "Content-Type": "video/webm",
+ "Content-Length": String(slice.byteLength),
+ "Content-Range": `bytes ${range.start}-${range.end}/${totalSize}`,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "private, no-store",
+ },
+ });
+}
+
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
index 76c07e5..90af0ea 100644
--- a/src/app/api/mocap/sessions/route.ts
+++ b/src/app/api/mocap/sessions/route.ts
@@ -6,6 +6,35 @@ import { prisma } from "@/lib/db/prisma";
import { getMocapStorage } from "@/lib/mocap/storage";
import { initializePoseStreamBlob } from "@/lib/mocap/capturePersistence";
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const rows = await prisma.mocapSession.findMany({
+ where: { userId: session.user.id },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ status: true,
+ durationSec: true,
+ createdAt: true,
+ capturePerspective: true,
+ qualityScore: true,
+ qualityFlags: true,
+ _count: {
+ select: {
+ strokePostureMetrics: true,
+ postureFaults: true,
+ },
+ },
+ },
+ });
+
+ return NextResponse.json({ sessions: rows });
+}
+
const CalibrationFrame = z.object({
pose: z.enum(["catch", "finish"]),
capturedAt: z.string().datetime(),
diff --git a/src/app/api/sessions/list/route.ts b/src/app/api/sessions/list/route.ts
index 44e7e53..f02321c 100644
--- a/src/app/api/sessions/list/route.ts
+++ b/src/app/api/sessions/list/route.ts
@@ -51,6 +51,9 @@ export async function GET() {
updatedAt: true,
importedAt: true,
sourceFile: true,
+ mocapSession: {
+ select: { id: true },
+ },
},
orderBy: {
timestamp: 'desc',
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index bc09e4e..3a3e1df 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -112,6 +112,7 @@ export default function MocapCapturePage() {
const [quality, setQuality] = useState(EMPTY_QUALITY);
const [framingDegraded, setFramingDegraded] = useState(false);
const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
+ const [recordOnly, setRecordOnly] = useState(false);
useEffect(() => {
if (state.kind !== "capturing") return;
@@ -410,6 +411,7 @@ export default function MocapCapturePage() {
durationSec,
qualityScore: qualityScoreFor(latestPoseFrameRef.current),
qualityFlags: sessionQualityFlags,
+ skipAnalysis: recordOnly,
}),
},
);
@@ -524,6 +526,16 @@ export default function MocapCapturePage() {
Side (left toward camera)
+
+ setRecordOnly(e.target.checked)}
+ disabled={state.kind === "capturing" || state.kind === "starting" || state.kind === "stopping"}
+ data-testid="mocap-record-only"
+ />
+ Record only (skip analysis)
+
{calibration.kind === "idle" &&
(state.kind === "idle" || state.kind === "done") ? (
@@ -674,6 +686,23 @@ export default function MocapCapturePage() {
{state.frameCount} pose frames · {state.durationSec.toFixed(1)}s
duration
+
) : null}
diff --git a/src/app/mocap/sessions/[id]/page.tsx b/src/app/mocap/sessions/[id]/page.tsx
new file mode 100644
index 0000000..1870710
--- /dev/null
+++ b/src/app/mocap/sessions/[id]/page.tsx
@@ -0,0 +1,659 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useParams } from "next/navigation";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ HEADER_SIZE,
+ BYTES_PER_FRAME_V1,
+ KEYPOINTS_PER_FRAME_V1,
+ decodeHeader,
+ decodeFrame,
+ frameByteOffset,
+ type PoseStreamHeader,
+} from "@/lib/mocap/poseFrameStream";
+
+// MediaPipe 33-keypoint skeleton connections for side-view rowing
+const SKELETON_CONNECTIONS: [number, number][] = [
+ [11, 12], // shoulders
+ [11, 13], [13, 15], // left arm
+ [12, 14], [14, 16], // right arm
+ [11, 23], [12, 24], // torso sides
+ [23, 24], // hips
+ [23, 25], [25, 27], // left leg
+ [24, 26], [26, 28], // right leg
+ [27, 29], [27, 31], // left foot
+ [28, 30], [28, 32], // right foot
+];
+
+interface PhaseBoundaries {
+ catchFrameIndex: number;
+ driveStartFrameIndex: number;
+ finishFrameIndex: number;
+ recoveryStartFrameIndex: number;
+ nextCatchFrameIndex: number;
+ confidence: number;
+}
+
+interface SessionStrokeMetric {
+ id: string;
+ strokeIndex: number;
+ phaseBoundariesJson: PhaseBoundaries;
+ segmentationSource: string;
+}
+
+interface FaultEvidence {
+ metric: string;
+ value: number;
+ threshold: number;
+ frameIndex?: number;
+}
+
+interface SessionFault {
+ id: string;
+ strokeIndex: number;
+ faultType: string;
+ severity: string;
+ phase: string;
+ evidenceJson: FaultEvidence;
+}
+
+interface MocapSessionDetail {
+ id: string;
+ status: string;
+ capturePerspective: string;
+ captureFps: number;
+ durationSec: number;
+ qualityScore: number | null;
+ qualityFlags: string[];
+ createdAt: string;
+ strokePostureMetrics: SessionStrokeMetric[];
+ postureFaults: SessionFault[];
+}
+
+async function fetchPoseHeader(id: string): Promise {
+ try {
+ const res = await fetch(`/api/mocap/sessions/${id}/pose-stream`, {
+ headers: { Range: `bytes=0-${HEADER_SIZE - 1}` },
+ });
+ if (!res.ok && res.status !== 206) return null;
+ const buf = new Uint8Array(await res.arrayBuffer());
+ return decodeHeader(buf);
+ } catch {
+ return null;
+ }
+}
+
+async function fetchPoseFrameAtIndex(
+ id: string,
+ frameIndex: number,
+): Promise {
+ try {
+ const start = frameByteOffset(frameIndex);
+ const end = start + BYTES_PER_FRAME_V1 - 1;
+ const res = await fetch(`/api/mocap/sessions/${id}/pose-stream`, {
+ headers: { Range: `bytes=${start}-${end}` },
+ });
+ if (!res.ok && res.status !== 206) return null;
+ const buf = new Uint8Array(await res.arrayBuffer());
+ const frame = decodeFrame(buf, 0);
+ return frame.keypoints;
+ } catch {
+ return null;
+ }
+}
+
+function drawSkeleton(
+ ctx: CanvasRenderingContext2D,
+ keypoints: Float32Array,
+ canvasW: number,
+ canvasH: number,
+ videoW: number,
+ videoH: number,
+) {
+ // Compute letterbox bounds (object-contain)
+ const videoAspect = videoW / videoH;
+ const canvasAspect = canvasW / canvasH;
+ let drawW: number, drawH: number, drawX: number, drawY: number;
+ if (videoAspect > canvasAspect) {
+ drawW = canvasW;
+ drawH = canvasW / videoAspect;
+ drawX = 0;
+ drawY = (canvasH - drawH) / 2;
+ } else {
+ drawH = canvasH;
+ drawW = canvasH * videoAspect;
+ drawX = (canvasW - drawW) / 2;
+ drawY = 0;
+ }
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+
+ // Connections
+ ctx.strokeStyle = "rgba(0, 220, 120, 0.85)";
+ ctx.lineWidth = 2;
+ for (const [a, b] of SKELETON_CONNECTIONS) {
+ const confA = keypoints[a * 3 + 2];
+ const confB = keypoints[b * 3 + 2];
+ if (confA < 0.3 || confB < 0.3) continue;
+ const ax = drawX + keypoints[a * 3] * drawW;
+ const ay = drawY + keypoints[a * 3 + 1] * drawH;
+ const bx = drawX + keypoints[b * 3] * drawW;
+ const by = drawY + keypoints[b * 3 + 1] * drawH;
+ ctx.beginPath();
+ ctx.moveTo(ax, ay);
+ ctx.lineTo(bx, by);
+ ctx.stroke();
+ }
+
+ // Keypoints
+ ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
+ for (let i = 0; i < KEYPOINTS_PER_FRAME_V1; i++) {
+ const conf = keypoints[i * 3 + 2];
+ if (conf < 0.3) continue;
+ const x = drawX + keypoints[i * 3] * drawW;
+ const y = drawY + keypoints[i * 3 + 1] * drawH;
+ ctx.beginPath();
+ ctx.arc(x, y, 3, 0, 2 * Math.PI);
+ ctx.fill();
+ }
+}
+
+function severityColor(severity: string) {
+ if (severity === "critical") return "bg-red-500";
+ if (severity === "warning") return "bg-yellow-500";
+ return "bg-blue-400";
+}
+
+function faultLabel(faultType: string): string {
+ return faultType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function fmtTime(sec: number): string {
+ const m = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+function fmtDate(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export default function MocapReplayPage() {
+ const { id } = useParams<{ id: string }>();
+
+ const videoRef = useRef(null);
+ const canvasRef = useRef(null);
+ const animRef = useRef(null);
+ const fetchingRef = useRef(false);
+ const lastFrameIndexRef = useRef(-1);
+
+ const [session, setSession] = useState(null);
+ const [poseHeader, setPoseHeader] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [selectedFault, setSelectedFault] = useState(null);
+ const [selectedStroke, setSelectedStroke] = useState(null);
+ const [reanalyzing, setReanalyzing] = useState(false);
+ const [reanalyzeError, setReanalyzeError] = useState(null);
+
+ // Load session data
+ useEffect(() => {
+ fetch(`/api/mocap/sessions/${id}`)
+ .then((r) => {
+ if (!r.ok) throw new Error(`${r.status}`);
+ return r.json();
+ })
+ .then((data) => setSession(data.session))
+ .catch((e) => setLoadError(e.message));
+ }, [id]);
+
+ // Load pose stream header
+ useEffect(() => {
+ if (!session || session.status !== "ready") return;
+ fetchPoseHeader(id).then(setPoseHeader);
+ }, [id, session]);
+
+ // Resize canvas to match video display dimensions
+ useEffect(() => {
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ if (!video || !canvas) return;
+ const onMeta = () => {
+ canvas.width = video.clientWidth || 1280;
+ canvas.height = video.clientHeight || 720;
+ };
+ video.addEventListener("loadedmetadata", onMeta);
+ return () => video.removeEventListener("loadedmetadata", onMeta);
+ }, []);
+
+ const renderFrame = useCallback(
+ async (frameIndex: number) => {
+ const video = videoRef.current;
+ const canvas = canvasRef.current;
+ if (!video || !canvas || fetchingRef.current) return;
+ if (frameIndex === lastFrameIndexRef.current) return;
+
+ fetchingRef.current = true;
+ try {
+ const keypoints = await fetchPoseFrameAtIndex(id, frameIndex);
+ if (keypoints && canvas) {
+ lastFrameIndexRef.current = frameIndex;
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ drawSkeleton(
+ ctx,
+ keypoints,
+ canvas.width,
+ canvas.height,
+ video.videoWidth || 1280,
+ video.videoHeight || 720,
+ );
+ }
+ }
+ } finally {
+ fetchingRef.current = false;
+ }
+ },
+ [id],
+ );
+
+ // rAF loop during playback + seeked handler
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video || !poseHeader) return;
+
+ const fps = poseHeader.fps;
+
+ const loop = () => {
+ if (!video.paused) {
+ const fi = Math.floor(video.currentTime * fps);
+ renderFrame(fi);
+ animRef.current = requestAnimationFrame(loop);
+ }
+ };
+
+ const onPlay = () => {
+ animRef.current = requestAnimationFrame(loop);
+ };
+ const onPause = () => {
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ animRef.current = null;
+ };
+ const onSeeked = () => {
+ if (video.paused) {
+ renderFrame(Math.floor(video.currentTime * fps));
+ }
+ };
+ const onTimeUpdate = () => setCurrentTime(video.currentTime);
+
+ video.addEventListener("play", onPlay);
+ video.addEventListener("pause", onPause);
+ video.addEventListener("seeked", onSeeked);
+ video.addEventListener("timeupdate", onTimeUpdate);
+
+ return () => {
+ if (animRef.current) cancelAnimationFrame(animRef.current);
+ video.removeEventListener("play", onPlay);
+ video.removeEventListener("pause", onPause);
+ video.removeEventListener("seeked", onSeeked);
+ video.removeEventListener("timeupdate", onTimeUpdate);
+ };
+ }, [poseHeader, renderFrame]);
+
+ const seekToFrame = useCallback(
+ (frameIndex: number) => {
+ const video = videoRef.current;
+ if (!video || !poseHeader) return;
+ video.currentTime = frameIndex / poseHeader.fps;
+ },
+ [poseHeader],
+ );
+
+ const seekToTime = useCallback((time: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
+ }, []);
+
+ const runAnalysis = useCallback(async () => {
+ setReanalyzing(true);
+ setReanalyzeError(null);
+ try {
+ const res = await fetch(`/api/mocap/sessions/${id}/reanalyze`, {
+ method: "POST",
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ // Refetch full session to get the new derived rows
+ const dataRes = await fetch(`/api/mocap/sessions/${id}`);
+ if (!dataRes.ok) throw new Error(`${dataRes.status}`);
+ const data = await dataRes.json();
+ setSession(data.session);
+ } catch (e) {
+ setReanalyzeError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setReanalyzing(false);
+ }
+ }, [id]);
+
+ const freezeAtCatch = useCallback(() => {
+ if (!selectedStroke || !session || !poseHeader) return;
+ const metric = session.strokePostureMetrics.find(
+ (m) => m.strokeIndex === selectedStroke,
+ );
+ if (metric) seekToFrame(metric.phaseBoundariesJson.catchFrameIndex);
+ }, [selectedStroke, session, poseHeader, seekToFrame]);
+
+ const freezeAtFinish = useCallback(() => {
+ if (!selectedStroke || !session || !poseHeader) return;
+ const metric = session.strokePostureMetrics.find(
+ (m) => m.strokeIndex === selectedStroke,
+ );
+ if (metric) seekToFrame(metric.phaseBoundariesJson.finishFrameIndex);
+ }, [selectedStroke, session, poseHeader, seekToFrame]);
+
+ if (loadError) {
+ return (
+
+
Failed to load session: {loadError}
+
+ Back to sessions
+
+
+ );
+ }
+
+ if (!session) {
+ return (
+
+ Loading…
+
+ );
+ }
+
+ const duration = session.durationSec;
+ const fps = poseHeader?.fps ?? session.captureFps;
+ const hasMetrics = session.strokePostureMetrics.length > 0;
+ const faultsByStroke = new Map();
+ for (const f of session.postureFaults) {
+ if (!faultsByStroke.has(f.strokeIndex)) faultsByStroke.set(f.strokeIndex, []);
+ faultsByStroke.get(f.strokeIndex)!.push(f);
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ ← Sessions
+
+
+
{fmtDate(session.createdAt)}
+
+ {session.capturePerspective} · {fmtTime(duration)}
+ {session.qualityScore !== null
+ ? ` · quality ${Math.round(session.qualityScore * 100)}%`
+ : ""}
+
+
+
+
+ {session.status}
+
+
+
+ {/* Video + skeleton overlay */}
+
+
+ {poseHeader ? (
+
+ ) : null}
+
+
+ {/* Freeze controls + stroke selector */}
+ {hasMetrics ? (
+
+
+ Stroke:{" "}
+
+
+ setSelectedStroke(e.target.value ? Number(e.target.value) : null)
+ }
+ >
+ — select stroke —
+ {session.strokePostureMetrics.map((m) => (
+
+ Stroke {m.strokeIndex + 1}
+
+ ))}
+
+
+ Freeze at catch
+
+
+ Freeze at finish
+
+
+ {fmtTime(currentTime)} / {fmtTime(duration)}
+
+
+ ) : null}
+
+ {/* Timeline */}
+ {hasMetrics && duration > 0 ? (
+
{
+ const rect = e.currentTarget.getBoundingClientRect();
+ seekToTime(((e.clientX - rect.left) / rect.width) * duration);
+ }}
+ >
+ {/* Stroke markers */}
+ {session.strokePostureMetrics.map((m) => {
+ const t = m.phaseBoundariesJson.catchFrameIndex / fps;
+ const pct = Math.min(100, (t / duration) * 100);
+ return (
+
+ );
+ })}
+ {/* Fault dots */}
+ {session.postureFaults.map((f, i) => {
+ const metric = session.strokePostureMetrics.find(
+ (m) => m.strokeIndex === f.strokeIndex,
+ );
+ if (!metric) return null;
+ const t = metric.phaseBoundariesJson.catchFrameIndex / fps;
+ const pct = Math.min(100, (t / duration) * 100);
+ return (
+
{
+ e.stopPropagation();
+ setSelectedFault(f);
+ setSelectedStroke(f.strokeIndex);
+ const frameIndex = metric.phaseBoundariesJson.catchFrameIndex;
+ seekToFrame(frameIndex);
+ }}
+ />
+ );
+ })}
+ {/* Playhead */}
+ {duration > 0 ? (
+
+ ) : null}
+
+ ) : null}
+
+ {/* Not-yet-analyzed state */}
+ {!hasMetrics && session.status === "ready" ? (
+
+
+
+ No posture analysis for this session.
+
+ {reanalyzeError ? (
+ Analysis failed: {reanalyzeError}
+ ) : null}
+
+ {reanalyzing ? "Analyzing…" : "Run analysis"}
+
+
+
+ ) : null}
+
+ {/* Stats */}
+ {hasMetrics ? (
+
+
+
+ f.severity === "critical").length,
+ )}
+ />
+
+
+ ) : null}
+
+ {/* Fault detail panel */}
+ {selectedFault ? (
+
+
+
+ {faultLabel(selectedFault.faultType)}
+
+ {selectedFault.severity}
+
+
+ phase: {selectedFault.phase} · stroke {selectedFault.strokeIndex + 1}
+
+
+
+
+
+ Metric: {selectedFault.evidenceJson.metric}
+
+
+ Value: {selectedFault.evidenceJson.value.toFixed(2)} ·
+ Threshold: {selectedFault.evidenceJson.threshold.toFixed(2)}
+
+ {selectedFault.evidenceJson.frameIndex !== undefined ? (
+ Frame: {selectedFault.evidenceJson.frameIndex}
+ ) : null}
+ setSelectedFault(null)}
+ >
+ Dismiss
+
+
+
+ ) : null}
+
+ {/* All faults list */}
+ {hasMetrics && session.postureFaults.length > 0 ? (
+
+
All faults
+
+ {session.postureFaults.map((f, i) => (
+ {
+ setSelectedFault(f);
+ setSelectedStroke(f.strokeIndex);
+ const metric = session.strokePostureMetrics.find(
+ (m) => m.strokeIndex === f.strokeIndex,
+ );
+ if (metric) seekToFrame(metric.phaseBoundariesJson.catchFrameIndex);
+ }}
+ >
+
+ {faultLabel(f.faultType)}
+
+ — stroke {f.strokeIndex + 1}, {f.phase}
+
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+function StatBox({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
diff --git a/src/app/mocap/sessions/page.tsx b/src/app/mocap/sessions/page.tsx
new file mode 100644
index 0000000..6ed7f31
--- /dev/null
+++ b/src/app/mocap/sessions/page.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+
+interface MocapSessionSummary {
+ id: string;
+ status: string;
+ durationSec: number;
+ createdAt: string;
+ capturePerspective: string;
+ qualityScore: number | null;
+ qualityFlags: string[];
+ _count: {
+ strokePostureMetrics: number;
+ postureFaults: number;
+ };
+}
+
+function statusBadge(status: string) {
+ if (status === "ready") return
Ready ;
+ if (status === "analyzing") return
Analyzing… ;
+ if (status === "capturing") return
Capturing ;
+ return
{status} ;
+}
+
+function fmtDuration(sec: number): string {
+ if (sec < 60) return `${sec.toFixed(0)}s`;
+ const m = Math.floor(sec / 60);
+ const s = Math.round(sec % 60);
+ return `${m}m ${s}s`;
+}
+
+function fmtDate(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export default function MocapSessionsPage() {
+ const [sessions, setSessions] = useState
(null);
+ const [error, setError] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [reanalyzingId, setReanalyzingId] = useState(null);
+
+ useEffect(() => {
+ fetch("/api/mocap/sessions")
+ .then((r) => {
+ if (!r.ok) throw new Error(`${r.status}`);
+ return r.json();
+ })
+ .then((data) => setSessions(data.sessions))
+ .catch((e) => setError(e.message));
+ }, []);
+
+ async function handleDelete(id: string) {
+ if (!window.confirm("Delete this session? This cannot be undone.")) return;
+ setDeletingId(id);
+ try {
+ const res = await fetch(`/api/mocap/sessions/${id}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`${res.status}`);
+ setSessions((prev) => prev?.filter((s) => s.id !== id) ?? null);
+ } catch (e) {
+ alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function handleReanalyze(id: string) {
+ setReanalyzingId(id);
+ try {
+ const res = await fetch(`/api/mocap/sessions/${id}/reanalyze`, {
+ method: "POST",
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ const data: { strokeMetricCount: number; faultCount: number } = await res.json();
+ setSessions((prev) =>
+ prev?.map((s) =>
+ s.id === id
+ ? {
+ ...s,
+ _count: {
+ strokePostureMetrics: data.strokeMetricCount,
+ postureFaults: data.faultCount,
+ },
+ }
+ : s,
+ ) ?? null,
+ );
+ } catch (e) {
+ alert(`Reanalyze failed: ${e instanceof Error ? e.message : String(e)}`);
+ } finally {
+ setReanalyzingId(null);
+ }
+ }
+
+ return (
+
+
+
+
Mocap sessions
+
+ All recorded motion capture sessions
+
+
+
+ New session
+
+
+
+ {error ? (
+
+
+ Failed to load sessions: {error}
+
+
+ ) : sessions === null ? (
+
+
+ Loading…
+
+
+ ) : sessions.length === 0 ? (
+
+
+ No sessions yet.{" "}
+
+ Record your first session.
+
+
+
+ ) : (
+
+ {sessions.map((s) => (
+
+
+
+
+ {fmtDate(s.createdAt)}
+
+ {statusBadge(s.status)}
+
+
+ {s.capturePerspective} · {fmtDuration(s.durationSec)}
+ {s.qualityScore !== null
+ ? ` · quality ${Math.round(s.qualityScore * 100)}%`
+ : ""}
+
+
+
+
+
+ {s._count.strokePostureMetrics} strokes
+ {s._count.postureFaults} faults
+ {s.qualityFlags.length > 0 && (
+
+ ⚠ {s.qualityFlags.join(", ")}
+
+ )}
+
+
+ {s.status === "ready" ? (
+ <>
+ handleReanalyze(s.id)}
+ data-testid={`mocap-reanalyze-${s.id}`}
+ >
+ {reanalyzingId === s.id ? "Analyzing…" : "Reanalyze"}
+
+
+ Replay
+
+ >
+ ) : null}
+ handleDelete(s.id)}
+ className="text-destructive hover:text-destructive"
+ data-testid={`mocap-delete-${s.id}`}
+ >
+ {deletingId === s.id ? "Deleting…" : "Delete"}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx
index 3a286f7..9207b6d 100644
--- a/src/app/sessions/page.tsx
+++ b/src/app/sessions/page.tsx
@@ -15,7 +15,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
-import { ArrowUpDown, Calendar, TrendingUp, Clock, Zap, Target, ArrowUp, ArrowDown, Filter, X, Trophy, Sparkles } from 'lucide-react';
+import { ArrowUpDown, Calendar, TrendingUp, Clock, Zap, Target, ArrowUp, ArrowDown, Filter, X, Trophy, Sparkles, Video } from 'lucide-react';
import { formatSessionDate } from '@/lib/dateTimeUtils';
import { TimeRangeSelector, defaultTimeRangeOptions, type TimeRange } from '@/components/ui/time-range-selector';
@@ -362,6 +362,12 @@ export default function SessionsPage() {
Stroke Data
+
+
+
+ Mocap
+
+
@@ -428,6 +434,16 @@ export default function SessionsPage() {
No stroke file
)}
+ e.stopPropagation()}>
+ {session.mocapSession ? (
+
+
+
+ Mocap
+
+
+ ) : null}
+
);
})}
diff --git a/src/lib/mocap/aiPayload.ts b/src/lib/mocap/aiPayload.ts
new file mode 100644
index 0000000..e565b07
--- /dev/null
+++ b/src/lib/mocap/aiPayload.ts
@@ -0,0 +1,107 @@
+/**
+ * Posture AI payload builder — 3-tier cloud data policy.
+ *
+ * Tier 1 (raw PoseFrameStream): HARD WALL — never sent to cloud AI.
+ * Tier 3 (fault summary): Default when cloudAIEnabled = true.
+ * Tier 2 (per-stroke metrics): Opt-in; requires mocapDetailedAIShare = true.
+ *
+ * This module has NO Prisma imports: it is a pure data-transformation layer.
+ */
+
+export interface PostureFaultSummary {
+ totalFaults: number;
+ faultCounts: Partial>;
+ severityCounts: { info: number; warning: number; critical: number };
+ qualityFlags: string[];
+ sessionQualityScore: number | null;
+}
+
+export interface PostureMetricSummary {
+ strokeIndex: number;
+ segmentationSource: string;
+ backAngleAtCatchDeg: number;
+ laybackAngleDeg: number;
+ recoveryDriveRatio: number;
+}
+
+export interface PostureAIPayload {
+ tier: 2 | 3;
+ faultSummary: PostureFaultSummary;
+ strokeMetrics?: PostureMetricSummary[]; // only present on tier 2
+}
+
+/**
+ * Build a cloud-safe posture AI payload respecting the 3-tier policy.
+ *
+ * Returns null when cloudAIEnabled is false (Tier 1 hard-wall).
+ * Returns a Tier 3 payload (fault summary only) by default.
+ * Returns a Tier 2 payload (fault summary + per-stroke metrics) when
+ * mocapDetailedAIShare is also true.
+ */
+export function buildPostureAIPayload(
+ faults: Array<{ faultType: string; severity: string }>,
+ metrics: Array<{
+ strokeIndex: number;
+ segmentationSource: string;
+ metricsJson: unknown;
+ }>,
+ qualityFlags: string[],
+ qualityScore: number | null,
+ opts: { cloudAIEnabled: boolean; mocapDetailedAIShare: boolean },
+): PostureAIPayload | null {
+ // Tier 1 hard-wall: no posture data leaves the device.
+ if (!opts.cloudAIEnabled) return null;
+
+ const faultCounts: Partial> = {};
+ const severityCounts = { info: 0, warning: 0, critical: 0 };
+
+ for (const f of faults) {
+ faultCounts[f.faultType] = (faultCounts[f.faultType] ?? 0) + 1;
+ if (f.severity === "info") severityCounts.info++;
+ else if (f.severity === "warning") severityCounts.warning++;
+ else if (f.severity === "critical") severityCounts.critical++;
+ }
+
+ const faultSummary: PostureFaultSummary = {
+ totalFaults: faults.length,
+ faultCounts,
+ severityCounts,
+ qualityFlags,
+ sessionQualityScore: qualityScore,
+ };
+
+ // Tier 3 (default): fault summary only — no body geometry, no keypoints.
+ if (!opts.mocapDetailedAIShare) {
+ return { tier: 3, faultSummary };
+ }
+
+ // Tier 2 (opt-in): fault summary + per-stroke scalar metrics — NO keypoints.
+ const strokeMetrics: PostureMetricSummary[] = metrics.map((m) => {
+ const mj = m.metricsJson as Record;
+ return {
+ strokeIndex: m.strokeIndex,
+ segmentationSource: m.segmentationSource,
+ backAngleAtCatchDeg:
+ typeof mj.backAngleAtCatchDeg === "number" ? mj.backAngleAtCatchDeg : 0,
+ laybackAngleDeg:
+ typeof mj.laybackAngleDeg === "number" ? mj.laybackAngleDeg : 0,
+ recoveryDriveRatio:
+ typeof mj.recoveryDriveRatio === "number" ? mj.recoveryDriveRatio : 0,
+ };
+ });
+
+ return { tier: 2, faultSummary, strokeMetrics };
+}
+
+/**
+ * Hard guard: throws if the serialised payload contains keypoint arrays.
+ * Call this before appending any posture payload to a cloud-bound prompt.
+ */
+export function assertNoKeypointsInPayload(payload: unknown): void {
+ const str = JSON.stringify(payload);
+ if (str.includes('"keypoints"') || str.includes('"landmarks"')) {
+ throw new Error(
+ "HARD GUARD VIOLATION: cloud-bound payload contains keypoint data",
+ );
+ }
+}
diff --git a/src/lib/mocap/sessionAnalysis.ts b/src/lib/mocap/sessionAnalysis.ts
index 1c84548..bd3c2ae 100644
--- a/src/lib/mocap/sessionAnalysis.ts
+++ b/src/lib/mocap/sessionAnalysis.ts
@@ -81,3 +81,82 @@ export async function analyzeAndPersistMocapSession(
faultCount: result.faults.length,
};
}
+
+/**
+ * Run analysis for a MocapSession that is linked to a RowingSession.
+ * Fetches StrokeData rows for the linked rowing session and aligns
+ * pose-derived strokes to them by index order (simple cross-correlation v1).
+ * Sets segmentationSource = "csv-aligned" and strokeDataId on each metric row.
+ */
+export async function analyzeAndPersistMocapSessionLinked(
+ storage: MocapStorage,
+ session: AnalyzeSessionInput,
+ rowingSessionId: string,
+): Promise {
+ const [poseBlob, userSettings, strokeDataRows] = await Promise.all([
+ storage.read(session.poseStreamPath),
+ prisma.userSettings.findUnique({
+ where: { userId: session.userId },
+ select: { postureThresholds: true },
+ }),
+ prisma.strokeData.findMany({
+ where: { sessionId: rowingSessionId },
+ orderBy: { strokeIndex: "asc" },
+ select: { id: true, strokeIndex: true },
+ }),
+ ]);
+
+ const thresholds = resolvePostureThresholdSettings(
+ userSettings?.postureThresholds,
+ ).settings.thresholds;
+ const stream = adaptPoseFrameStreamBlob(
+ poseBlob,
+ session.capturePerspective as CapturePerspective,
+ );
+ const result = analyzePoseFrameStream(stream, { thresholds });
+
+ // Build an index map: position (0-based array index) → StrokeData id
+ const strokeDataIdByPosition = new Map();
+ strokeDataRows.forEach((sd, pos) => {
+ strokeDataIdByPosition.set(pos, sd.id);
+ });
+
+ await prisma.$transaction(async (tx) => {
+ await tx.postureFault.deleteMany({
+ where: { mocapSessionId: session.id },
+ });
+ await tx.strokePostureMetric.deleteMany({
+ where: { mocapSessionId: session.id },
+ });
+ if (result.metrics.length > 0) {
+ await tx.strokePostureMetric.createMany({
+ data: result.metrics.map((metric) => ({
+ mocapSessionId: session.id,
+ strokeIndex: metric.strokeIndex,
+ phaseBoundariesJson:
+ metric.phaseBoundariesJson as unknown as Prisma.InputJsonValue,
+ metricsJson: metric.metricsJson as unknown as Prisma.InputJsonValue,
+ segmentationSource: "csv-aligned",
+ strokeDataId: strokeDataIdByPosition.get(metric.strokeIndex) ?? null,
+ })),
+ });
+ }
+ if (result.faults.length > 0) {
+ await tx.postureFault.createMany({
+ data: result.faults.map((fault) => ({
+ mocapSessionId: session.id,
+ strokeIndex: fault.strokeIndex,
+ faultType: fault.faultType,
+ severity: fault.severity,
+ phase: fault.phase,
+ evidenceJson: fault.evidenceJson as unknown as Prisma.InputJsonValue,
+ })),
+ });
+ }
+ });
+
+ return {
+ strokeMetricCount: result.metrics.length,
+ faultCount: result.faults.length,
+ };
+}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 0d4f736..96878f8 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -118,6 +118,7 @@ export interface UseCaseConfig {
export interface AISettings {
cloudAIEnabled: boolean;
+ mocapDetailedAIShare: boolean;
openaiApiKey: string;
maxTokens: number;
@@ -246,6 +247,7 @@ export class SettingsService {
aiSettings: {
openaiApiKey: '',
cloudAIEnabled: false,
+ mocapDetailedAIShare: false,
maxTokens: 4000,
// Per-use-case configurations with smart defaults
@@ -576,6 +578,7 @@ Be specific and actionable. Only include information relevant to rowing training
aiSettings: {
...this.defaultSettings.aiSettings,
cloudAIEnabled: (dbSettings.cloudAIEnabled as boolean) || false,
+ mocapDetailedAIShare: (dbSettings.mocapDetailedAIShare as boolean) || false,
// Note: openaiApiKey is NOT loaded from DB - it's kept local only
// Will be preserved from localStorage if present
maxTokens: (dbSettings.maxTokens as number) || 4000,
@@ -643,6 +646,7 @@ Be specific and actionable. Only include information relevant to rowing training
planReminders: settings.notificationSettings.planReminders,
adherenceAlerts: settings.notificationSettings.adherenceAlerts,
cloudAIEnabled: settings.aiSettings.cloudAIEnabled,
+ mocapDetailedAIShare: settings.aiSettings.mocapDetailedAIShare,
maxTokens: settings.aiSettings.maxTokens,
userProfileContext: settings.aiSettings.userProfileContext,
userProfileRawInput: settings.aiSettings.userProfileRawInput,
diff --git a/src/types/session.ts b/src/types/session.ts
index aa74dc5..af27069 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -21,6 +21,7 @@ export interface Session {
maxStrokeRate: number;
consistencyScore?: number | null; // Pre-computed consistency score (0-100)
strokeData?: StrokeData[]; // Optional detailed stroke data
+ mocapSession?: { id: string } | null; // Linked mocap session (if any)
}
export interface StrokeData {
From 6e892629079890f733ef95e3eb67271d036678d9 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 15:03:01 +0200
Subject: [PATCH 09/29] feat(mocap): add cloud AI posture tiers, overlap-check,
sync prompt, settings UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- aiAnalysis.ts + cloudAI.ts: attach PostureAIPayload to insight prompts (tier 3 default, tier 2 opt-in, tier 1 hard wall)
- sync page: show overlap banner linking mocap↔rowing sessions after CSV import
- overlap-check API: find unlinked MocapSessions within ±2 min of newly-imported RowingSession
- settings: mocapDetailedAIShare toggle with tier explainer, disabled when cloudAIEnabled=false
- tests/aiPayload.test.ts: 11 unit tests covering all tier permutations + hard-guard assertion
Co-Authored-By: Claude Sonnet 4.6
---
.../api/mocap/sessions/overlap-check/route.ts | 79 ++++++++++
src/app/settings/page.tsx | 17 +++
src/app/sync/page.tsx | 73 ++++++++-
src/lib/aiAnalysis.ts | 67 ++++++++
src/lib/cloudAI.ts | 24 ++-
tests/aiPayload.test.ts | 143 ++++++++++++++++++
6 files changed, 399 insertions(+), 4 deletions(-)
create mode 100644 src/app/api/mocap/sessions/overlap-check/route.ts
create mode 100644 tests/aiPayload.test.ts
diff --git a/src/app/api/mocap/sessions/overlap-check/route.ts b/src/app/api/mocap/sessions/overlap-check/route.ts
new file mode 100644
index 0000000..76a07d6
--- /dev/null
+++ b/src/app/api/mocap/sessions/overlap-check/route.ts
@@ -0,0 +1,79 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * POST /api/mocap/sessions/overlap-check
+ * Given a list of newly-imported RowingSession ids, find any existing
+ * MocapSessions (unlinked) whose capture window overlaps the rowing
+ * session's timestamp by ±2 minutes.
+ *
+ * Body: { rowingSessionIds: string[] }
+ * Response: { overlaps: Array<{ rowingSessionId: string; mocapSessionId: string }> }
+ */
+export async function POST(req: Request) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+ const OVERLAP_MARGIN_MS = 2 * 60 * 1000; // ±2 minutes
+
+ let rowingSessionIds: string[];
+ try {
+ const body = await req.json();
+ if (!Array.isArray(body?.rowingSessionIds) || body.rowingSessionIds.length === 0) {
+ return NextResponse.json({ overlaps: [] });
+ }
+ rowingSessionIds = body.rowingSessionIds as string[];
+ } catch {
+ return NextResponse.json({ overlaps: [] });
+ }
+
+ // Fetch the rowing sessions' timestamps
+ const rowingSessions = await prisma.rowingSession.findMany({
+ where: { id: { in: rowingSessionIds }, userId },
+ select: { id: true, timestamp: true },
+ });
+
+ if (rowingSessions.length === 0) {
+ return NextResponse.json({ overlaps: [] });
+ }
+
+ // Fetch unlinked mocap sessions for this user (ready status, no rowingSessionId)
+ const mocapSessions = await prisma.mocapSession.findMany({
+ where: { userId, rowingSessionId: null, status: "ready" },
+ select: { id: true, createdAt: true, durationSec: true },
+ });
+
+ if (mocapSessions.length === 0) {
+ return NextResponse.json({ overlaps: [] });
+ }
+
+ const overlaps: Array<{ rowingSessionId: string; mocapSessionId: string }> = [];
+
+ for (const rs of rowingSessions) {
+ const rsTime = rs.timestamp.getTime();
+ const rsStart = rsTime - OVERLAP_MARGIN_MS;
+ const rsEnd = rsTime + OVERLAP_MARGIN_MS;
+
+ for (const ms of mocapSessions) {
+ const msStart = ms.createdAt.getTime();
+ const msEnd = msStart + ms.durationSec * 1000;
+
+ // Overlap check: mocap window [msStart, msEnd] overlaps [rsStart, rsEnd]
+ if (msEnd >= rsStart && msStart <= rsEnd) {
+ overlaps.push({ rowingSessionId: rs.id, mocapSessionId: ms.id });
+ // One mocap session can only link to one rowing session — stop after first match
+ break;
+ }
+ }
+ }
+
+ return NextResponse.json({ overlaps });
+}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 052b7c5..8a277a7 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1449,6 +1449,23 @@ export default function SettingsPage() {
/>
+ {/* Posture Data Sharing Tier Toggle */}
+
+
+
Posture Analysis Data Sharing
+
+ Tier 3 (default): Sends fault counts and severity totals only — no body geometry.{' '}
+ Tier 2 (opt-in): Also sends per-stroke back angle, layback, and recovery ratio.
+ Raw pose keypoints are never sent to cloud AI.
+
+
+
saveSettings('aiSettings', { mocapDetailedAIShare: checked })}
+ disabled={!settingsData.aiSettings.cloudAIEnabled}
+ />
+
+
{settingsData.aiSettings.cloudAIEnabled && (
<>
{/* API Key Input - render the input ONLY when the user clicks "Edit".
diff --git a/src/app/sync/page.tsx b/src/app/sync/page.tsx
index bdc9351..01da73a 100644
--- a/src/app/sync/page.tsx
+++ b/src/app/sync/page.tsx
@@ -11,9 +11,14 @@ import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
-import { Upload, FileText, AlertCircle, CheckCircle, ArrowRight, FileArchive, Database, RefreshCw, Settings } from 'lucide-react';
+import { Upload, FileText, AlertCircle, CheckCircle, ArrowRight, FileArchive, Database, RefreshCw, Settings, Video } from 'lucide-react';
import Link from 'next/link';
+interface MocapOverlap {
+ rowingSessionId: string;
+ mocapSessionId: string;
+}
+
type UploadState = 'idle' | 'dragging' | 'validating' | 'processing' | 'saving' | 'syncing' | 'success' | 'error';
export default function UploadPage() {
@@ -27,6 +32,8 @@ export default function UploadPage() {
const [uploadProgress, setUploadProgress] = useState(null);
const [zipProgress, setZipProgress] = useState(null);
const [syncMessage, setSyncMessage] = useState('');
+ const [mocapOverlaps, setMocapOverlaps] = useState([]);
+ const [dismissedOverlaps, setDismissedOverlaps] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -68,6 +75,26 @@ export default function UploadPage() {
processFile(file);
}, []);
+ const checkMocapOverlap = useCallback(async (savedSessions: Session[]) => {
+ if (savedSessions.length === 0) return;
+ try {
+ const ids = savedSessions.map((s) => s.id).filter(Boolean);
+ const res = await fetch('/api/mocap/sessions/overlap-check', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rowingSessionIds: ids }),
+ });
+ if (!res.ok) return;
+ const data = await res.json();
+ if (Array.isArray(data.overlaps) && data.overlaps.length > 0) {
+ setMocapOverlaps(data.overlaps);
+ setDismissedOverlaps(false);
+ }
+ } catch {
+ // Non-critical — silently ignore overlap check errors
+ }
+ }, []);
+
const processFile = async (file: File) => {
setSelectedFile(file);
setError('');
@@ -118,6 +145,7 @@ export default function UploadPage() {
// skip DB save since we already saved with chunked upload
if (saveResult.sessions && saveResult.sessions.length > 0) {
updateSessionsInStore(saveResult.sessions);
+ await checkMocapOverlap(saveResult.sessions);
}
}
@@ -173,6 +201,7 @@ export default function UploadPage() {
// skip DB save since we already saved with chunked upload
if (saveResult.sessions && saveResult.sessions.length > 0) {
addSessions(saveResult.sessions, { skipDbSave: true });
+ await checkMocapOverlap(saveResult.sessions);
}
}
@@ -195,6 +224,8 @@ export default function UploadPage() {
setSyncMessage('');
setUploadProgress(null);
setZipProgress(null);
+ setMocapOverlaps([]);
+ setDismissedOverlaps(false);
}, []);
const formatDuration = (seconds: number): string => {
@@ -307,6 +338,9 @@ export default function UploadPage() {
if (saveResult.success) {
addSessions(sessions, { skipDbSave: true });
+ if (saveResult.sessions && saveResult.sessions.length > 0) {
+ await checkMocapOverlap(saveResult.sessions);
+ }
totalImported += result.importedSessions;
totalDistance += result.totalDistance;
totalTime += result.totalTime;
@@ -642,6 +676,43 @@ export default function UploadPage() {
)}
+ {/* Mocap Overlap Prompt */}
+ {mocapOverlaps.length > 0 && !dismissedOverlaps && (
+
+
+
+
+
+ Mocap session detected nearby
+
+
+ {mocapOverlaps.length === 1
+ ? 'A motion-capture session was recorded within 2 minutes of your imported rowing session. Link them to enable csv-aligned posture analysis.'
+ : `${mocapOverlaps.length} motion-capture sessions were recorded within 2 minutes of your imported rowing sessions. Link them to enable csv-aligned posture analysis.`}
+
+
+ {mocapOverlaps.map((overlap) => (
+
+
+ View Mocap Session
+
+ ))}
+ setDismissedOverlaps(true)}
+ className="text-xs text-purple-500 dark:text-purple-400 hover:underline px-1"
+ >
+ Dismiss
+
+
+
+
+
+ )}
+
{/* Action Buttons */}
diff --git a/src/lib/aiAnalysis.ts b/src/lib/aiAnalysis.ts
index 5ea2c46..4bc9bf5 100644
--- a/src/lib/aiAnalysis.ts
+++ b/src/lib/aiAnalysis.ts
@@ -1,5 +1,10 @@
import { Session } from '@/types/session';
import { cloudAI } from '@/lib/cloudAI';
+import {
+ buildPostureAIPayload,
+ assertNoKeypointsInPayload,
+ type PostureAIPayload,
+} from '@/lib/mocap/aiPayload';
// Types for AI analysis results
export interface TrendData {
@@ -401,3 +406,65 @@ export class AIAnalysisService {
// Export singleton instance
export const aiAnalysis = AIAnalysisService.getInstance();
+
+// ============================================================================
+// Posture AI Payload Integration
+// ============================================================================
+
+/**
+ * Input type for posture data fetched server-side (from DB records).
+ * No keypoints — only aggregated fault/metric rows.
+ */
+export interface MocapSessionPostureData {
+ faults: Array<{ faultType: string; severity: string }>;
+ metrics: Array<{
+ strokeIndex: number;
+ segmentationSource: string;
+ metricsJson: unknown;
+ }>;
+ qualityFlags: string[];
+ qualityScore: number | null;
+}
+
+/**
+ * Build a cloud-safe posture AI payload from DB-fetched mocap data and user
+ * settings, then verify the hard keypoint guard.
+ *
+ * Returns null when cloudAI is disabled (no posture data leaves the device).
+ * Call this server-side (e.g. in an API route) where DB access is available;
+ * pass the returned payload into the cloud AI prompt builder.
+ */
+export function buildAndValidatePosturePayload(
+ postureData: MocapSessionPostureData,
+ opts: { cloudAIEnabled: boolean; mocapDetailedAIShare: boolean },
+): PostureAIPayload | null {
+ const payload = buildPostureAIPayload(
+ postureData.faults,
+ postureData.metrics,
+ postureData.qualityFlags,
+ postureData.qualityScore,
+ opts,
+ );
+
+ if (payload !== null) {
+ // Hard guard: must never contain keypoint arrays.
+ assertNoKeypointsInPayload(payload);
+ }
+
+ return payload;
+}
+
+/**
+ * Serialise a validated PostureAIPayload as a JSON context block suitable
+ * for appending to an AI prompt string.
+ */
+export function formatPosturePayloadForPrompt(
+ payload: PostureAIPayload,
+): string {
+ const tierLabel =
+ payload.tier === 3
+ ? 'Tier 3 – Fault Summary (no body geometry)'
+ : 'Tier 2 – Fault Summary + Per-Stroke Metrics (no keypoints)';
+
+ return `\n\n---\nPOSTURE ANALYSIS CONTEXT [${tierLabel}]:\n${JSON.stringify(payload, null, 2)}\n---`;
+}
diff --git a/src/lib/cloudAI.ts b/src/lib/cloudAI.ts
index 380a3aa..288895a 100644
--- a/src/lib/cloudAI.ts
+++ b/src/lib/cloudAI.ts
@@ -1,5 +1,6 @@
import { Session } from '@/types/session';
import { TrainingPlan, TrainingWeek, TrainingSession } from '@/lib/trainingPlans';
+import type { PostureAIPayload } from '@/lib/mocap/aiPayload';
import { SettingsService } from '@/lib/settings';
import {
DEFAULT_PLAN_GENERATION_PROMPT,
@@ -982,7 +983,10 @@ Remember: You're building a long-term coaching relationship. Be supportive, know
// Generate rowing-specific insights using OpenAI
- async generateInsights(sessions: Session[]): Promise {
+ async generateInsights(
+ sessions: Session[],
+ posturePayload?: PostureAIPayload | null,
+ ): Promise {
if (!this.config) {
throw new Error('Cloud AI service not configured');
}
@@ -997,7 +1001,7 @@ Remember: You're building a long-term coaching relationship. Be supportive, know
try {
const anonymizedData = this.anonymizeSessions(sessions);
- const prompt = this.buildInsightPrompt(anonymizedData);
+ const prompt = this.buildInsightPrompt(anonymizedData, posturePayload);
const useCaseConfig = this.aiSettings.insights;
const config: ApiRequestConfig = {
@@ -1068,7 +1072,10 @@ Remember: You're building a long-term coaching relationship. Be supportive, know
}
// Build user prompt with session data using configurable prompt
- private buildInsightPrompt(sessions: Record[]): string {
+ private buildInsightPrompt(
+ sessions: Record[],
+ posturePayload?: PostureAIPayload | null,
+ ): string {
// Include more sessions for better progress analysis
const recentSessions = sessions.slice(-20); // Last 20 sessions for better context
const sessionSummary = this.createSessionSummary(recentSessions);
@@ -1114,6 +1121,17 @@ JSON structure:
CRITICAL: Your response must be ONLY the JSON array of insights. Do not include any explanations, markdown, or the training data itself.`;
+ // Append posture context block when available (tier 2 or tier 3 payload).
+ // Raw keypoint data is never included — the hard guard in aiPayload.ts
+ // enforces this before the payload reaches this point.
+ if (posturePayload) {
+ const tierLabel =
+ posturePayload.tier === 3
+ ? 'Tier 3 – Fault Summary (no body geometry)'
+ : 'Tier 2 – Fault Summary + Per-Stroke Metrics (no keypoints)';
+ return `${finalPrompt}\n\n---\nPOSTURE ANALYSIS CONTEXT [${tierLabel}]:\n${JSON.stringify(posturePayload, null, 2)}\n---`;
+ }
+
return finalPrompt;
}
diff --git a/tests/aiPayload.test.ts b/tests/aiPayload.test.ts
new file mode 100644
index 0000000..3fe4a6a
--- /dev/null
+++ b/tests/aiPayload.test.ts
@@ -0,0 +1,143 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildPostureAIPayload,
+ assertNoKeypointsInPayload,
+} from "../src/lib/mocap/aiPayload.js";
+
+const MOCK_FAULTS = [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+];
+
+const MOCK_METRICS = [
+ {
+ strokeIndex: 0,
+ segmentationSource: "pose-segmented",
+ metricsJson: {
+ backAngleAtCatchDeg: 25,
+ laybackAngleDeg: 35,
+ recoveryDriveRatio: 1.8,
+ },
+ },
+];
+
+describe("buildPostureAIPayload", () => {
+ test("returns null when cloudAIEnabled false", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: false,
+ mocapDetailedAIShare: true,
+ });
+ assert.equal(result, null);
+ });
+
+ test("tier 3 when mocapDetailedAIShare false", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.tier, 3);
+ assert.equal(result.strokeMetrics, undefined);
+ // Hard guard must pass
+ assertNoKeypointsInPayload(result);
+ });
+
+ test("tier 2 when both flags true", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.tier, 2);
+ assert.ok(Array.isArray(result.strokeMetrics));
+ assert.equal(result.strokeMetrics!.length, 1);
+ // Hard guard must pass
+ assertNoKeypointsInPayload(result);
+ });
+
+ test("tier 3 fault summary is correct", () => {
+ const faults = [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "excessive_layback", severity: "critical" },
+ ];
+ const result = buildPostureAIPayload(faults, [], ["low_confidence"], 0.72, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.faultSummary.totalFaults, 3);
+ assert.equal(result.faultSummary.faultCounts["rounded_back_at_catch"], 2);
+ assert.equal(result.faultSummary.faultCounts["excessive_layback"], 1);
+ assert.equal(result.faultSummary.severityCounts.warning, 2);
+ assert.equal(result.faultSummary.severityCounts.critical, 1);
+ assert.equal(result.faultSummary.severityCounts.info, 0);
+ assert.deepEqual(result.faultSummary.qualityFlags, ["low_confidence"]);
+ assert.equal(result.faultSummary.sessionQualityScore, 0.72);
+ });
+
+ test("tier 2 stroke metrics contain correct scalar values", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.ok(result !== null && result.strokeMetrics);
+ const m = result.strokeMetrics[0];
+ assert.equal(m.strokeIndex, 0);
+ assert.equal(m.segmentationSource, "pose-segmented");
+ assert.equal(m.backAngleAtCatchDeg, 25);
+ assert.equal(m.laybackAngleDeg, 35);
+ assert.equal(m.recoveryDriveRatio, 1.8);
+ });
+
+ test("tier 2 stroke metrics default to 0 for missing numeric fields", () => {
+ const metrics = [
+ {
+ strokeIndex: 1,
+ segmentationSource: "csv-aligned",
+ metricsJson: { someOtherField: "hello" },
+ },
+ ];
+ const result = buildPostureAIPayload(MOCK_FAULTS, metrics, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.ok(result !== null && result.strokeMetrics);
+ const m = result.strokeMetrics[0];
+ assert.equal(m.backAngleAtCatchDeg, 0);
+ assert.equal(m.laybackAngleDeg, 0);
+ assert.equal(m.recoveryDriveRatio, 0);
+ });
+});
+
+describe("assertNoKeypointsInPayload", () => {
+ test("throws on payload containing keypoints key", () => {
+ assert.throws(() => assertNoKeypointsInPayload({ keypoints: [1, 2, 3] }));
+ });
+
+ test("throws on payload containing landmarks key", () => {
+ assert.throws(() =>
+ assertNoKeypointsInPayload({ frame: { landmarks: [] } }),
+ );
+ });
+
+ test("does not throw on safe payload", () => {
+ assert.doesNotThrow(() => assertNoKeypointsInPayload({ data: "safe" }));
+ });
+
+ test("does not throw on tier 3 payload from buildPostureAIPayload", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(result));
+ });
+
+ test("does not throw on tier 2 payload from buildPostureAIPayload", () => {
+ const result = buildPostureAIPayload(MOCK_FAULTS, MOCK_METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(result));
+ });
+});
From 7b0be740ec3007d2cce4ffca9935722252767501 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 15:14:43 +0200
Subject: [PATCH 10/29] feat(mocap): coaching cues + end-of-session summary
(#14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CoachingAdvisor pure module: getCoachingCues() maps faults to cue copy, audio hints, drills per fault key; deduplicates by type keeping highest severity; suppresses info by default
- Capture page: live coaching cue banner after finalize, audio mute toggle, SessionCoachingSummary component showing top-3 fault types by frequency×severity with drill suggestions
- MocapPreferences (verbosity/audioEnabled) added to UserSettings schema, settings service, and DB mapping
Co-Authored-By: Claude Sonnet 4.6
---
prisma/schema.prisma | 1 +
src/app/mocap/page.tsx | 116 ++++++++++++++++++
src/lib/mocap/coaching/coachingAdvisor.ts | 140 ++++++++++++++++++++++
src/lib/settings.ts | 19 ++-
4 files changed, 274 insertions(+), 2 deletions(-)
create mode 100644 src/lib/mocap/coaching/coachingAdvisor.ts
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f7a6059..9abf68a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -515,6 +515,7 @@ model UserSettings {
userProfileContext String? @db.Text
userProfileRawInput String? @db.Text
postureThresholds Json?
+ mocapPreferences Json?
dashboardSettings Json?
sessionsViewSettings Json?
sessionAnalysisSettings Json?
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index 3a3e1df..8d031cc 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -15,6 +15,11 @@ import {
} from "@/lib/mocap/browserPoseSource";
import { QUALITY_FLAG } from "@/lib/mocap/poseFrameStream";
import { VideoUploader } from "@/lib/mocap/videoUploader";
+import {
+ getCoachingCues,
+ type CoachingCue,
+} from "@/lib/mocap/coaching/coachingAdvisor";
+import type { PostureFault } from "@/lib/mocap/analysis/types";
const CAPTURE_FPS = 30;
const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
@@ -23,6 +28,12 @@ const MIN_TRACKED_KEYPOINTS = 20;
const MIN_MEAN_CONFIDENCE = 0.5;
const DEGRADED_FRAME_MS = 2000;
+const SEVERITY_WEIGHT: Record = {
+ critical: 3,
+ warning: 2,
+ info: 1,
+};
+
type CaptureState =
| { kind: "idle" }
| { kind: "starting" }
@@ -113,6 +124,9 @@ export default function MocapCapturePage() {
const [framingDegraded, setFramingDegraded] = useState(false);
const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
const [recordOnly, setRecordOnly] = useState(false);
+ const [activeCue, setActiveCue] = useState(null);
+ const [sessionFaults, setSessionFaults] = useState([]);
+ const [audioEnabled, setAudioEnabled] = useState(false);
useEffect(() => {
if (state.kind !== "capturing") return;
@@ -425,6 +439,20 @@ export default function MocapCapturePage() {
} = await finalizeRes.json();
await teardown();
setCalibration({ kind: "idle" });
+
+ // Fetch faults for end-of-session summary
+ let faults: PostureFault[] = [];
+ try {
+ const sessionRes = await fetch(`/api/mocap/sessions/${finalized.id}`);
+ if (sessionRes.ok) {
+ const sessionData = await sessionRes.json();
+ faults = (sessionData.session?.postureFaults ?? []) as PostureFault[];
+ setSessionFaults(faults);
+ }
+ } catch {
+ // non-fatal — summary just won't show
+ }
+
setState({
kind: "done",
sessionId: finalized.id,
@@ -536,6 +564,15 @@ export default function MocapCapturePage() {
/>
Record only (skip analysis)
+
+ setAudioEnabled(e.target.checked)}
+ data-testid="mocap-audio-toggle"
+ />
+ Audio cues
+
{calibration.kind === "idle" &&
(state.kind === "idle" || state.kind === "done") ? (
+
) : null}
+ {activeCue ? (
+
+
+ {activeCue.severity === "critical" ? "⚠ " : "ℹ "}
+ {activeCue.message}
+
+ {activeCue.drills.length > 0 ? (
+
+ Drills: {activeCue.drills.join(" · ")}
+
+ ) : null}
+
setActiveCue(null)}
+ >
+ Dismiss
+
+
+ ) : null}
+
{state.kind === "error" ? (
Error: {state.message}
@@ -788,6 +854,56 @@ function qualityFlagLabel(quality: PoseQuality): string {
return labels.length > 0 ? labels.join(", ") : "ok";
}
+function SessionCoachingSummary({ faults }: { faults: PostureFault[] }) {
+ if (faults.length === 0) return null;
+
+ // Aggregate by fault type: total weight = count × severity weight
+ const typeMap = new Map
();
+ for (const f of faults) {
+ const existing = typeMap.get(f.faultType);
+ const w = SEVERITY_WEIGHT[f.severity] ?? 1;
+ if (!existing) {
+ typeMap.set(f.faultType, { count: 1, weight: w, severity: f.severity });
+ } else {
+ existing.count++;
+ existing.weight += w;
+ if ((SEVERITY_WEIGHT[f.severity] ?? 1) > (SEVERITY_WEIGHT[existing.severity] ?? 1)) {
+ existing.severity = f.severity;
+ }
+ }
+ }
+
+ const top3 = [...typeMap.entries()]
+ .sort((a, b) => b[1].weight - a[1].weight)
+ .slice(0, 3);
+
+ if (top3.length === 0) return null;
+
+ const cues = getCoachingCues(
+ faults.filter((f) => top3.some(([t]) => t === f.faultType)),
+ { strokeCount: faults.length },
+ { minSeverity: "info" },
+ );
+
+ return (
+
+
Session summary
+ {top3.map(([faultType, info]) => {
+ const cue = cues.find((c) => c.faultType === faultType);
+ const label = faultType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+ return (
+
+
{label} × {info.count}
+ {cue?.drills.map((d) => (
+
→ {d}
+ ))}
+
+ );
+ })}
+
+ );
+}
+
function pickRecorderMime(): string {
const candidates = [
"video/webm;codecs=vp9",
diff --git a/src/lib/mocap/coaching/coachingAdvisor.ts b/src/lib/mocap/coaching/coachingAdvisor.ts
new file mode 100644
index 0000000..f8137a8
--- /dev/null
+++ b/src/lib/mocap/coaching/coachingAdvisor.ts
@@ -0,0 +1,140 @@
+import type { FaultSeverity, PostureFault, PostureFaultType } from "../analysis/types";
+
+export interface SessionContext {
+ strokeCount: number;
+}
+
+export interface CoachingCue {
+ faultType: PostureFaultType;
+ severity: FaultSeverity;
+ message: string;
+ audioHint: string;
+ drills: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Drill catalog (keyed by fault type)
+// ---------------------------------------------------------------------------
+
+const DRILL_CATALOG: Record = {
+ rounded_back_at_catch: [
+ "Pause drill at the catch",
+ "Body angle check with mirror",
+ ],
+ early_arm_bend: [
+ "Arms-only rowing drill",
+ "Pause drill at arms-away position",
+ ],
+ back_opens_before_legs_drive: [
+ "Legs-only rowing drill",
+ "Pick drill sequence",
+ ],
+ excessive_layback: [
+ "Half-slide rowing",
+ "Finish position hold drill",
+ ],
+ slow_recovery_ratio: [
+ "Controlled recovery timing drill",
+ "Pause at the finish drill",
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Hand-written coaching copy (message + audioHint per fault type)
+// ---------------------------------------------------------------------------
+
+interface CueCopy {
+ message: string;
+ audioHint: string;
+}
+
+const CUE_COPY: Record = {
+ rounded_back_at_catch: {
+ message:
+ "Your back is rounding at the catch. Keep the chest tall and the back straight — reach forward with a flat back before taking the stroke.",
+ audioHint: "Tall chest, flat back",
+ },
+ early_arm_bend: {
+ message:
+ "Your arms are bending before the legs have finished the drive. Lock the arms out and let the legs push first, then draw with the body and arms in sequence.",
+ audioHint: "Legs, then arms",
+ },
+ back_opens_before_legs_drive: {
+ message:
+ "Your body is swinging open before the legs have engaged. Initiate the drive with the legs and keep the back angle constant until the legs are nearly straight.",
+ audioHint: "Hold body angle",
+ },
+ excessive_layback: {
+ message:
+ "You are leaning too far back at the finish. Aim for a slight layback — around 10–20 degrees past vertical — rather than collapsing the trunk backward.",
+ audioHint: "Slight layback only",
+ },
+ slow_recovery_ratio: {
+ message:
+ "Your recovery is very slow relative to the drive. Control the slide speed but aim to keep the recovery-to-drive ratio below 2.5 to maintain a good rhythm.",
+ audioHint: "Control your slide",
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Severity ordering (for deduplication: keep highest severity per fault type)
+// ---------------------------------------------------------------------------
+
+const SEVERITY_RANK: Record = {
+ info: 0,
+ warning: 1,
+ critical: 2,
+};
+
+// ---------------------------------------------------------------------------
+// getCoachingCues — pure function, no Prisma imports
+// ---------------------------------------------------------------------------
+
+/**
+ * Derives CoachingCues from a list of PostureFaults.
+ *
+ * Rules:
+ * - Returns an empty array for an empty faults list.
+ * - Suppresses `info`-severity faults by default (controlled by caller via
+ * the `minSeverity` option, default `'warning'`).
+ * - Emits at most one cue per unique fault type; when the same fault type
+ * appears at multiple severities, the highest severity wins.
+ * - `message` and `audioHint` are hand-written per fault type.
+ * - `drills` are drawn from DRILL_CATALOG.
+ */
+export function getCoachingCues(
+ faults: PostureFault[],
+ _sessionContext: SessionContext,
+ opts: { minSeverity?: FaultSeverity } = {},
+): CoachingCue[] {
+ if (faults.length === 0) return [];
+
+ const minRank = SEVERITY_RANK[opts.minSeverity ?? "warning"];
+
+ // Deduplicate: one entry per fault type, highest severity wins.
+ const best = new Map();
+ for (const fault of faults) {
+ if (SEVERITY_RANK[fault.severity] < minRank) continue;
+ const existing = best.get(fault.faultType);
+ if (
+ !existing ||
+ SEVERITY_RANK[fault.severity] > SEVERITY_RANK[existing.severity]
+ ) {
+ best.set(fault.faultType, fault);
+ }
+ }
+
+ const cues: CoachingCue[] = [];
+ for (const fault of best.values()) {
+ const copy = CUE_COPY[fault.faultType];
+ cues.push({
+ faultType: fault.faultType,
+ severity: fault.severity,
+ message: copy.message,
+ audioHint: copy.audioHint,
+ drills: DRILL_CATALOG[fault.faultType],
+ });
+ }
+
+ return cues;
+}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 96878f8..7f1fe41 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -105,9 +105,15 @@ export interface SmartRowSettings {
lastSync: Date | null;
}
+export interface MocapPreferences {
+ verbosity: 'quiet' | 'verbose';
+ audioEnabled: boolean;
+}
+
export interface MocapSettings {
postureThresholds: UserPostureThresholdSettings;
postureThresholdWarning: string | null;
+ mocapPreferences: MocapPreferences;
}
export interface UseCaseConfig {
@@ -242,7 +248,11 @@ export class SettingsService {
},
mocapSettings: {
postureThresholds: defaultPostureThresholdSettings(),
- postureThresholdWarning: null
+ postureThresholdWarning: null,
+ mocapPreferences: {
+ verbosity: 'quiet',
+ audioEnabled: false,
+ },
},
aiSettings: {
openaiApiKey: '',
@@ -573,7 +583,11 @@ Be specific and actionable. Only include information relevant to rowing training
smartRowSettings: this.defaultSettings.smartRowSettings,
mocapSettings: {
postureThresholds: resolvedPostureThresholds.settings,
- postureThresholdWarning: resolvedPostureThresholds.warning
+ postureThresholdWarning: resolvedPostureThresholds.warning,
+ mocapPreferences: {
+ verbosity: ((dbSettings.mocapPreferences as Record | null)?.verbosity as 'quiet' | 'verbose') ?? 'quiet',
+ audioEnabled: ((dbSettings.mocapPreferences as Record | null)?.audioEnabled as boolean) ?? false,
+ },
},
aiSettings: {
...this.defaultSettings.aiSettings,
@@ -651,6 +665,7 @@ Be specific and actionable. Only include information relevant to rowing training
userProfileContext: settings.aiSettings.userProfileContext,
userProfileRawInput: settings.aiSettings.userProfileRawInput,
postureThresholds: settings.mocapSettings.postureThresholds,
+ mocapPreferences: settings.mocapSettings.mocapPreferences,
aiConfig: {
chat: settings.aiSettings.chat,
insights: settings.aiSettings.insights,
From a117c6e5274f11e51edf1572402e13952f5e7a53 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 15:18:46 +0200
Subject: [PATCH 11/29] chore: add git worktree to allowed bash commands
---
.claude/settings.local.json | 3 ++-
test-results/.last-run.json | 4 ++++
2 files changed, 6 insertions(+), 1 deletion(-)
create mode 100644 test-results/.last-run.json
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ec238ad..f63c1c9 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -32,7 +32,8 @@
"Bash(PGPASSWORD=rowing_dev_password psql -h localhost -U rowing -d rowing_tracker -c \"INSERT INTO _prisma_migrations \\(id, checksum, finished_at, migration_name, started_at, applied_steps_count\\) VALUES \\(gen_random_uuid\\(\\)::text, md5\\('20260508120000_add_mocap_session'\\), now\\(\\), '20260508120000_add_mocap_session', now\\(\\), 1\\) ON CONFLICT DO NOTHING;\")",
"Bash(git commit *)",
"Bash(xargs -I{} echo {})",
- "Bash(npm test *)"
+ "Bash(npm test *)",
+ "Bash(git worktree *)"
]
}
}
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
From 1dff7b3da2a82a1a21f8ea2156a347f3a289a120 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 15:26:44 +0200
Subject: [PATCH 12/29] adjust app header
---
src/components/navigation.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
index 2076c94..e2fcf63 100644
--- a/src/components/navigation.tsx
+++ b/src/components/navigation.tsx
@@ -117,7 +117,7 @@ export function Navigation() {
-
+
Rowing Tracker
From eab6db4c76fbedaf5b56eb17748c1081d32d9c58 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 15:40:01 +0200
Subject: [PATCH 13/29] fix(db): add missing UserSettings.mocapPreferences
column
Schema declared mocapPreferences Json? but no migration created it,
causing /api/settings to 500 with Prisma P2022 ColumnNotFound. Added
migration 20260508170000_add_mocap_preferences_settings.
---
.../20260508170000_add_mocap_preferences_settings/migration.sql | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 prisma/migrations/20260508170000_add_mocap_preferences_settings/migration.sql
diff --git a/prisma/migrations/20260508170000_add_mocap_preferences_settings/migration.sql b/prisma/migrations/20260508170000_add_mocap_preferences_settings/migration.sql
new file mode 100644
index 0000000..965108e
--- /dev/null
+++ b/prisma/migrations/20260508170000_add_mocap_preferences_settings/migration.sql
@@ -0,0 +1,2 @@
+-- Add mocap preferences JSON column missing from previous migrations.
+ALTER TABLE "UserSettings" ADD COLUMN "mocapPreferences" JSONB;
From d926035a2f6d3429c0fac398d06bfbbc1437faaf Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 16:20:13 +0200
Subject: [PATCH 14/29] feat(mocap): live post-stroke coaching cues (US 4, 5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- LiveCoachingEngine drives the pure StrokePhaseSegmenter →
PostureMetricsCalculator → PostureFaultDetector pipeline
incrementally; emits one CoachingCue per newly-completed stroke
with per-faultType throttling.
- cueAudio wraps speechSynthesis with cancel-and-replace + SSR guards.
- /mocap capture page wires the engine, persists verbosity + audio
preferences via mocapPreferences, auto-dismisses visual cues.
- Settings: surface Cue Verbosity + Audio Cues controls.
- Fix Zod settings schema to accept mocapPreferences and
mocapDetailedAIShare (pre-existing silent-reject bug).
- Tests: 5 fixture-driven LiveCoachingEngine cases.
---
src/app/api/settings/route.ts | 2 +
src/app/mocap/page.tsx | 193 +++++++++++++++++-
src/app/settings/page.tsx | 59 ++++++
.../mocap/analysis/poseFrameStreamAdapter.ts | 2 +-
src/lib/mocap/coaching/cueAudio.ts | 45 ++++
src/lib/mocap/coaching/liveCoachingEngine.ts | 182 +++++++++++++++++
src/lib/validations/settings.ts | 7 +
tests/liveCoachingEngine.test.ts | 159 +++++++++++++++
8 files changed, 641 insertions(+), 8 deletions(-)
create mode 100644 src/lib/mocap/coaching/cueAudio.ts
create mode 100644 src/lib/mocap/coaching/liveCoachingEngine.ts
create mode 100644 tests/liveCoachingEngine.test.ts
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index 41850a9..5a08837 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -124,6 +124,7 @@ export async function POST(req: Request) {
// AI settings
if (settingsData.cloudAIEnabled !== undefined) updateData.cloudAIEnabled = settingsData.cloudAIEnabled;
+ if (settingsData.mocapDetailedAIShare !== undefined) updateData.mocapDetailedAIShare = settingsData.mocapDetailedAIShare;
if (settingsData.maxTokens !== undefined) updateData.maxTokens = settingsData.maxTokens;
if (settingsData.aiConfig !== undefined) updateData.aiConfig = settingsData.aiConfig;
if (settingsData.customPromptsAi !== undefined) updateData.customPromptsAi = settingsData.customPromptsAi;
@@ -136,6 +137,7 @@ export async function POST(req: Request) {
if (settingsData.userProfileContext !== undefined) updateData.userProfileContext = settingsData.userProfileContext;
if (settingsData.userProfileRawInput !== undefined) updateData.userProfileRawInput = settingsData.userProfileRawInput;
if (settingsData.postureThresholds !== undefined) updateData.postureThresholds = settingsData.postureThresholds;
+ if (settingsData.mocapPreferences !== undefined) updateData.mocapPreferences = settingsData.mocapPreferences;
// Dashboard and view settings
if (settingsData.dashboardSettings !== undefined) updateData.dashboardSettings = settingsData.dashboardSettings;
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index 8d031cc..2b4ac23 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -13,13 +13,28 @@ import {
BrowserPoseSource,
type PoseSourceStatus,
} from "@/lib/mocap/browserPoseSource";
-import { QUALITY_FLAG } from "@/lib/mocap/poseFrameStream";
+import {
+ BYTES_PER_FRAME_V1,
+ QUALITY_FLAG,
+ decodeFrame,
+} from "@/lib/mocap/poseFrameStream";
import { VideoUploader } from "@/lib/mocap/videoUploader";
import {
getCoachingCues,
type CoachingCue,
} from "@/lib/mocap/coaching/coachingAdvisor";
-import type { PostureFault } from "@/lib/mocap/analysis/types";
+import { LiveCoachingEngine } from "@/lib/mocap/coaching/liveCoachingEngine";
+import {
+ cancelSpokenCues,
+ speakCue,
+} from "@/lib/mocap/coaching/cueAudio";
+import { keypointTripletsToPosePoints } from "@/lib/mocap/analysis/poseFrameStreamAdapter";
+import type {
+ Calibration,
+ PoseAnalysisFrame,
+ PostureFault,
+} from "@/lib/mocap/analysis/types";
+import { settings } from "@/lib/settings";
const CAPTURE_FPS = 30;
const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
@@ -109,6 +124,9 @@ export default function MocapCapturePage() {
const startedAtRef = useRef(0);
const degradedSinceRef = useRef(null);
const latestPoseFrameRef = useRef(EMPTY_QUALITY);
+ const engineRef = useRef(null);
+ const cueDismissTimerRef = useRef | null>(null);
+ const audioEnabledRef = useRef(false);
const [state, setState] = useState({ kind: "idle" });
const [calibration, setCalibration] = useState({
@@ -127,6 +145,49 @@ export default function MocapCapturePage() {
const [activeCue, setActiveCue] = useState(null);
const [sessionFaults, setSessionFaults] = useState([]);
const [audioEnabled, setAudioEnabled] = useState(false);
+ const [verbosity, setVerbosity] = useState<"quiet" | "verbose">("quiet");
+
+ // Hydrate live-cue prefs from persisted settings on mount.
+ useEffect(() => {
+ const prefs = settings.getMocapSettings().mocapPreferences;
+ setAudioEnabled(prefs.audioEnabled);
+ setVerbosity(prefs.verbosity);
+ audioEnabledRef.current = prefs.audioEnabled;
+ }, []);
+
+ useEffect(() => {
+ audioEnabledRef.current = audioEnabled;
+ if (!audioEnabled) cancelSpokenCues();
+ }, [audioEnabled]);
+
+ const updateAudioEnabled = useCallback((next: boolean) => {
+ setAudioEnabled(next);
+ const current = settings.getMocapSettings().mocapPreferences;
+ settings.updateMocapSettings({
+ mocapPreferences: { ...current, audioEnabled: next },
+ });
+ }, []);
+
+ const updateVerbosity = useCallback((next: "quiet" | "verbose") => {
+ setVerbosity(next);
+ const current = settings.getMocapSettings().mocapPreferences;
+ settings.updateMocapSettings({
+ mocapPreferences: { ...current, verbosity: next },
+ });
+ }, []);
+
+ const clearCueDismissTimer = useCallback(() => {
+ if (cueDismissTimerRef.current) {
+ clearTimeout(cueDismissTimerRef.current);
+ cueDismissTimerRef.current = null;
+ }
+ }, []);
+
+ const dismissCue = useCallback(() => {
+ clearCueDismissTimer();
+ cancelSpokenCues();
+ setActiveCue(null);
+ }, [clearCueDismissTimer]);
useEffect(() => {
if (state.kind !== "capturing") return;
@@ -154,6 +215,12 @@ export default function MocapCapturePage() {
setFramesEncoded(info.framesEncoded);
setQuality(nextQuality);
+ // Feed the live coaching engine when active.
+ if (engineRef.current) {
+ const frame = decodeBase64PoseFrame(info.poseFrameBase64);
+ if (frame) engineRef.current.pushFrame(frame);
+ }
+
if (!monitorDegradedFraming) return;
const degraded = isDegradedFraming(nextQuality);
@@ -182,6 +249,9 @@ export default function MocapCapturePage() {
sourceRef.current = null;
recorderRef.current = null;
uploaderRef.current = null;
+ engineRef.current = null;
+ clearCueDismissTimer();
+ cancelSpokenCues();
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
streamRef.current = null;
@@ -189,7 +259,7 @@ export default function MocapCapturePage() {
if (videoRef.current) {
videoRef.current.srcObject = null;
}
- }, []);
+ }, [clearCueDismissTimer]);
const handleError = useCallback(
async (err: unknown, sessionId?: string) => {
@@ -332,6 +402,39 @@ export default function MocapCapturePage() {
await calibrationSourceRef.current?.stop();
calibrationSourceRef.current = null;
+ // Spin up the live coaching engine (skipped when in record-only mode).
+ const calibrationFramesForEngine = buildEngineCalibration(
+ perspective,
+ calibrationFrames.catchFrame,
+ calibrationFrames.finishFrame,
+ );
+ const mocapPrefs = settings.getMocapSettings();
+ if (!recordOnly) {
+ engineRef.current = new LiveCoachingEngine({
+ fps: CAPTURE_FPS,
+ capturePerspective: perspective,
+ calibration: calibrationFramesForEngine,
+ thresholds: mocapPrefs.postureThresholds.thresholds,
+ minSeverity:
+ mocapPrefs.mocapPreferences.verbosity === "verbose"
+ ? "info"
+ : "warning",
+ onCue: (cue) => {
+ clearCueDismissTimer();
+ setActiveCue(cue);
+ cueDismissTimerRef.current = setTimeout(() => {
+ setActiveCue(null);
+ cueDismissTimerRef.current = null;
+ }, 4000);
+ if (audioEnabledRef.current) {
+ speakCue(cue.audioHint);
+ }
+ },
+ });
+ } else {
+ engineRef.current = null;
+ }
+
const createRes = await fetch("/api/mocap/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -398,7 +501,14 @@ export default function MocapCapturePage() {
} catch (err) {
await handleError(err, sessionId);
}
- }, [calibration, handleError, handlePoseFrame, perspective]);
+ }, [
+ calibration,
+ handleError,
+ handlePoseFrame,
+ perspective,
+ recordOnly,
+ clearCueDismissTimer,
+ ]);
const stop = useCallback(async () => {
if (state.kind !== "capturing") return;
@@ -413,6 +523,15 @@ export default function MocapCapturePage() {
});
}
await sourceRef.current?.stop();
+ // Drain any pending strokes from the live engine before tearing it down.
+ try {
+ engineRef.current?.flush();
+ } catch {
+ // non-fatal
+ }
+ engineRef.current = null;
+ clearCueDismissTimer();
+ cancelSpokenCues();
await uploaderRef.current?.drain();
const durationSec = (Date.now() - startedAtRef.current) / 1000;
@@ -462,7 +581,14 @@ export default function MocapCapturePage() {
} catch (err) {
await handleError(err, sessionId);
}
- }, [state, handleError, sessionQualityFlags, teardown]);
+ }, [
+ state,
+ handleError,
+ sessionQualityFlags,
+ teardown,
+ recordOnly,
+ clearCueDismissTimer,
+ ]);
useEffect(() => {
return () => {
@@ -568,11 +694,25 @@ export default function MocapCapturePage() {
setAudioEnabled(e.target.checked)}
+ onChange={(e) => updateAudioEnabled(e.target.checked)}
data-testid="mocap-audio-toggle"
/>
Audio cues
+
+ Cues:
+
+ updateVerbosity(e.target.value as "quiet" | "verbose")
+ }
+ data-testid="mocap-verbosity"
+ >
+ Quiet
+ Verbose
+
+
{calibration.kind === "idle" &&
(state.kind === "idle" || state.kind === "done") ? (
setActiveCue(null)}
+ onClick={dismissCue}
>
Dismiss
@@ -904,6 +1044,45 @@ function SessionCoachingSummary({ faults }: { faults: PostureFault[] }) {
);
}
+function decodeBase64PoseFrame(
+ base64: string,
+): PoseAnalysisFrame | null {
+ if (!base64) return null;
+ try {
+ const binary = atob(base64);
+ if (binary.length < BYTES_PER_FRAME_V1) return null;
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ const decoded = decodeFrame(bytes, 0);
+ return {
+ timestampMs: decoded.timestampMs,
+ keypoints: keypointTripletsToPosePoints(decoded.keypoints),
+ qualityFlags: decoded.qualityFlags,
+ };
+ } catch {
+ return null;
+ }
+}
+
+function buildEngineCalibration(
+ capturePerspective: "side-left" | "side-right",
+ catchFrameBase64: { poseFrameBase64: string } | undefined,
+ finishFrameBase64: { poseFrameBase64: string } | undefined,
+): Calibration | undefined {
+ const catchFrame = catchFrameBase64?.poseFrameBase64
+ ? decodeBase64PoseFrame(catchFrameBase64.poseFrameBase64) ?? undefined
+ : undefined;
+ const finishFrame = finishFrameBase64?.poseFrameBase64
+ ? decodeBase64PoseFrame(finishFrameBase64.poseFrameBase64) ?? undefined
+ : undefined;
+ if (!catchFrame && !finishFrame) return undefined;
+ return {
+ capturePerspective,
+ catchFrame,
+ finishFrame,
+ };
+}
+
function pickRecorderMime(): string {
const candidates = [
"video/webm;codecs=vp9",
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index 8a277a7..bb0ab20 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1314,6 +1314,65 @@ export default function SettingsPage() {
+
+
+
+ Live Coaching Cues
+
+ Controls visual and audio feedback during a mocap session.
+ Cues fire post-stroke (≤ 1s after each stroke completes).
+
+
+
+
+
+
Cue Verbosity
+
+ Quiet (default): only warning + critical faults.{' '}
+ Verbose: also surfaces info-severity cues.
+
+
+
+ saveSettings('mocapSettings', {
+ mocapPreferences: {
+ ...settingsData.mocapSettings.mocapPreferences,
+ verbosity: e.target.value as 'quiet' | 'verbose',
+ },
+ })
+ }
+ data-testid="mocap-cue-verbosity"
+ >
+ Quiet
+ Verbose
+
+
+
+
+
+
Audio Cues
+
+ Speak the short audio hint for each cue using the
+ browser's speech synthesis. Visual cues remain on regardless.
+
+
+
+ saveSettings('mocapSettings', {
+ mocapPreferences: {
+ ...settingsData.mocapSettings.mocapPreferences,
+ audioEnabled: checked,
+ },
+ })
+ }
+ data-testid="mocap-cue-audio"
+ />
+
+
+
);
};
diff --git a/src/lib/mocap/analysis/poseFrameStreamAdapter.ts b/src/lib/mocap/analysis/poseFrameStreamAdapter.ts
index 2987484..dd1f75f 100644
--- a/src/lib/mocap/analysis/poseFrameStreamAdapter.ts
+++ b/src/lib/mocap/analysis/poseFrameStreamAdapter.ts
@@ -69,7 +69,7 @@ function adaptFrame(bytes: Uint8Array, offset: number): PoseAnalysisFrame {
};
}
-function keypointTripletsToPosePoints(keypoints: Float32Array): PosePoint[] {
+export function keypointTripletsToPosePoints(keypoints: Float32Array): PosePoint[] {
const points: PosePoint[] = [];
for (let i = 0; i < keypoints.length; i += 3) {
points.push({
diff --git a/src/lib/mocap/coaching/cueAudio.ts b/src/lib/mocap/coaching/cueAudio.ts
new file mode 100644
index 0000000..57fcafe
--- /dev/null
+++ b/src/lib/mocap/coaching/cueAudio.ts
@@ -0,0 +1,45 @@
+/**
+ * Thin wrapper around `window.speechSynthesis` for speaking coaching-cue
+ * audio hints. Cancel-and-replace policy: a new cue interrupts whatever is
+ * currently speaking so the rower never hears stacked stale advice.
+ */
+
+export interface SpeakCueOptions {
+ rate?: number;
+ pitch?: number;
+ volume?: number;
+ lang?: string;
+}
+
+export function isSpeechSynthesisAvailable(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.speechSynthesis !== "undefined" &&
+ typeof window.SpeechSynthesisUtterance !== "undefined"
+ );
+}
+
+export function speakCue(text: string, opts: SpeakCueOptions = {}): void {
+ if (!text || !isSpeechSynthesisAvailable()) return;
+ const synth = window.speechSynthesis;
+ try {
+ synth.cancel();
+ const utter = new SpeechSynthesisUtterance(text);
+ utter.rate = opts.rate ?? 1.05;
+ utter.pitch = opts.pitch ?? 1;
+ utter.volume = opts.volume ?? 1;
+ if (opts.lang) utter.lang = opts.lang;
+ synth.speak(utter);
+ } catch {
+ // Speech synthesis is best-effort; never let it break the capture loop.
+ }
+}
+
+export function cancelSpokenCues(): void {
+ if (!isSpeechSynthesisAvailable()) return;
+ try {
+ window.speechSynthesis.cancel();
+ } catch {
+ // ignore
+ }
+}
diff --git a/src/lib/mocap/coaching/liveCoachingEngine.ts b/src/lib/mocap/coaching/liveCoachingEngine.ts
new file mode 100644
index 0000000..18dc07c
--- /dev/null
+++ b/src/lib/mocap/coaching/liveCoachingEngine.ts
@@ -0,0 +1,182 @@
+/**
+ * LiveCoachingEngine - drives post-stroke coaching cues during a live mocap
+ * session. Buffers incoming PoseAnalysisFrames, periodically re-runs the pure
+ * StrokePhaseSegmenter / PostureMetricsCalculator / PostureFaultDetector
+ * pipeline, and emits a single CoachingCue per newly-completed stroke via
+ * `onCue`. No DOM, no I/O - fully testable with synthetic frame streams.
+ *
+ * Cues fire post-stroke (per resolved decision in the PRD), not intra-stroke.
+ * Same-faultType throttling honors US 5 ("non-nagging").
+ */
+import { StrokePhaseSegmenter } from "../analysis/strokePhaseSegmenter";
+import { PostureMetricsCalculator } from "../analysis/postureMetrics";
+import { PostureFaultDetector } from "../analysis/postureFaultDetector";
+import {
+ postureThresholdsV1,
+ type PostureThresholdBands,
+} from "../analysis/postureThresholds";
+import type {
+ Calibration,
+ CapturePerspective,
+ FaultSeverity,
+ PoseAnalysisFrame,
+ PoseFrameStream,
+ PostureFault,
+ PostureFaultType,
+ Stroke,
+} from "../analysis/types";
+import { getCoachingCues, type CoachingCue } from "./coachingAdvisor";
+
+export interface LiveCoachingEngineOptions {
+ fps: number;
+ capturePerspective: CapturePerspective;
+ calibration?: Calibration;
+ thresholds?: PostureThresholdBands;
+ /** Lowest severity that produces a cue. quiet -> 'warning', verbose -> 'info'. */
+ minSeverity?: FaultSeverity;
+ /**
+ * Minimum wall-clock interval between segmenter passes, in ms. Caps O(N^2)
+ * work over a long capture. Default 500 ms.
+ */
+ segmenterIntervalMs?: number;
+ /**
+ * Minimum wall-clock interval between cues for the same faultType, in ms.
+ * Honors US 5 ("non-nagging"). Default 8000 ms.
+ */
+ perFaultThrottleMs?: number;
+ /**
+ * Trailing margin in frames to wait before considering a stroke "complete".
+ * Avoids emitting against a stroke whose nextCatchFrameIndex was placed at
+ * the very end of the buffer where the segmenter is least confident.
+ */
+ trailingFrameMargin?: number;
+ onCue: (cue: CoachingCue, stroke: Stroke, faults: PostureFault[]) => void;
+ /** Wall clock (ms). Injectable for tests. Defaults to Date.now. */
+ now?: () => number;
+}
+
+const SEVERITY_RANK: Record = {
+ info: 0,
+ warning: 1,
+ critical: 2,
+};
+
+export class LiveCoachingEngine {
+ private readonly fps: number;
+ private readonly capturePerspective: CapturePerspective;
+ private readonly calibration: Calibration | undefined;
+ private readonly thresholds: PostureThresholdBands;
+ private readonly minSeverity: FaultSeverity;
+ private readonly segmenterIntervalMs: number;
+ private readonly perFaultThrottleMs: number;
+ private readonly trailingFrameMargin: number;
+ private readonly onCue: LiveCoachingEngineOptions["onCue"];
+ private readonly now: () => number;
+
+ private frames: PoseAnalysisFrame[] = [];
+ private lastTickAtMs = 0;
+ private lastEmittedStrokeIndex = -1;
+ private lastCueAtByType = new Map();
+ private completedStrokeCount = 0;
+
+ constructor(opts: LiveCoachingEngineOptions) {
+ this.fps = opts.fps;
+ this.capturePerspective = opts.capturePerspective;
+ this.calibration = opts.calibration;
+ this.thresholds = opts.thresholds ?? postureThresholdsV1.thresholds;
+ this.minSeverity = opts.minSeverity ?? "warning";
+ this.segmenterIntervalMs = opts.segmenterIntervalMs ?? 500;
+ this.perFaultThrottleMs = opts.perFaultThrottleMs ?? 8000;
+ this.trailingFrameMargin = opts.trailingFrameMargin ?? 3;
+ this.onCue = opts.onCue;
+ this.now = opts.now ?? (() => Date.now());
+ }
+
+ /** Append a frame to the live buffer; may trigger a (throttled) tick. */
+ pushFrame(frame: PoseAnalysisFrame): void {
+ this.frames.push(frame);
+ const now = this.now();
+ if (now - this.lastTickAtMs >= this.segmenterIntervalMs) {
+ this.lastTickAtMs = now;
+ this.tick();
+ }
+ }
+
+ /** Force a final analysis pass (e.g. on session stop). */
+ flush(): void {
+ this.lastTickAtMs = this.now();
+ this.tick();
+ }
+
+ /** Re-run the full pipeline on the current buffer and emit any new cues. */
+ tick(): void {
+ if (this.frames.length < 3) return;
+
+ const stream: PoseFrameStream = {
+ fps: this.fps,
+ capturePerspective: this.capturePerspective,
+ frames: this.frames,
+ };
+
+ const strokes = StrokePhaseSegmenter(stream);
+ if (strokes.length === 0) return;
+
+ const lastFrameIndex = this.frames.length - 1;
+ const completionBoundary = lastFrameIndex - this.trailingFrameMargin;
+
+ for (const stroke of strokes) {
+ if (stroke.strokeIndex <= this.lastEmittedStrokeIndex) continue;
+ // Only emit once the stroke is fully closed and a small trailing margin
+ // of frames has accumulated past nextCatchFrameIndex.
+ if (stroke.nextCatchFrameIndex > completionBoundary) break;
+
+ this.lastEmittedStrokeIndex = stroke.strokeIndex;
+ this.completedStrokeCount += 1;
+
+ const metrics = PostureMetricsCalculator(stream, stroke, this.calibration);
+ const faults = PostureFaultDetector(metrics, this.thresholds);
+ if (faults.length === 0) continue;
+
+ const cues = getCoachingCues(
+ faults,
+ { strokeCount: this.completedStrokeCount },
+ { minSeverity: this.minSeverity },
+ );
+ if (cues.length === 0) continue;
+
+ // Pick the single highest-severity cue for this stroke (avoid stacking
+ // multiple visual/audio cues at once).
+ const cue = pickPrimaryCue(cues);
+ const lastAt = this.lastCueAtByType.get(cue.faultType) ?? -Infinity;
+ const now = this.now();
+ if (now - lastAt < this.perFaultThrottleMs) continue;
+
+ this.lastCueAtByType.set(cue.faultType, now);
+ this.onCue(cue, stroke, faults);
+ }
+ }
+
+ /** Reset all internal state. Used between sessions. */
+ reset(): void {
+ this.frames = [];
+ this.lastTickAtMs = 0;
+ this.lastEmittedStrokeIndex = -1;
+ this.lastCueAtByType.clear();
+ this.completedStrokeCount = 0;
+ }
+
+ /** Test/diagnostic accessor. */
+ get bufferedFrameCount(): number {
+ return this.frames.length;
+ }
+}
+
+function pickPrimaryCue(cues: CoachingCue[]): CoachingCue {
+ let best = cues[0];
+ for (const cue of cues) {
+ if (SEVERITY_RANK[cue.severity] > SEVERITY_RANK[best.severity]) {
+ best = cue;
+ }
+ }
+ return best;
+}
diff --git a/src/lib/validations/settings.ts b/src/lib/validations/settings.ts
index 035708e..4f60908 100644
--- a/src/lib/validations/settings.ts
+++ b/src/lib/validations/settings.ts
@@ -140,6 +140,11 @@ const postureThresholdSettingsSchema = z.object({
}
});
+const mocapPreferencesSchema = z.object({
+ verbosity: z.enum(['quiet', 'verbose']),
+ audioEnabled: z.boolean(),
+});
+
/**
* Main settings update schema
* All fields are optional since the API accepts partial updates
@@ -177,6 +182,7 @@ export const settingsUpdateSchema = z.object({
// AI settings
cloudAIEnabled: z.boolean(),
+ mocapDetailedAIShare: z.boolean(),
maxTokens: z.number().int().min(100).max(128000),
aiConfig: aiConfigSchema,
customPromptsAi: customPromptsAiSchema,
@@ -189,6 +195,7 @@ export const settingsUpdateSchema = z.object({
userProfileContext: z.string().max(50000).nullable(),
userProfileRawInput: z.string().max(100000).nullable(),
postureThresholds: postureThresholdSettingsSchema.nullable(),
+ mocapPreferences: mocapPreferencesSchema.nullable(),
// View settings (flexible JSON)
dashboardSettings: viewSettingsSchema,
diff --git a/tests/liveCoachingEngine.test.ts b/tests/liveCoachingEngine.test.ts
new file mode 100644
index 0000000..002ca53
--- /dev/null
+++ b/tests/liveCoachingEngine.test.ts
@@ -0,0 +1,159 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { LiveCoachingEngine } from "../src/lib/mocap/coaching/liveCoachingEngine";
+import type { CoachingCue } from "../src/lib/mocap/coaching/coachingAdvisor";
+import type {
+ PoseFrameStream,
+ PostureFault,
+ PostureFaultType,
+ Stroke,
+} from "../src/lib/mocap/analysis/types";
+
+interface Fixture {
+ name: string;
+ stream: PoseFrameStream;
+ expected: {
+ strokeCount: number;
+ faults: Array<{
+ faultType: PostureFaultType;
+ severity: "info" | "warning" | "critical";
+ }>;
+ };
+}
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const fixturesDir = path.join(here, "fixtures", "mocap");
+
+function loadFixture(fileName: string): Fixture {
+ return JSON.parse(
+ readFileSync(path.join(fixturesDir, fileName), "utf8"),
+ ) as Fixture;
+}
+
+interface RecordedCue {
+ cue: CoachingCue;
+ stroke: Stroke;
+ faults: PostureFault[];
+ emittedAtMs: number;
+ bufferedFrameCount: number;
+}
+
+function runFixture(
+ fixture: Fixture,
+ opts: {
+ minSeverity?: "info" | "warning";
+ perFaultThrottleMs?: number;
+ segmenterIntervalMs?: number;
+ trailingFrameMargin?: number;
+ } = {},
+): { cues: RecordedCue[]; finalClock: number } {
+ const cues: RecordedCue[] = [];
+ let clock = 0;
+ // Each frame advances the injected clock by one tick (1000/fps ms).
+ const frameTickMs = 1000 / fixture.stream.fps;
+
+ const engine = new LiveCoachingEngine({
+ fps: fixture.stream.fps,
+ capturePerspective: fixture.stream.capturePerspective,
+ minSeverity: opts.minSeverity ?? "warning",
+ // Tick on every frame so determinism does not depend on wall clock.
+ segmenterIntervalMs: opts.segmenterIntervalMs ?? 0,
+ perFaultThrottleMs: opts.perFaultThrottleMs ?? 8000,
+ trailingFrameMargin: opts.trailingFrameMargin ?? 3,
+ now: () => clock,
+ onCue: (cue, stroke, faults) => {
+ cues.push({
+ cue,
+ stroke,
+ faults,
+ emittedAtMs: clock,
+ bufferedFrameCount: engine.bufferedFrameCount,
+ });
+ },
+ });
+
+ for (const frame of fixture.stream.frames) {
+ clock += frameTickMs;
+ engine.pushFrame(frame);
+ }
+ engine.flush();
+ return { cues, finalClock: clock };
+}
+
+test("clean-reference fixture emits no cues", () => {
+ const fixture = loadFixture("clean-reference.json");
+ const { cues } = runFixture(fixture);
+ assert.equal(
+ cues.length,
+ 0,
+ `expected no cues on clean reference, got ${cues
+ .map((r) => r.cue.faultType)
+ .join(", ")}`,
+ );
+});
+
+test("rounded-back-critical fixture emits a critical rounded_back_at_catch cue", () => {
+ const fixture = loadFixture("rounded-back-critical.json");
+ const { cues } = runFixture(fixture);
+ const matching = cues.filter(
+ (r) => r.cue.faultType === "rounded_back_at_catch",
+ );
+ assert.ok(matching.length >= 1, "expected at least one rounded-back cue");
+ assert.equal(matching[0].cue.severity, "critical");
+ assert.ok(
+ matching[0].cue.drills.length >= 1,
+ "cue should ship with drill suggestions",
+ );
+});
+
+test("excessive-layback fixture surfaces only at verbose verbosity (info severity)", () => {
+ const fixture = loadFixture("excessive-layback.json");
+ const expectsInfoOnly = fixture.expected.faults.every(
+ (f) => f.severity === "info",
+ );
+ if (!expectsInfoOnly) {
+ // Fixture is mixed-severity: skip the strict quiet/verbose contrast
+ // assertion to keep the test stable across fixture tweaks.
+ return;
+ }
+
+ const quiet = runFixture(fixture, { minSeverity: "warning" });
+ assert.equal(
+ quiet.cues.length,
+ 0,
+ "info-only faults must be suppressed in quiet mode",
+ );
+
+ const verbose = runFixture(fixture, { minSeverity: "info" });
+ assert.ok(
+ verbose.cues.length >= 1,
+ "info-only faults should surface in verbose mode",
+ );
+});
+
+test("same faultType is throttled within the per-fault throttle window", () => {
+ const fixture = loadFixture("rounded-back-critical.json");
+ const { cues } = runFixture(fixture, { perFaultThrottleMs: 60_000 });
+ const rounded = cues.filter(
+ (r) => r.cue.faultType === "rounded_back_at_catch",
+ );
+ assert.equal(
+ rounded.length,
+ 1,
+ `expected throttle to collapse repeated cues; got ${rounded.length}`,
+ );
+});
+
+test("cues are emitted post-stroke, after nextCatchFrameIndex is buffered", () => {
+ const fixture = loadFixture("rounded-back-critical.json");
+ const { cues } = runFixture(fixture);
+ for (const record of cues) {
+ assert.ok(
+ record.bufferedFrameCount >= record.stroke.nextCatchFrameIndex,
+ `cue for stroke ${record.stroke.strokeIndex} fired before stroke completion`,
+ );
+ }
+});
From afe5f37141ff14e4836c09c5c690de1c27068852 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Fri, 8 May 2026 17:11:27 +0200
Subject: [PATCH 15/29] docs: refresh README and DATABASE_SCHEMA to match
current schema, mocap, sync, and scripts
---
README.md | 366 +++++++++++++++++++++-------------
docs/DATABASE_SCHEMA.md | 429 +++++++++++++++++++++++++---------------
2 files changed, 494 insertions(+), 301 deletions(-)
diff --git a/README.md b/README.md
index 6a59a72..58c3c39 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
# Rowing Tracker
-An AI-powered web application for tracking rowing workouts with analytics, training plans, and achievement tracking. Built for rowers who use SmartRow equipment. This app was completely written by AI.
+An AI-powered web application for tracking rowing workouts with analytics, training plans, motion-capture posture analysis, and achievement tracking. Built for rowers who use SmartRow equipment. This app was completely written by AI.
## Overview
-Rowing Tracker is a modern, AI-powered web app built specifically for rowers who use SmartRow equipment. Upload your CSV exports and unlock the power of artificial intelligence to analyze your performance, generate personalized training plans, and receive expert coaching insights. With multi-user support and secure authentication, each rower gets their own private workspace with data stored in PostgreSQL (local or cloud).
+Rowing Tracker is a modern, AI-powered web app built specifically for rowers who use SmartRow equipment. Workouts are imported either via automated SmartRow.fit sync or manual CSV/ZIP upload, and the app turns them into deep analytics, personalized AI training plans, automated coaching insights, and webcam-based posture analysis. Each rower gets a private workspace with data stored in PostgreSQL (local or Supabase) and isolated by authenticated user.
## Features
@@ -23,19 +23,20 @@ Rowing Tracker is a modern, AI-powered web app built specifically for rowers who
### 📊 Analytics & Tracking
- **Dashboard**: Comprehensive overview with key metrics, volume charts, and trend analysis
-- **Advanced Analytics**: Detailed breakdown of performance, split trends, stroke rate, and training adherence
-- **Interactive Chart Explanations**: Click "Explain" on any chart (analytics page AND session details) to get AI-powered analysis—explanations are saved in tooltips for quick reference with "Back to chart" navigation
-- **Time-Range Aware Explanations**: Analytics chart explanations are cached per time range—switching between "Last 7 days" and "Last 30 days" generates separate, context-appropriate AI analyses
+- **Advanced Analytics** (`/analytics`): Detailed breakdown of performance, split trends, stroke rate, consistency score, and training adherence with a uniform legend (teal dot = individual sessions, orange line = 10-session moving average) and synchronized global smoothing controls
+- **Insights View** (`/insights`): Dedicated page for browsing, filtering, archiving, and giving feedback (`helpful` / `not_helpful` / `action_taken`) on AI-generated performance insights, with full-text search across the archive
+- **Interactive Chart Explanations**: Click "Explain" on any chart (analytics page AND session details) to get AI-powered analysis—explanations are cached server-side via `ChartExplanation`, displayed in tooltips for quick reference, and offer "Back to chart" navigation
+- **Time-Range Aware Explanations**: Analytics chart explanations are cached per time range—switching between "Last 7 days" and "Last 30 days" yields separate, context-appropriate AI analyses
- **Structured AI Explanations**: Chart explanations follow a clear format: "Why This Chart Matters" (practical value), "What I See In Your Data" (patterns/trends), and "What This Means For You" (actionable insights)
-- **Performance Correlations**: Explore scatter plots showing relationships between power/pace, stroke rate/pace, duration/distance, energy/duration, and more
+- **Performance Correlations**: Scatter plots showing relationships between power/pace, stroke rate/pace, duration/distance, energy/duration, with Split Time always-on as a permanent correlation chart
- **Sessions List**: Browse, filter, and sort all your rowing sessions with advanced search
- **Session Details**: Deep dive into individual workout metrics with interactive charts and AI explanations across all analysis modules:
- *Overview*: Power & Stroke Rate
- *Performance Graphs*: Pace Analysis, Work per Stroke, Stroke Length, Heart Rate
- *Segments*: Segment Analysis (100m/500m), Rolling Power Average, Rolling Split Average
- *Deep Analysis*: Power Distribution, Rhythm Distribution, Rate vs Power, Rate vs Split
-- **Stroke-by-Stroke Analysis**: Upload SmartRow stroke exports to unlock power/rhythm distributions, stroke-length consistency, and technique maps for every stroke
-- **Personal Records**: Automatic tracking of your best times and performances across all distances
+- **Stroke-by-Stroke Analysis**: Stroke data parsed from SmartRow detailed CSVs is persisted with each session, unlocking power/rhythm distributions, stroke-length consistency, technique maps, and a pre-computed `consistencyScore` per session
+- **Personal Records**: Automatic tracking of best times and performances across all distances
### 🔐 Multi-User & Authentication
@@ -45,14 +46,22 @@ Rowing Tracker is a modern, AI-powered web app built specifically for rowers who
- **User Profiles**: Manage your account, change password, and update profile information
- **Data Isolation**: Each user's data is completely isolated and private
+### � Data Import & Sync
+
+- **Automated SmartRow Sync**: One-click sync from `smartrow.fit` directly inside `/sync`. A server-side Playwright session logs in with stored credentials, exports the workouts list (CSV) and the detailed-stroke archive (ZIP), and imports both in a single pass
+- **CSV Drag-and-Drop**: Manual upload of any SmartRow CSV (sessions list or detailed stroke export)
+- **ZIP Batch Import**: Upload the SmartRow archive ZIP to import many detailed-stroke sessions at once with progress reporting
+- **Duplicate-Safe Imports**: Sessions are deduplicated by `(userId, timestamp, distance)`; existing sessions are updated, not duplicated
+- **Last-Sync Timestamp**: SmartRow credentials and the most recent sync time are stored in `UserSettings.smartRowSettings`
+
### 💾 Data & Storage
-- **CSV Import**: Simple drag-and-drop upload for SmartRow CSV files
-- **PostgreSQL Database**: Robust, scalable data storage with full ACID compliance
-- **Local Development**: Docker-based PostgreSQL for easy local development
-- **Cloud Ready**: Supabase support for production deployments
-- **Memory System**: Upload and store PDFs and images for AI analysis
-- **Data Privacy**: Your data is encrypted and isolated per user
+- **PostgreSQL Database**: Robust, scalable data storage with full ACID compliance via Prisma
+- **Local Development**: Docker-based PostgreSQL plus Mailpit for SMTP
+- **Cloud Ready**: Supabase (pooler + direct URL) supported for production
+- **Memory System**: Upload and store PDFs and images for AI analysis; files are kept on disk (or Vercel Blob in deployed environments) and referenced by `MemoryDocument.filePath`
+- **Mocap Storage**: Recorded video and the `PoseFrameStream` binary blob are stored side-by-side on the same backend (local `storage/` directory or Vercel Blob), referenced by `MocapSession.videoStoragePath` and `MocapSession.poseStreamPath`
+- **Data Privacy**: Workout data, mocap video, and pose data are scoped by user ID; AI keys are encrypted at rest
### 🎨 User Experience
@@ -65,28 +74,50 @@ Rowing Tracker is a modern, AI-powered web app built specifically for rowers who
### 🏅 Gamification & Motivation
- **Dynamic Awards System**: Earn achievements for session milestones (First Splash, Century Club, Year of Rowing), total distance (Million Meter Club), streaks, duration, power output, pace improvements, and more
-- **Improvement Awards**: Track percentage gains in power (up to +100% Double Power) and pace compared to your baseline to unlock progressive tier awards
-- **Streak Milestones**: Stay consistent with notifications for 7-, 14-, 21-, 45-, 60-, and 100-day streaks
-- **Live Award Notifications**: Celebrate wins instantly with animated overlays whenever you unlock something new
+- **Improvement Awards**: Track percentage gains in power (up to +100% Double Power) and pace compared to a baseline computed from the first 3 valid sessions
+- **Streak Milestones**: Notifications for 7-, 14-, 21-, 45-, 60-, and 100-day streaks
+- **AI Award Suggestions**: The AI proposes custom awards (`AIAwardSuggestion`) with structured criteria; once approved they auto-evaluate against your data
+- **Generated Achievement Stories & Art**: Each unlocked award gets an AI-written story and optional generated image (`GeneratedAchievement`), with a configurable `colorPalette` for the artwork
+- **Live Award Notifications**: Animated overlays the moment a new achievement unlocks
- **High-Tier Stretch Goals**: Long-term achievements including 750k meters, 1 Million meters, 100 hours rowing, 300W power, and sub-1:35/500m pace
+### 🎥 Motion-Capture & Posture Analysis (Mocap)
+
+- **Browser-Based Capture** (`/mocap`): Single-webcam recording with in-browser MediaPipe Pose Landmarker running in a Web Worker. Zero install, no cloud upload of video by default.
+- **Side-View Capture Perspectives**: `side-left` or `side-right`. Front-view-only metrics (left/right asymmetry, knee-track deviation) are explicitly marked as `requires-multi-cam` rather than silently estimated.
+- **Per-Session Calibration**: Two reference frames captured before recording (catch + finish) establish baselines for the current camera setup. Calibration is stored on the `MocapSession`, not on the user.
+- **Pose Frame Stream**: A versioned binary `PoseFrameStream` blob (2D `{x, y, confidence}` keypoints + per-frame quality flags) is appended in chunks via `POST /api/mocap/sessions/:id/pose-stream` and finalized with `POST /api/mocap/sessions/:id/finalize`.
+- **Stroke Segmentation**: `pose-segmented` boundaries during live capture; mandatory atomic re-segmentation to `csv-aligned` when a `MocapSession` is linked to a `RowingSession` via cross-correlation against `StrokeData`.
+- **v1 Posture Fault Catalog**: Five stroke-granular faults computable from a 2D side view—`rounded_back_at_catch`, `early_arm_bend`, `back_opens_before_legs_drive`, `excessive_layback`, `slow_recovery_ratio`—each with `info` / `warning` / `critical` severity bands.
+- **Configurable Thresholds**: Conservative hand-coded defaults (`postureThresholdsV1`) with auto-migration on version bumps; user customization stored in `UserSettings.postureThresholds` is preserved (`userOverridden: true`).
+- **Live Coaching Cues**: Post-stroke cues (≤ 1 s after the stroke completes) via the `LiveCoachingEngine`, with optional spoken audio (`speakCue`) and configurable verbosity in `UserSettings.mocapPreferences`.
+- **Auto-Link on CSV Import**: When a CSV import produces a `RowingSession` whose timestamp overlaps a `MocapSession` capture window by ±2 minutes, the user is prompted to link them (never silent). Linking is bidirectional, exclusive, and reversible (`/unlink` endpoint).
+- **Re-Analysis Endpoint**: `POST /api/mocap/sessions/:id/reanalyze` re-runs the segmenter, metrics calculator, and fault detector with current rules so old sessions benefit from updated thresholds.
+- **Cloud-AI Payload Tiers**: AI gets a `PostureFault` summary by default; per-stroke metrics are opt-in via `UserSettings.mocapDetailedAIShare`; raw frames never cross to cloud (see ADR-0004).
+
## Tech Stack
-- **Framework**: Next.js 16 (App Router)
-- **Language**: TypeScript
-- **Styling**: TailwindCSS
-- **Components**: shadcn/ui
-- **Charts**: Recharts
-- **AI Integration**: OpenAI API
-- **Authentication**: NextAuth.js v4
-- **Database**: PostgreSQL with Prisma v7
+- **Framework**: Next.js 16 (App Router, React 19)
+- **Language**: TypeScript 5
+- **Styling**: TailwindCSS 4
+- **Components**: shadcn/ui (Radix UI primitives)
+- **Charts**: Recharts 3
+- **Animations**: Framer Motion
+- **Markdown**: react-markdown + remark-gfm + Shiki for code highlighting
+- **AI Integration**: OpenAI API (chat, image generation, condensation prompts)
+- **Authentication**: NextAuth.js v4 (Credentials, Email magic link, optional Google OAuth)
+- **Database**: PostgreSQL with Prisma v7 and `@prisma/adapter-pg`
- **State Management**: Zustand with persist middleware
-- **Storage**:
- - PostgreSQL for user data, sessions, plans, and achievements
- - File system for award images
-- **CSV Parsing**: papaparse
+- **Storage**:
+ - PostgreSQL for user data, sessions, plans, achievements, mocap rows
+ - File system (or Vercel Blob via `@vercel/blob`) for award images, memory documents, mocap video, and pose stream blobs
+- **Pose Estimation**: `@mediapipe/tasks-vision` Pose Landmarker, Web Worker, WASM
+- **CSV / ZIP Parsing**: `papaparse`, `jszip`
+- **PDF Extraction**: `unpdf`
+- **SmartRow Automation**: `playwright` (server-side login + export download for `/api/smartrow/sync`)
- **Email**: Nodemailer with Mailpit (local) or SMTP (production)
- **Rate Limiting**: Upstash Redis for API protection
+- **Validation**: Zod
- **Development**: Docker Compose for local services
## Quick Start
@@ -155,42 +186,49 @@ Rowing Tracker is a modern, AI-powered web app built specifically for rowers who
9. **Configure AI (Optional)**
- Go to Settings → AI Coach
- - Enter your OpenAI API Key to enable Chat and Training Plans
+ - Enter your OpenAI API Key to enable Chat, Insights, and Training Plans
- Add your Personal Context to inform the AI about medical conditions, preferences, or goals
- - Customize the AI prompts in the Advanced Configuration section
+ - Customize the AI prompts (base, chat, training plan, insights) in Advanced Configuration with one-click "reset to default"
+
+10. **Configure SmartRow Auto-Sync (Optional)**
+ - Go to Settings → SmartRow
+ - Enter your `smartrow.fit` email and password (stored per-user in `UserSettings.smartRowSettings`)
+ - Visit `/sync` and click **Sync Now** to pull all workouts in one pass
+
+11. **Promote a User to Admin (Optional)**
+ ```bash
+ npm run admin:promote --
+ ```
+ Admin users see the **Admin Panel** entry in the user menu and can access `/admin` to manage other users.
+
+## Importing Your SmartRow Data
+
+The `/sync` page offers three import paths. All three deduplicate against existing sessions and update changed records in place.
-## SmartRow CSV Export Guide
+### 1. Automated Sync from smartrow.fit (recommended)
-### How to Export Your Data
+1. Save your SmartRow credentials in **Settings → SmartRow**.
+2. Open `/sync` and click **Sync Now**.
+3. The server runs a Playwright session against `https://smartrow.fit/my-workouts/`, downloads the workouts CSV and the detailed-stroke ZIP, and imports both. The `lastSync` timestamp is updated on completion.
-1. **Connect to SmartRow App**
- - Open the SmartRow mobile app
- - Ensure you're logged in and synced
+### 2. Manual CSV Upload
-2. **Export Sessions**
- - Go to Settings/Profile
- - Find "Export Data" or "CSV Export"
- - Select the date range you want to export
- - Choose CSV format
- - Download the file to your device
+Drag-and-drop a SmartRow CSV onto `/sync` or click to browse. Both the workouts-list CSV and individual detailed-stroke CSVs are accepted.
-3. **Upload to Rowing Tracker**
- - Open the Rowing Tracker web app
- - Drag and drop your CSV file or click to browse
- - Wait for processing (typically instant for most files)
- - Your data will be automatically analyzed and stored
+### 3. ZIP Batch Upload
+
+Drop the SmartRow archive ZIP onto `/sync` to import many detailed-stroke sessions at once with progress reporting (`ZipProcessProgress`).
### CSV Format Requirements
-The app expects SmartRow CSV exports with the following format:
-- **Delimiter**: Semicolon (;)
-- **Decimal Format**: Comma (,) - European format
-- **Timestamp**: YYYY-MM-DD HH:MM:SS.mmm (UTC)
+SmartRow CSV exports use:
+- **Delimiter**: Semicolon (`;`)
+- **Decimal Format**: Comma (`,`) — European format
+- **Timestamp**: `YYYY-MM-DD HH:MM:SS.mmm` (UTC)
- **Time Field**: Seconds
-### Required Columns
+### Required Columns (workouts list)
-Your CSV must include these columns:
- Time stamp (UTC)
- Distance (m)
- Time (seconds)
@@ -198,11 +236,15 @@ Your CSV must include these columns:
- Stroke count (#)
- Average power (W)
- Maximum power (W)
-- Average split (s) - per 500m
+- Average split (s) — per 500m
- Minimum split (s)
- Average stroke rate (SPM)
- Maximum stroke rate (SPM)
+### Detailed-Stroke CSV
+
+Detailed-stroke files (one per session, contained in the SmartRow ZIP) are parsed by `src/lib/strokeParser.ts` into `StrokeData` rows. They drive stroke-by-stroke analysis and the precomputed `consistencyScore` on `RowingSession`.
+
## Deployment
### Supabase (Production)
@@ -242,7 +284,7 @@ Your CSV must include these columns:
Available npm scripts:
```bash
-# Start local Docker services
+# Start local Docker services (PostgreSQL + Mailpit)
npm run db:start
# Stop local Docker services
@@ -260,11 +302,20 @@ npm run db:migrate:deploy
# Reset database (WARNING: deletes all data)
npm run db:reset
+# Seed database (where a seed is configured)
+npm run db:seed
+
# Open Prisma Studio (database GUI)
npm run db:studio
# Push schema without migration (dev only)
npm run db:push
+
+# Promote a user to admin
+npm run admin:promote --
+
+# Backfill consistencyScore for existing sessions (one-off)
+npx tsx scripts/backfill-consistency.ts
```
## Architecture Overview
@@ -272,76 +323,109 @@ npm run db:push
```
rowing-tracker/
├── src/
-│ ├── app/ # Next.js App Router
-│ │ ├── (routes)/ # Route groups
-│ │ │ ├── page.tsx # Dashboard
-│ │ │ ├── sessions/ # Sessions pages
-│ │ │ ├── prs/ # Personal records
-│ │ │ ├── upload/ # CSV upload
-│ │ │ ├── analytics/ # Advanced analytics
-│ │ │ ├── chat/ # AI Coach chat
-│ │ │ ├── plans/ # Training plans
-│ │ │ ├── profile/ # User profile
-│ │ │ └── settings/ # App settings
-│ │ ├── api/ # API routes
-│ │ │ ├── auth/ # NextAuth endpoints
-│ │ │ └── user/ # User management
-│ │ ├── auth/ # Auth pages
-│ │ │ ├── login/ # Login page
-│ │ │ ├── register/ # Registration
-│ │ │ └── verify-email/ # Email verification
-│ │ ├── layout.tsx # Root layout
-│ │ └── globals.css # Global styles
-│ ├── components/ # Reusable UI components
-│ ├── hooks/ # Custom React hooks
-│ ├── lib/ # Utility functions & services
-│ │ ├── auth.ts # NextAuth configuration
-│ │ ├── db/prisma.ts # Prisma client singleton
-│ │ ├── services/ # Service singletons
-│ │ ├── ai/ # AI configuration & prompts
-│ │ └── utils/ # Utilities (CSV parser, awards, etc.)
-│ └── types/ # TypeScript type definitions
-├── prisma/ # Database schema & migrations
+│ ├── app/ # Next.js App Router
+│ │ ├── dashboard/ # Main dashboard
+│ │ ├── analytics/ # Advanced analytics page
+│ │ ├── sessions/ # Session list and detail pages
+│ │ ├── prs/ # Personal records & achievements
+│ │ ├── plans/ # AI training plans
+│ │ ├── chat/ # AI Coach chat
+│ │ ├── insights/ # AI insights archive & feedback
+│ │ ├── mocap/ # Webcam capture + posture replay
+│ │ ├── sync/ # SmartRow sync, CSV/ZIP upload
+│ │ ├── profile/ # User profile
+│ │ ├── settings/ # App settings (AI, SmartRow, posture, ...)
+│ │ ├── admin/ # Admin user management (admin role only)
+│ │ ├── auth/ # Login, register, verify-email, reset
+│ │ ├── api/ # API routes (NextAuth, sessions, mocap, smartrow, ...)
+│ │ ├── layout.tsx
+│ │ └── globals.css
+│ ├── components/ # Reusable UI components (shadcn/ui + custom)
+│ ├── hooks/ # Custom React hooks
+│ ├── lib/ # Utilities & services
+│ │ ├── auth.ts # NextAuth configuration
+│ │ ├── db/prisma.ts # Prisma client singleton
+│ │ ├── services/ # Service singletons
+│ │ ├── mocap/ # Pose source, frame stream, analysis pipeline, coaching
+│ │ │ ├── browserPoseSource.ts
+│ │ │ ├── poseFrameStream.ts
+│ │ │ ├── poseWorker.ts
+│ │ │ ├── analysis/ # Pure functions: segmenter, metrics, fault detector, thresholds
+│ │ │ └── coaching/ # LiveCoachingEngine, CoachingAdvisor, cue audio
+│ │ ├── csvParser.ts
+│ │ ├── strokeParser.ts
+│ │ ├── zipParser.ts
+│ │ ├── awards.ts
+│ │ └── ...
+│ ├── types/ # TypeScript type definitions
+│ └── middleware.ts # Auth + admin route guards
+├── prisma/ # Database schema & migrations
│ └── schema.prisma
-├── docs/ # Documentation
+├── scripts/ # One-off operational scripts
+│ ├── promote-admin.ts
+│ └── backfill-consistency.ts
+├── tests/ # Unit + Playwright e2e tests
+│ ├── *.test.ts # tsx --test
+│ ├── fixtures/mocap/ # Pose-frame fixtures
+│ └── e2e/ # Playwright specs
+├── docs/
│ ├── DATABASE_SCHEMA.md
│ ├── design-system.md
│ ├── prd.md
-│ └── csvs/ # Sample SmartRow CSV files
-└── docker-compose.yml # Local PostgreSQL & Mailpit
+│ ├── prd-mocap-posture.md # Mocap PRD + locked decisions
+│ ├── adr/ # Architecture Decision Records (0001–0004)
+│ ├── agents/ # Agent docs (issue tracker, triage labels, domain)
+│ └── csvs/ # Sample SmartRow CSVs
+├── CONTEXT.md # Domain glossary
+├── AGENTS.md # Engineering rules
+└── docker-compose.yml # Local PostgreSQL & Mailpit
```
### Data Flow
-1. **Authentication**: User registers/logs in → NextAuth validates → JWT session created
-2. **Upload**: User drops CSV file → papaparse processes → validation → saved to PostgreSQL
-3. **Storage**:
- - User data, sessions, plans → PostgreSQL via Prisma
- - Award images → File system
- - Client state → Zustand store (ephemeral)
-4. **Display**: Components fetch from database → calculate metrics → render charts
-5. **Analysis**: Real-time PR calculations, trend analysis, aggregations
-6. **AI Features**: Context retrieved from database → sent to OpenAI → response streamed
-7. **Data Isolation**: All queries filtered by authenticated user ID
+1. **Authentication**: User registers/logs in → NextAuth validates (Credentials / Email magic link / Google OAuth) → JWT session created with `id` and `role`
+2. **Import**:
+ - **Sync**: `/api/smartrow/sync` runs Playwright against smartrow.fit → returns CSV + base64 ZIP → client parses with papaparse / jszip → saved to PostgreSQL
+ - **Manual**: User drops CSV/ZIP → client-side validation → saved to PostgreSQL
+3. **Mocap Capture**: Browser webcam → Pose Landmarker in Web Worker → chunked HTTP uploads of video and `PoseFrameStream` → finalize → stroke segmentation, metrics, and faults computed in browser; server only persists
+4. **Storage**:
+ - User data, sessions, plans, mocap rows → PostgreSQL via Prisma
+ - Award images, memory documents, mocap video, pose stream blobs → file system or Vercel Blob
+ - Client state → Zustand store (persisted to DB on key actions)
+5. **Display**: Components fetch from database → calculate metrics → render charts; cache busts via `UserSettings.sessionsRevision` / `insightsRevision`
+6. **Analysis**: Real-time PR calculations, trend analysis, consistency score, posture metrics, fault counts
+7. **AI Features**: Context (sessions, achievements, memory documents, posture summary) retrieved from database → personal context injected from `UserSettings.userProfileContext` → sent to OpenAI → response streamed
+8. **Linking**: When a `RowingSession` overlaps a `MocapSession` capture window by ±2 minutes, the user is prompted to link; linking triggers atomic re-segmentation to `csv-aligned`
+9. **Data Isolation**: All queries filtered by authenticated user ID; admin endpoints additionally gated via `src/lib/adminAuth.ts`
## Development
### Available Scripts
**Development:**
-- `npm run dev` - Start development server
-- `npm run build` - Build for production
-- `npm run start` - Start production server
-- `npm run lint` - Run ESLint
+- `npm run dev` — Start development server
+- `npm run build` — Build for production
+- `npm run start` — Start production server
+- `npm run lint` — Run ESLint
**Database:**
-- `npm run db:start` - Start PostgreSQL & Mailpit
-- `npm run db:stop` - Stop Docker services
-- `npm run db:generate` - Generate Prisma client
-- `npm run db:migrate` - Run migrations
-- `npm run db:studio` - Open Prisma Studio
-- `npm run db:push` - Push schema (dev only)
-- `npm run db:reset` - Reset database
+- `npm run db:start` — Start PostgreSQL & Mailpit
+- `npm run db:stop` — Stop Docker services
+- `npm run db:generate` — Generate Prisma client
+- `npm run db:migrate` — Run migrations (dev)
+- `npm run db:migrate:deploy` — Apply migrations (prod)
+- `npm run db:studio` — Open Prisma Studio
+- `npm run db:push` — Push schema without migration (dev only)
+- `npm run db:seed` — Seed database (when configured)
+- `npm run db:reset` — Reset database (destructive)
+
+**Tests:**
+- `npm test` — Run unit tests via `tsx --test` (covers `tests/*.test.ts`, including `mocapAnalysis`, `poseFrameStream`, `liveCoachingEngine`, `aiPayload`, etc.)
+- `npm run test:e2e` — Run Playwright end-to-end tests (`tests/e2e/`, e.g. `mocap-capture.spec.ts`)
+
+**Operations:**
+- `npm run admin:promote -- ` — Promote a user to admin
+- `npx tsx scripts/backfill-consistency.ts` — Backfill `consistencyScore` for existing sessions and bump `sessionsRevision` for affected users
### Project Structure
@@ -362,41 +446,46 @@ npx shadcn@latest add button card table badge
## Data Model
-The app uses PostgreSQL with Prisma ORM. Key models:
+PostgreSQL with Prisma ORM. Key models:
**User Management:**
-- `User` - User accounts with authentication
-- `Account` - OAuth provider accounts
-- `AuthSession` - Active sessions
-- `VerificationToken` - Email verification tokens
-- `UserSettings` - User preferences and configuration
-- `UserApiKey` - Encrypted API keys (OpenAI, etc.)
+- `User` — User accounts with authentication and `role` (`user` / `admin`)
+- `Account` — OAuth provider accounts
+- `AuthSession` — Active sessions
+- `VerificationToken` — Email verification + magic-link tokens
+- `PasswordResetToken` — Password reset flow
+- `UserSettings` — Preferences, AI config, SmartRow credentials, posture thresholds, mocap preferences, dashboard/sessions/analytics view state, cache-busting revisions
+- `UserApiKey` — Encrypted per-provider API keys (e.g. OpenAI)
**Rowing Data:**
-- `RowingSession` - Workout sessions with metrics
-- `StrokeData` - Stroke-by-stroke analysis data
-- `PersonalRecord` - Best performances per distance
+- `RowingSession` — Workout sessions with metrics and pre-computed `consistencyScore`
+- `StrokeData` — Stroke-by-stroke analysis data (with optional `strokeLength`)
+- `PersonalRecord` — Best performance per distance
+
+**Mocap (Posture Analysis):**
+- `MocapSession` — One capture per session with video + pose stream paths, `capturePerspective`, calibration frames, `qualityScore`, status
+- `StrokePostureMetric` — Per-stroke posture metrics with `segmentationSource` (`pose-segmented` or `csv-aligned`)
+- `PostureFault` — Detected faults with `faultType`, `severity`, `phase`, `evidenceJson`
**Achievements:**
-- `EarnedAward` - Unlocked achievements
-- `AIAwardSuggestion` - AI-suggested custom awards
-- `GeneratedAchievement` - AI-generated award stories/images
+- `EarnedAward` — Unlocked achievements
+- `AIAwardSuggestion` — AI-suggested custom awards with structured criteria
+- `GeneratedAchievement` — AI-generated story + optional image with `colorPalette`
**Training:**
-- `TrainingPlan` - Multi-week training programs
-- `TrainingWeek` - Weekly training structure
-- `TrainingSession` - Individual planned workouts
-- `TrainingSessionLink` - Links planned to actual sessions
+- `TrainingPlan` — Multi-week training programs with adherence tracking
+- `TrainingWeek` — Weekly structure
+- `TrainingSession` — Individual planned workouts with target zones
+- `TrainingSessionLink` — Links planned sessions to actual `RowingSession` rows
**AI & Memory:**
-- `ChatSession` - AI coach conversations
-- `ChatMessage` - Individual chat messages
-- `AIInsight` - Generated performance insights
-- `MemoryDocument` - Uploaded PDFs/images for AI context
-- `MemoryBlob` - Binary data storage
-- `ChartExplanation` - Cached chart explanations
+- `ChatSession` — AI coach conversations grouped by `category` (`chat`, `explanation`, `plan_analysis`, `insight_discussion`)
+- `ChatMessage` — Individual messages with optional attachments
+- `AIInsight` — Generated performance insights with `priority`, `confidence`, `evidence`, `feedback`
+- `MemoryDocument` — Uploaded PDFs / images / training plans / notes referenced by `filePath`
+- `ChartExplanation` — Cached chart explanations keyed by `(userId, chartId)`
-See `prisma/schema.prisma` for complete schema or `docs/DATABASE_SCHEMA.md` for detailed documentation.
+See `prisma/schema.prisma` for the complete, authoritative schema, and `docs/DATABASE_SCHEMA.md` for the annotated reference.
## Privacy & Data
@@ -443,10 +532,13 @@ Rate limiting is optional but recommended for production. To enable:
### Protected Endpoints
Rate limiting is applied to:
-- `/api/auth/register` - Prevents registration spam
-- `/api/chat` (POST) - Controls AI usage costs
-- `/api/user/delete` - Protects account deletion
-- `/api/user/export` - Prevents data export abuse
+- `/api/auth/register` — Prevents registration spam
+- `/api/auth/forgot-password`, `/api/auth/reset-password` — Limits password-reset abuse
+- `/api/chat` (POST) — Controls AI usage costs
+- `/api/smartrow/sync` — Limits Playwright-driven SmartRow scrapes
+- `/api/mocap/sessions/*` (upload + finalize) — Caps mocap capture write volume
+- `/api/user/delete` — Protects account deletion
+- `/api/user/export` — Prevents data export abuse
## Browser Support
diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md
index 773d0d6..0a23d83 100644
--- a/docs/DATABASE_SCHEMA.md
+++ b/docs/DATABASE_SCHEMA.md
@@ -1,8 +1,10 @@
# Database Schema (Condensed, current)
-- **Stack**: PostgreSQL + Prisma + NextAuth.
-- **Hosting**: Dev via Docker Postgres; Prod via Supabase/Vercel Postgres/Railway.
+- **Stack**: PostgreSQL + Prisma v7 + NextAuth.js v4.
+- **Hosting**: Dev via Docker Postgres; Prod via Supabase (pooler + direct URL) or any managed Postgres.
- **Migrations**: `npx prisma generate` → `npx prisma migrate dev` (dev) / `npx prisma migrate deploy` (prod).
+- **Adapter**: `@prisma/adapter-pg` over `pg` `Pool` (configured in `prisma.config.ts`).
+- **Source of truth**: `prisma/schema.prisma`. This document is a condensed, annotated mirror.
## Core Models (Prisma)
```prisma
@@ -27,24 +29,28 @@ model User {
emailVerified DateTime?
name String?
image String?
- passwordHash String? // For email/password auth
+ passwordHash String? // For email/password auth (bcrypt)
+ role String @default("user") // 'user' | 'admin'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
- accounts Account[]
- sessions AuthSession[]
- rowingSessions RowingSession[]
- personalRecords PersonalRecord[]
- earnedAwards EarnedAward[]
- aiAwards AIAwardSuggestion[]
+ accounts Account[]
+ sessions AuthSession[]
+ rowingSessions RowingSession[]
+ mocapSessions MocapSession[]
+ personalRecords PersonalRecord[]
+ earnedAwards EarnedAward[]
+ aiAwards AIAwardSuggestion[]
generatedAchievements GeneratedAchievement[]
- trainingPlans TrainingPlan[]
- chatSessions ChatSession[]
- aiInsights AIInsight[]
- memoryDocuments MemoryDocument[]
- settings UserSettings?
-
+ trainingPlans TrainingPlan[]
+ chatSessions ChatSession[]
+ aiInsights AIInsight[]
+ memoryDocuments MemoryDocument[]
+ settings UserSettings?
+ apiKeys UserApiKey[]
+ chartExplanations ChartExplanation[]
+
@@index([email])
}
@@ -86,6 +92,17 @@ model VerificationToken {
@@unique([identifier, token])
}
+model PasswordResetToken {
+ id String @id @default(cuid())
+ email String
+ token String @unique
+ expires DateTime
+ createdAt DateTime @default(now())
+
+ @@index([email])
+ @@index([token])
+}
+
// ============================================================================
// ROWING SESSIONS
// ============================================================================
@@ -109,6 +126,7 @@ model RowingSession {
avgStrokeLength Float // meters
avgStrokeRate Float // SPM
maxStrokeRate Float
+ consistencyScore Float? // Pre-computed power-CV-based consistency (0–100)
// Metadata
createdAt DateTime @default(now())
@@ -119,6 +137,7 @@ model RowingSession {
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
strokeData StrokeData[]
+ mocapSession MocapSession?
personalRecords PersonalRecord[]
trainingSessionLinks TrainingSessionLink[]
@@ -146,6 +165,7 @@ model StrokeData {
strokeLength Float?
session RowingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
+ mocapMetrics StrokePostureMetric[]
@@index([sessionId])
@@index([sessionId, strokeIndex])
@@ -172,6 +192,74 @@ model PersonalRecord {
@@index([userId])
}
+// ============================================================================
+// MOCAP (motion-capture posture analysis — see docs/prd-mocap-posture.md)
+// ============================================================================
+
+model MocapSession {
+ id String @id @default(cuid())
+ userId String
+ rowingSessionId String? @unique // Bidirectional, exclusive link to RowingSession
+ videoStoragePath String // Path to recorded video on backend (FS or Vercel Blob)
+ poseStreamPath String // Path to PoseFrameStream binary blob (see ADR-0001)
+ source String // 'browser' | 'sidecar'
+ captureModelVersion String // e.g. 'mediapipe-pose-landmarker-lite@0.10.35'
+ capturePerspective String // 'side-left' | 'side-right' | 'sidecar-3d'
+ captureFps Float
+ calibrationCatchFrame Json? // Per-session catch baseline (encoded pose frame)
+ calibrationFinishFrame Json? // Per-session finish baseline (encoded pose frame)
+ durationSec Float @default(0)
+ qualityScore Float?
+ qualityFlags String[] @default([])
+ status String @default("capturing") // capturing | analyzing | ready | linked
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ rowingSession RowingSession? @relation(fields: [rowingSessionId], references: [id], onDelete: SetNull)
+ strokePostureMetrics StrokePostureMetric[]
+ postureFaults PostureFault[]
+
+ @@index([userId])
+ @@index([userId, createdAt])
+}
+
+model StrokePostureMetric {
+ id String @id @default(cuid())
+ mocapSessionId String
+ strokeIndex Int
+ phaseBoundariesJson Json // catch / drive / finish / recovery boundaries
+ metricsJson Json // back angle, layback, sequencing offsets, etc.
+ segmentationSource String // 'pose-segmented' | 'csv-aligned'
+ strokeDataId String? // Joined to StrokeData when csv-aligned
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ mocapSession MocapSession @relation(fields: [mocapSessionId], references: [id], onDelete: Cascade)
+ strokeData StrokeData? @relation(fields: [strokeDataId], references: [id], onDelete: SetNull)
+
+ @@unique([mocapSessionId, strokeIndex, segmentationSource])
+ @@index([mocapSessionId])
+ @@index([strokeDataId])
+}
+
+model PostureFault {
+ id String @id @default(cuid())
+ mocapSessionId String
+ strokeIndex Int
+ faultType String // see CONTEXT.md PostureFault catalog
+ severity String // 'info' | 'warning' | 'critical'
+ phase String // 'catch' | 'drive' | 'finish' | 'recovery'
+ evidenceJson Json // frame index + metric value + threshold
+ createdAt DateTime @default(now())
+
+ mocapSession MocapSession @relation(fields: [mocapSessionId], references: [id], onDelete: Cascade)
+
+ @@index([mocapSessionId])
+ @@index([mocapSessionId, strokeIndex])
+ @@index([faultType, severity])
+}
+
// ============================================================================
// AWARDS & ACHIEVEMENTS
// ============================================================================
@@ -190,45 +278,43 @@ model EarnedAward {
}
model AIAwardSuggestion {
- id String @id @default(cuid())
- userId String
-
- title String
- description String
- rationale String @db.Text
- status String // 'suggested' | 'approved' | 'earned'
-
+ id String @id @default(cuid())
+ userId String
+ title String
+ description String
+ rationale String @db.Text
+ status String // 'suggested' | 'approved' | 'earned'
+
// Structured criteria for auto-evaluation
- criteriaType String? // 'total_distance', 'single_session_power', etc.
+ criteriaType String? // 'total_distance', 'single_session_power', etc.
criteriaValue Float?
- criteriaComparison String? // 'gte', 'lte', 'eq'
-
- targetDate DateTime?
- suggestedAt DateTime @default(now())
- approvedAt DateTime?
- earnedAt DateTime?
- model String? // AI model used
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
+ criteriaComparison String? // 'gte' | 'lte' | 'eq'
+
+ targetDate DateTime?
+ suggestedAt DateTime @default(now())
+ approvedAt DateTime?
+ earnedAt DateTime?
+ model String? // AI model used
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@index([userId])
@@index([userId, status])
}
model GeneratedAchievement {
- id String @id @default(cuid())
- userId String
- awardId String // Can be static or AI award ID
-
- story String? @db.Text
- imageUrl String? // Path to stored image
- hasImage Boolean @default(false)
-
- earnedAt DateTime?
- generatedAt DateTime @default(now())
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
+ id String @id @default(cuid())
+ userId String
+ awardId String // Can be static or AI award ID
+ story String? @db.Text
+ imageUrl String? // Path to stored image
+ hasImage Boolean @default(false)
+ colorPalette String? @default("classic") // Image color palette
+ earnedAt DateTime?
+ generatedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@unique([userId, awardId])
@@index([userId])
}
@@ -376,26 +462,30 @@ model ChatMessage {
// ============================================================================
model AIInsight {
- id String @id @default(cuid())
- userId String
-
- type String // 'performance' | 'recommendation' | 'trend' | 'achievement' | 'warning'
- title String
- description String @db.Text
- priority String // 'high' | 'medium' | 'low'
- actionable Boolean @default(false)
- confidence Float?
- evidence String[] // Array of evidence strings
- category String?
-
- source String // 'cloud-ai' | 'local-analysis'
- archived Boolean @default(false)
-
- dateGenerated DateTime @default(now())
+ id String @id @default(cuid())
+ userId String
+
+ type String // 'performance' | 'recommendation' | 'trend' | 'achievement' | 'warning'
+ title String
+ description String @db.Text
+ priority String // 'high' | 'medium' | 'low'
+ actionable Boolean @default(false)
+ confidence Float?
+ evidence String[] // Evidence strings backing the insight
+ category String?
+
+ source String // 'cloud-ai' | 'local-analysis'
+ archived Boolean @default(false)
+
+ dateGenerated DateTime @default(now())
archivedAt DateTime?
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
+
+ // User feedback collected from /insights
+ feedback String? // 'helpful' | 'not_helpful' | 'action_taken'
+ feedbackAt DateTime?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@index([userId])
@@index([userId, archived])
@@index([userId, dateGenerated])
@@ -406,111 +496,113 @@ model AIInsight {
// ============================================================================
model MemoryDocument {
- id String @id @default(cuid())
- userId String
-
- name String
- type String // 'image' | 'pdf' | 'training_plan' | 'insight' | 'note'
- source String // 'user' | 'system'
- mimeType String
- size Int // bytes
-
+ id String @id @default(cuid())
+ userId String
+
+ name String
+ type String // 'image' | 'pdf' | 'training_plan' | 'insight' | 'note'
+ source String // 'user' | 'system'
+ mimeType String
+ size Int // bytes
+ filePath String? // FS path or Vercel Blob URL; nullable for system docs
+
description String? @db.Text
extractedText String? @db.Text
tags String[]
-
- // For system documents
- content Json?
- status String? // 'active' | 'archived' for training plans
-
- uploadedAt DateTime @default(now())
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- blob MemoryBlob?
-
+
+ // For system documents (training plans, insights, etc.)
+ content Json?
+ status String? // 'active' | 'archived' for training plans
+
+ uploadedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@index([userId])
@@index([userId, type])
}
-model MemoryBlob {
- id String @id @default(cuid())
- documentId String @unique
-
- data Bytes // Binary data
-
- document MemoryDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
-}
-
// ============================================================================
// USER SETTINGS
// ============================================================================
model UserSettings {
- id String @id @default(cuid())
- userId String @unique
-
+ id String @id @default(cuid())
+ userId String @unique
+
// User Preferences
- theme String @default("system")
- units String @default("metric")
- dateFormat String @default("MM/DD/YYYY")
- timeFormat String @default("24h")
- language String @default("en")
- timeZone String?
- defaultChartType String @default("line")
- animationsEnabled Boolean @default(true)
- showPromptSuggestions Boolean @default(true)
- customPrompts String[]
-
+ theme String @default("system")
+ units String @default("metric")
+ dateFormat String @default("MM/DD/YYYY")
+ timeFormat String @default("24h")
+ language String @default("en")
+ timeZone String? @default("UTC")
+ defaultChartType String @default("line")
+ animationsEnabled Boolean @default(true)
+ showPromptSuggestions Boolean @default(true)
+ customPrompts String[]
+
// Training Settings
- trainingZones Json? // Zone configuration
- preferredMetrics String[]
- weeklyGoalType String @default("sessions")
- weeklyGoalTarget Int @default(3)
- restDayAlerts Boolean @default(true)
- adaptationEnabled Boolean @default(true)
-
+ trainingZones Json?
+ preferredMetrics String[]
+ weeklyGoalType String @default("sessions")
+ weeklyGoalTarget Int @default(3)
+ restDayAlerts Boolean @default(true)
+ adaptationEnabled Boolean @default(true)
+
// Notification Settings
- sessionReminders Boolean @default(false)
- weeklyProgress Boolean @default(true)
- achievementAlerts Boolean @default(true)
- planReminders Boolean @default(true)
- adherenceAlerts Boolean @default(true)
-
- // AI Settings (sensitive - API key stored separately)
- cloudAIEnabled Boolean @default(false)
- maxTokens Int @default(1500)
- aiConfig Json? // Per-use-case config (chat, insights, etc.)
- customPrompts_ai Json? // System prompts, etc.
-
+ sessionReminders Boolean @default(false)
+ weeklyProgress Boolean @default(true)
+ achievementAlerts Boolean @default(true)
+ planReminders Boolean @default(true)
+ adherenceAlerts Boolean @default(true)
+
+ // AI Settings (API keys stored separately in UserApiKey)
+ cloudAIEnabled Boolean @default(false)
+ mocapDetailedAIShare Boolean @default(false) // ADR-0004: opt-in to share per-stroke metrics with cloud AI
+ maxTokens Int @default(1500)
+ aiConfig Json? // Per-use-case AI config (chat, insights, plans, ...)
+ customPromptsAi Json? // Base / chat / plan / insights prompt overrides
+
// Personal context for AI
- userProfileContext String? @db.Text
- userProfileRawInput String? @db.Text
-
- // Dashboard/View Settings
- dashboardSettings Json?
- sessionsViewSettings Json?
+ userProfileContext String? @db.Text
+ userProfileRawInput String? @db.Text
+
+ // Mocap (posture analysis)
+ postureThresholds Json? // Per-fault threshold overrides; respects userOverridden flag
+ mocapPreferences Json? // Capture source default, live-cue verbosity, audio on/off
+
+ // Dashboard / View Settings
+ dashboardSettings Json?
+ sessionsViewSettings Json?
sessionAnalysisSettings Json?
- chartSettings Json?
- analyticsSettings Json?
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ chartSettings Json?
+ analyticsSettings Json?
+
+ // Cache-busting revisions bumped by mutations to invalidate client caches
+ sessionsRevision Int @default(0)
+ insightsRevision Int @default(0)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
-// Separate table for sensitive API keys (encrypted at rest)
+// Separate table for sensitive API keys (AES-256-GCM encrypted at rest)
model UserApiKey {
- id String @id @default(cuid())
- userId String
-
- provider String // 'openai', etc.
- keyHash String // Hashed for verification
- encryptedKey String @db.Text // Encrypted API key
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
+ id String @id @default(cuid())
+ userId String
+
+ provider String // 'openai', etc.
+ keyHash String // Hashed for verification
+ encryptedKey String @db.Text // Encrypted API key
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@unique([userId, provider])
@@index([userId])
}
@@ -520,16 +612,18 @@ model UserApiKey {
// ============================================================================
model ChartExplanation {
- id String @id @default(cuid())
- userId String
- chartId String // Unique identifier for the chart
-
- summary String @db.Text
- fullResponse String @db.Text
- chartTitle String
-
- generatedAt DateTime @default(now())
-
+ id String @id @default(cuid())
+ userId String
+ chartId String // Unique identifier for the chart (may include time-range suffix)
+
+ summary String @db.Text
+ fullResponse String @db.Text
+ chartTitle String
+
+ generatedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
@@unique([userId, chartId])
@@index([userId])
}
@@ -538,7 +632,14 @@ model ChartExplanation {
---
## Notes & Operations
-- Indexes are defined via Prisma; main filters are on `userId`, timestamps, status/archived.
-- Security: all queries scoped by `userId`; API keys encrypted (UserApiKey, AES-256-GCM, hashed); NextAuth enforced on routes.
-- Backups: enable managed backups/PITR; allow user export if needed.
+
+- **Source of truth**: `prisma/schema.prisma`. If this document and the schema disagree, the schema wins.
+- **Indexes**: defined via Prisma; primary filters are `userId`, timestamps, `status` / `archived`, and mocap session id.
+- **Security**: all queries are scoped by `userId`; API keys are AES-256-GCM encrypted (`UserApiKey`); NextAuth is enforced on protected routes; admin endpoints additionally checked through `src/lib/adminAuth.ts`.
+- **Mocap storage**: video and `PoseFrameStream` blobs live on the storage backend (`storage/` directory or Vercel Blob), never in Postgres; see ADR-0001 and ADR-0003.
+- **Cache invalidation**: mutations bump `UserSettings.sessionsRevision` / `insightsRevision` so client caches discard stale data without a full refetch protocol.
+- **Backups**: enable managed backups / PITR on the Postgres host; user-initiated export is available via `/api/user/export`.
+- **One-off scripts**:
+ - `npm run admin:promote -- ` — set `User.role = 'admin'`.
+ - `npx tsx scripts/backfill-consistency.ts` — backfill `RowingSession.consistencyScore` from existing `StrokeData` and bump `sessionsRevision` for affected users.
From 3497e35da68b3a772ef2e1773605c1c20196138d Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:05:36 +0200
Subject: [PATCH 16/29] Implement mocap camera readiness gate (#19)
---
src/app/mocap/page.tsx | 149 ++++++++++---------
src/lib/mocap/cameraReadiness.ts | 246 +++++++++++++++++++++++++++++++
tests/cameraReadiness.test.ts | 121 +++++++++++++++
3 files changed, 449 insertions(+), 67 deletions(-)
create mode 100644 src/lib/mocap/cameraReadiness.ts
create mode 100644 tests/cameraReadiness.test.ts
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index 2b4ac23..b003866 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -13,9 +14,14 @@ import {
BrowserPoseSource,
type PoseSourceStatus,
} from "@/lib/mocap/browserPoseSource";
+import {
+ cameraQualityFlagLabel,
+ evaluateCameraReadiness,
+ type CameraReadinessFrame,
+ type CameraReadinessResult,
+} from "@/lib/mocap/cameraReadiness";
import {
BYTES_PER_FRAME_V1,
- QUALITY_FLAG,
decodeFrame,
} from "@/lib/mocap/poseFrameStream";
import { VideoUploader } from "@/lib/mocap/videoUploader";
@@ -39,9 +45,7 @@ import { settings } from "@/lib/settings";
const CAPTURE_FPS = 30;
const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
const VIDEO_TIMESLICE_MS = 1000;
-const MIN_TRACKED_KEYPOINTS = 20;
-const MIN_MEAN_CONFIDENCE = 0.5;
-const DEGRADED_FRAME_MS = 2000;
+const QUALITY_HISTORY_MS = 5000;
const SEVERITY_WEIGHT: Record = {
critical: 3,
@@ -122,8 +126,9 @@ export default function MocapCapturePage() {
const sourceRef = useRef(null);
const calibrationSourceRef = useRef(null);
const startedAtRef = useRef(0);
- const degradedSinceRef = useRef(null);
const latestPoseFrameRef = useRef(EMPTY_QUALITY);
+ const qualityHistoryRef = useRef([]);
+ const latestCameraReadinessRef = useRef(null);
const engineRef = useRef(null);
const cueDismissTimerRef = useRef | null>(null);
const audioEnabledRef = useRef(false);
@@ -139,6 +144,8 @@ export default function MocapCapturePage() {
);
const [elapsedSec, setElapsedSec] = useState(0);
const [quality, setQuality] = useState(EMPTY_QUALITY);
+ const [cameraReadiness, setCameraReadiness] =
+ useState(null);
const [framingDegraded, setFramingDegraded] = useState(false);
const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
const [recordOnly, setRecordOnly] = useState(false);
@@ -215,33 +222,50 @@ export default function MocapCapturePage() {
setFramesEncoded(info.framesEncoded);
setQuality(nextQuality);
+ const nowMs = Date.now();
+ const decodedFrame = decodeBase64PoseFrame(info.poseFrameBase64);
+ qualityHistoryRef.current = [
+ ...qualityHistoryRef.current.filter(
+ (frame) => frame.timestampMs >= nowMs - QUALITY_HISTORY_MS,
+ ),
+ {
+ timestampMs: nowMs,
+ trackedKeypointCount: info.trackedKeypointCount,
+ meanConfidence: info.meanConfidence,
+ qualityFlags: info.qualityFlags,
+ keypoints: Array.isArray(decodedFrame?.keypoints)
+ ? decodedFrame.keypoints
+ : undefined,
+ },
+ ];
+ const readiness = evaluateCameraReadiness(qualityHistoryRef.current, {
+ capturePerspective: perspective,
+ nowMs,
+ });
+ latestCameraReadinessRef.current = readiness;
+ setCameraReadiness(readiness);
+
// Feed the live coaching engine when active.
if (engineRef.current) {
- const frame = decodeBase64PoseFrame(info.poseFrameBase64);
- if (frame) engineRef.current.pushFrame(frame);
+ if (decodedFrame) engineRef.current.pushFrame(decodedFrame);
}
if (!monitorDegradedFraming) return;
- const degraded = isDegradedFraming(nextQuality);
- if (!degraded) {
- degradedSinceRef.current = null;
+ if (!readiness.sustainedDegraded) {
setFramingDegraded(false);
return;
}
- const now = Date.now();
- degradedSinceRef.current ??= now;
- if (now - degradedSinceRef.current >= DEGRADED_FRAME_MS) {
- setFramingDegraded(true);
- setSessionQualityFlags((flags) =>
- flags.includes("framing-degraded")
- ? flags
- : [...flags, "framing-degraded"],
- );
- }
+ setFramingDegraded(true);
+ setSessionQualityFlags((flags) =>
+ appendUniqueFlags(flags, [
+ "camera-readiness-degraded",
+ ...readiness.qualityFlags.filter((flag) => flag !== "ok"),
+ ]),
+ );
},
- [],
+ [perspective],
);
const teardown = useCallback(async () => {
@@ -297,6 +321,9 @@ export default function MocapCapturePage() {
setPoseStatus("idle");
setFramesEncoded(0);
setQuality(EMPTY_QUALITY);
+ setCameraReadiness(null);
+ qualityHistoryRef.current = [];
+ latestCameraReadinessRef.current = null;
latestPoseFrameRef.current = EMPTY_QUALITY;
try {
if (!streamRef.current) {
@@ -334,7 +361,7 @@ export default function MocapCapturePage() {
const captureCalibrationFrame = useCallback(
(pose: CalibrationPose) => {
const latest = latestPoseFrameRef.current;
- if (!isCameraReadyForCapture(latest)) {
+ if (!latestCameraReadinessRef.current?.ready) {
setCalibration((current) => ({
...current,
hint:
@@ -377,7 +404,7 @@ export default function MocapCapturePage() {
const start = useCallback(async () => {
const calibrationFrames = getCalibrationFrames(calibration);
- if (!calibrationFrames || !isCameraReadyForCapture(latestPoseFrameRef.current)) {
+ if (!calibrationFrames || !latestCameraReadinessRef.current?.ready) {
setCalibration((current) => ({
...current,
hint:
@@ -492,7 +519,9 @@ export default function MocapCapturePage() {
setFramesEncoded(0);
setFramingDegraded(false);
setSessionQualityFlags([]);
- degradedSinceRef.current = null;
+ qualityHistoryRef.current = [];
+ latestCameraReadinessRef.current = null;
+ setCameraReadiness(null);
setState({
kind: "capturing",
sessionId,
@@ -642,7 +671,7 @@ export default function MocapCapturePage() {
: !("finishFrame" in calibration && calibration.finishFrame)
? "finish"
: null;
- const cameraReady = isCameraReadyForCapture(quality);
+ const cameraReady = cameraReadiness?.ready ?? false;
const canRecord =
state.kind !== "capturing" &&
state.kind !== "starting" &&
@@ -790,6 +819,15 @@ export default function MocapCapturePage() {
+ {cameraReadiness && !cameraReady ? (
+
+ {cameraReadiness.message}
+
+ ) : null}
+
{calibration.hint ? (
- Framing degraded. Check lighting and keep the rower fully in the
- side view.
+ Camera readiness degraded for several seconds. Check lighting,
+ tracking, and side-view framing before continuing.
) : null}
@@ -834,21 +872,20 @@ export default function MocapCapturePage() {
0
- ? (framesEncoded / elapsedSec).toFixed(1)
- : "0.0"
- }
+ value={(cameraReadiness?.effectiveFps ?? 0).toFixed(1)}
/>
+
-
{state.kind === "done" ? (
@@ -865,21 +902,21 @@ export default function MocapCapturePage() {
) : null}
@@ -960,38 +997,16 @@ function getCalibrationFrames(
return null;
}
-function isCameraReadyForCapture(quality: PoseQuality): boolean {
- return (
- quality.trackedKeypointCount >= MIN_TRACKED_KEYPOINTS &&
- quality.meanConfidence >= MIN_MEAN_CONFIDENCE &&
- (quality.qualityFlags & QUALITY_FLAG.OUT_OF_FRAME) === 0
- );
-}
-
-function isDegradedFraming(quality: PoseQuality): boolean {
- return (
- quality.trackedKeypointCount < MIN_TRACKED_KEYPOINTS ||
- quality.meanConfidence < MIN_MEAN_CONFIDENCE ||
- (quality.qualityFlags &
- (QUALITY_FLAG.OUT_OF_FRAME | QUALITY_FLAG.LOW_CONFIDENCE)) !==
- 0
- );
-}
-
function qualityScoreFor(quality: PoseQuality): number {
const trackedRatio = quality.trackedKeypointCount / 33;
return Math.max(0, Math.min(1, quality.meanConfidence * trackedRatio));
}
-function qualityFlagLabel(quality: PoseQuality): string {
- const labels = [];
- if ((quality.qualityFlags & QUALITY_FLAG.OUT_OF_FRAME) !== 0) {
- labels.push("out");
- }
- if ((quality.qualityFlags & QUALITY_FLAG.LOW_CONFIDENCE) !== 0) {
- labels.push("low");
- }
- return labels.length > 0 ? labels.join(", ") : "ok";
+function appendUniqueFlags(
+ current: readonly string[],
+ next: readonly string[],
+): string[] {
+ return [...new Set([...current, ...next])];
}
function SessionCoachingSummary({ faults }: { faults: PostureFault[] }) {
diff --git a/src/lib/mocap/cameraReadiness.ts b/src/lib/mocap/cameraReadiness.ts
new file mode 100644
index 0000000..be0c577
--- /dev/null
+++ b/src/lib/mocap/cameraReadiness.ts
@@ -0,0 +1,246 @@
+import { QUALITY_FLAG } from "./poseFrameStream";
+import {
+ POSE_LANDMARK_INDEX,
+ landmarkSide,
+ type CapturePerspective,
+ type PoseLandmarkName,
+ type PosePoint,
+} from "./analysis/types";
+
+const MIN_TRACKED_KEYPOINTS = 20;
+const MIN_MEAN_CONFIDENCE = 0.5;
+const MIN_GOOD_FRAME_RATIO = 0.7;
+const MIN_READINESS_FRAMES = 5;
+const READINESS_WINDOW_MS = 1200;
+const DEGRADATION_WINDOW_MS = 2000;
+const MIN_SIDE_LANDMARK_CONFIDENCE = 0.4;
+const FRAME_EDGE_MARGIN = 0.03;
+const MIN_SIDE_BODY_HEIGHT = 0.2;
+const MIN_SIDE_BODY_WIDTH = 0.12;
+
+const SIDE_LANDMARKS = {
+ left: ["leftShoulder", "leftHip", "leftKnee", "leftAnkle"],
+ right: ["rightShoulder", "rightHip", "rightKnee", "rightAnkle"],
+} as const satisfies Record<"left" | "right", readonly PoseLandmarkName[]>;
+
+export type CameraReadinessFrame = {
+ timestampMs: number;
+ trackedKeypointCount: number;
+ meanConfidence: number;
+ qualityFlags: number;
+ keypoints?: readonly PosePoint[];
+};
+
+export type CameraReadinessResult = {
+ ready: boolean;
+ sustainedDegraded: boolean;
+ effectiveFps: number;
+ modelConfidence: number;
+ trackedKeypointCount: number;
+ qualityFlags: string[];
+ message: string;
+ goodFrameRatio: number;
+ sideViewCoverage: {
+ ok: boolean;
+ visibleSideLandmarks: number;
+ requiredSideLandmarks: number;
+ bodyHeight: number;
+ bodyWidth: number;
+ };
+};
+
+export function evaluateCameraReadiness(
+ frames: readonly CameraReadinessFrame[],
+ opts: {
+ capturePerspective: CapturePerspective;
+ nowMs?: number;
+ readinessWindowMs?: number;
+ degradationWindowMs?: number;
+ },
+): CameraReadinessResult {
+ const nowMs = opts.nowMs ?? frames.at(-1)?.timestampMs ?? 0;
+ const readinessWindowMs = opts.readinessWindowMs ?? READINESS_WINDOW_MS;
+ const degradationWindowMs = opts.degradationWindowMs ?? DEGRADATION_WINDOW_MS;
+ const recent = frames.filter(
+ (frame) => frame.timestampMs >= nowMs - readinessWindowMs,
+ );
+ const degradedWindow = frames.filter(
+ (frame) => frame.timestampMs >= nowMs - degradationWindowMs,
+ );
+ const latest = recent.at(-1) ?? frames.at(-1);
+ const sideViewCoverage = latest
+ ? evaluateSideViewCoverage(latest.keypoints, opts.capturePerspective)
+ : emptySideViewCoverage();
+
+ if (!latest || recent.length === 0) {
+ return {
+ ready: false,
+ sustainedDegraded: false,
+ effectiveFps: 0,
+ modelConfidence: 0,
+ trackedKeypointCount: 0,
+ qualityFlags: ["no-pose"],
+ message: "Waiting for pose tracking.",
+ goodFrameRatio: 0,
+ sideViewCoverage,
+ };
+ }
+
+ const goodFrames = recent.filter((frame) =>
+ isGoodReadinessFrame(frame, opts.capturePerspective),
+ );
+ const degradedFrames = degradedWindow.filter(
+ (frame) => !isGoodReadinessFrame(frame, opts.capturePerspective),
+ );
+ const goodFrameRatio = goodFrames.length / recent.length;
+ const sustainedDegraded =
+ degradedWindow.length >= MIN_READINESS_FRAMES &&
+ degradedFrames.length / degradedWindow.length >= MIN_GOOD_FRAME_RATIO &&
+ windowDurationMs(degradedWindow) >= degradationWindowMs * 0.75;
+
+ const flags = new Set();
+ collectFrameFlags(latest, sideViewCoverage, flags);
+ if (recent.length < MIN_READINESS_FRAMES) flags.add("warming-up");
+ if (goodFrameRatio < MIN_GOOD_FRAME_RATIO) flags.add("unstable-pose-quality");
+ if (sustainedDegraded) flags.add("sustained-degradation");
+
+ const ready =
+ recent.length >= MIN_READINESS_FRAMES &&
+ goodFrameRatio >= MIN_GOOD_FRAME_RATIO &&
+ isGoodReadinessFrame(latest, opts.capturePerspective);
+
+ return {
+ ready,
+ sustainedDegraded,
+ effectiveFps: effectiveFps(recent),
+ modelConfidence: average(recent.map((frame) => frame.meanConfidence)),
+ trackedKeypointCount: Math.round(
+ average(recent.map((frame) => frame.trackedKeypointCount)),
+ ),
+ qualityFlags: flags.size > 0 ? [...flags] : ["ok"],
+ message: ready
+ ? "Camera ready."
+ : "Keep the rower fully in the side view until tracking stabilizes.",
+ goodFrameRatio,
+ sideViewCoverage,
+ };
+}
+
+export function cameraQualityFlagLabel(flags: readonly string[]): string {
+ const readable: Record = {
+ ok: "OK",
+ "no-pose": "No pose",
+ "low-keypoint-coverage": "Low keypoint coverage",
+ "low-confidence": "Low confidence",
+ "out-of-frame": "Out of frame",
+ "poor-side-view": "Poor side view",
+ "warming-up": "Warming up",
+ "unstable-pose-quality": "Unstable pose quality",
+ "sustained-degradation": "Sustained degradation",
+ };
+ return flags.map((flag) => readable[flag] ?? flag).join(", ");
+}
+
+function isGoodReadinessFrame(
+ frame: CameraReadinessFrame,
+ capturePerspective: CapturePerspective,
+): boolean {
+ return (
+ frame.trackedKeypointCount >= MIN_TRACKED_KEYPOINTS &&
+ frame.meanConfidence >= MIN_MEAN_CONFIDENCE &&
+ (frame.qualityFlags &
+ (QUALITY_FLAG.OUT_OF_FRAME | QUALITY_FLAG.LOW_CONFIDENCE)) ===
+ 0 &&
+ evaluateSideViewCoverage(frame.keypoints, capturePerspective).ok
+ );
+}
+
+function collectFrameFlags(
+ frame: CameraReadinessFrame,
+ sideViewCoverage: CameraReadinessResult["sideViewCoverage"],
+ flags: Set,
+): void {
+ if (frame.trackedKeypointCount === 0 && frame.meanConfidence === 0) {
+ flags.add("no-pose");
+ }
+ if (frame.trackedKeypointCount < MIN_TRACKED_KEYPOINTS) {
+ flags.add("low-keypoint-coverage");
+ }
+ if (
+ frame.meanConfidence < MIN_MEAN_CONFIDENCE ||
+ (frame.qualityFlags & QUALITY_FLAG.LOW_CONFIDENCE) !== 0
+ ) {
+ flags.add("low-confidence");
+ }
+ if ((frame.qualityFlags & QUALITY_FLAG.OUT_OF_FRAME) !== 0) {
+ flags.add("out-of-frame");
+ }
+ if (!sideViewCoverage.ok) {
+ flags.add("poor-side-view");
+ }
+}
+
+function evaluateSideViewCoverage(
+ keypoints: readonly PosePoint[] | undefined,
+ capturePerspective: CapturePerspective,
+): CameraReadinessResult["sideViewCoverage"] {
+ if (!keypoints) return emptySideViewCoverage();
+
+ const side = landmarkSide(capturePerspective);
+ const points = SIDE_LANDMARKS[side]
+ .map((name) => keypoints[POSE_LANDMARK_INDEX[name]])
+ .filter(isVisibleSidePoint);
+ if (points.length === 0) return emptySideViewCoverage();
+
+ const xs = points.map((point) => point.x);
+ const ys = points.map((point) => point.y);
+ const bodyHeight = Math.max(...ys) - Math.min(...ys);
+ const bodyWidth = Math.max(...xs) - Math.min(...xs);
+ return {
+ ok:
+ points.length === SIDE_LANDMARKS[side].length &&
+ bodyHeight >= MIN_SIDE_BODY_HEIGHT &&
+ bodyWidth >= MIN_SIDE_BODY_WIDTH,
+ visibleSideLandmarks: points.length,
+ requiredSideLandmarks: SIDE_LANDMARKS[side].length,
+ bodyHeight,
+ bodyWidth,
+ };
+}
+
+function isVisibleSidePoint(point: PosePoint | undefined): point is PosePoint {
+ return (
+ Boolean(point) &&
+ point!.confidence >= MIN_SIDE_LANDMARK_CONFIDENCE &&
+ point!.x >= FRAME_EDGE_MARGIN &&
+ point!.x <= 1 - FRAME_EDGE_MARGIN &&
+ point!.y >= FRAME_EDGE_MARGIN &&
+ point!.y <= 1 - FRAME_EDGE_MARGIN
+ );
+}
+
+function emptySideViewCoverage(): CameraReadinessResult["sideViewCoverage"] {
+ return {
+ ok: false,
+ visibleSideLandmarks: 0,
+ requiredSideLandmarks: 4,
+ bodyHeight: 0,
+ bodyWidth: 0,
+ };
+}
+
+function effectiveFps(frames: readonly CameraReadinessFrame[]): number {
+ if (frames.length < 2) return 0;
+ const durationSec = windowDurationMs(frames) / 1000;
+ return durationSec > 0 ? (frames.length - 1) / durationSec : 0;
+}
+
+function windowDurationMs(frames: readonly CameraReadinessFrame[]): number {
+ if (frames.length < 2) return 0;
+ return frames[frames.length - 1].timestampMs - frames[0].timestampMs;
+}
+
+function average(values: readonly number[]): number {
+ if (values.length === 0) return 0;
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
diff --git a/tests/cameraReadiness.test.ts b/tests/cameraReadiness.test.ts
new file mode 100644
index 0000000..6886e2f
--- /dev/null
+++ b/tests/cameraReadiness.test.ts
@@ -0,0 +1,121 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ evaluateCameraReadiness,
+ type CameraReadinessFrame,
+} from "../src/lib/mocap/cameraReadiness";
+import { QUALITY_FLAG } from "../src/lib/mocap/poseFrameStream";
+import {
+ POSE_LANDMARK_INDEX,
+ type PosePoint,
+} from "../src/lib/mocap/analysis/types";
+
+function makeFrame(
+ timestampMs: number,
+ overrides: Partial = {},
+): CameraReadinessFrame {
+ return {
+ timestampMs,
+ trackedKeypointCount: 28,
+ meanConfidence: 0.82,
+ qualityFlags: 0,
+ keypoints: makeSideViewKeypoints("right"),
+ ...overrides,
+ };
+}
+
+function makeSideViewKeypoints(side: "left" | "right"): PosePoint[] {
+ const keypoints = Array.from({ length: 33 }, () => ({
+ x: 0.5,
+ y: 0.5,
+ confidence: 0.85,
+ }));
+ const names =
+ side === "left"
+ ? ["leftShoulder", "leftHip", "leftKnee", "leftAnkle"]
+ : ["rightShoulder", "rightHip", "rightKnee", "rightAnkle"];
+ const positions = [
+ { x: 0.42, y: 0.25 },
+ { x: 0.5, y: 0.48 },
+ { x: 0.62, y: 0.66 },
+ { x: 0.72, y: 0.82 },
+ ];
+ for (const [i, name] of names.entries()) {
+ keypoints[POSE_LANDMARK_INDEX[name as keyof typeof POSE_LANDMARK_INDEX]] =
+ {
+ ...positions[i],
+ confidence: 0.9,
+ };
+ }
+ return keypoints;
+}
+
+test("camera readiness accepts sustained good pose quality and side-view coverage", () => {
+ const frames = Array.from({ length: 6 }, (_, i) => makeFrame(i * 200));
+ const result = evaluateCameraReadiness(frames, {
+ capturePerspective: "side-right",
+ nowMs: 1000,
+ });
+
+ assert.equal(result.ready, true);
+ assert.equal(result.sustainedDegraded, false);
+ assert.deepEqual(result.qualityFlags, ["ok"]);
+ assert.equal(result.sideViewCoverage.ok, true);
+ assert.ok(result.effectiveFps > 0);
+});
+
+test("camera readiness rejects borderline unstable history without marking sustained degradation", () => {
+ const frames = [
+ makeFrame(0),
+ makeFrame(200),
+ makeFrame(400, {
+ trackedKeypointCount: 16,
+ meanConfidence: 0.45,
+ qualityFlags: QUALITY_FLAG.LOW_CONFIDENCE,
+ }),
+ makeFrame(600),
+ makeFrame(800, {
+ trackedKeypointCount: 15,
+ meanConfidence: 0.42,
+ qualityFlags: QUALITY_FLAG.LOW_CONFIDENCE,
+ }),
+ makeFrame(1000),
+ ];
+
+ const result = evaluateCameraReadiness(frames, {
+ capturePerspective: "side-right",
+ nowMs: 1000,
+ });
+
+ assert.equal(result.ready, false);
+ assert.equal(result.sustainedDegraded, false);
+ assert.ok(result.qualityFlags.includes("unstable-pose-quality"));
+});
+
+test("camera readiness flags failed side-view framing and sustained degradation", () => {
+ const poorSideView = makeSideViewKeypoints("right").map((point, index) =>
+ index === POSE_LANDMARK_INDEX.rightAnkle
+ ? { ...point, x: 0.99, confidence: 0.2 }
+ : point,
+ );
+ const frames = Array.from({ length: 12 }, (_, i) =>
+ makeFrame(i * 200, {
+ trackedKeypointCount: 12,
+ meanConfidence: 0.35,
+ qualityFlags: QUALITY_FLAG.OUT_OF_FRAME | QUALITY_FLAG.LOW_CONFIDENCE,
+ keypoints: poorSideView,
+ }),
+ );
+
+ const result = evaluateCameraReadiness(frames, {
+ capturePerspective: "side-right",
+ nowMs: 2200,
+ });
+
+ assert.equal(result.ready, false);
+ assert.equal(result.sustainedDegraded, true);
+ assert.equal(result.sideViewCoverage.ok, false);
+ assert.ok(result.qualityFlags.includes("low-keypoint-coverage"));
+ assert.ok(result.qualityFlags.includes("poor-side-view"));
+ assert.ok(result.qualityFlags.includes("sustained-degradation"));
+});
From c7e324acd078d71ebd20a86188ff428521906843 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:13:01 +0200
Subject: [PATCH 17/29] feat(mocap): confirm CSV import links
---
.../[id]/link/[rowingSessionId]/route.ts | 46 ++++++-
src/app/sync/page.tsx | 117 ++++++++++++++++--
src/lib/mocap/linking.ts | 72 +++++++++++
tests/mocapLinking.test.ts | 46 +++++++
4 files changed, 264 insertions(+), 17 deletions(-)
create mode 100644 src/lib/mocap/linking.ts
create mode 100644 tests/mocapLinking.test.ts
diff --git a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
index b9d65dd..3e42665 100644
--- a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
+++ b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts
@@ -75,11 +75,27 @@ export async function POST(
);
}
- // Atomically create the link and set status to "analyzing"
- await prisma.mocapSession.update({
- where: { id },
- data: { rowingSessionId, status: "analyzing" },
- });
+ try {
+ const linkUpdate = await prisma.mocapSession.updateMany({
+ where: { id, userId, rowingSessionId: null, status: "ready" },
+ data: { rowingSessionId, status: "analyzing" },
+ });
+
+ if (linkUpdate.count !== 1) {
+ return NextResponse.json(
+ { error: "Mocap session is already linked to a rowing session. Unlink first." },
+ { status: 409 },
+ );
+ }
+ } catch (err) {
+ if ((err as { code?: string })?.code === "P2002") {
+ return NextResponse.json(
+ { error: "Rowing session is already linked to another mocap session." },
+ { status: 409 },
+ );
+ }
+ throw err;
+ }
const storage = getMocapStorage();
@@ -103,6 +119,26 @@ export async function POST(
select: { id: true, rowingSessionId: true, status: true },
});
+ await prisma.userSettings.upsert({
+ where: { userId },
+ update: {
+ sessionsRevision: { increment: 1 },
+ },
+ create: {
+ userId,
+ theme: "system",
+ units: "metric",
+ dateFormat: "MM/DD/YYYY",
+ timeFormat: "24h",
+ language: "en",
+ defaultChartType: "line",
+ animationsEnabled: true,
+ cloudAIEnabled: false,
+ maxTokens: 4000,
+ sessionsRevision: 1,
+ },
+ });
+
return NextResponse.json({
id: updated.id,
rowingSessionId: updated.rowingSessionId,
diff --git a/src/app/sync/page.tsx b/src/app/sync/page.tsx
index 01da73a..71a6181 100644
--- a/src/app/sync/page.tsx
+++ b/src/app/sync/page.tsx
@@ -7,6 +7,8 @@ import { processZipFile, ZipImportResult, ZipProcessProgress } from '@/lib/zipPa
import { formatValidationErrors, hasCriticalErrors } from '@/lib/validation';
import { ImportResult, Session } from '@/types/session';
import { saveSessionsToDBChunked, UploadProgress } from '@/lib/dataSync';
+import { clearSessionsCache } from '@/lib/services/sessionsCache';
+import { confirmMocapSessionLink } from '@/lib/mocap/linking';
import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -14,9 +16,13 @@ import { Progress } from '@/components/ui/progress';
import { Upload, FileText, AlertCircle, CheckCircle, ArrowRight, FileArchive, Database, RefreshCw, Settings, Video } from 'lucide-react';
import Link from 'next/link';
+type MocapOverlapStatus = 'idle' | 'linking' | 'linked' | 'conflict' | 'error';
+
interface MocapOverlap {
rowingSessionId: string;
mocapSessionId: string;
+ status?: MocapOverlapStatus;
+ message?: string;
}
type UploadState = 'idle' | 'dragging' | 'validating' | 'processing' | 'saving' | 'syncing' | 'success' | 'error';
@@ -87,7 +93,10 @@ export default function UploadPage() {
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.overlaps) && data.overlaps.length > 0) {
- setMocapOverlaps(data.overlaps);
+ setMocapOverlaps(data.overlaps.map((overlap: MocapOverlap) => ({
+ ...overlap,
+ status: 'idle',
+ })));
setDismissedOverlaps(false);
}
} catch {
@@ -95,6 +104,62 @@ export default function UploadPage() {
}
}, []);
+ const handleConfirmMocapLink = useCallback(async (overlap: MocapOverlap) => {
+ const matchesOverlap = (candidate: MocapOverlap) =>
+ candidate.mocapSessionId === overlap.mocapSessionId &&
+ candidate.rowingSessionId === overlap.rowingSessionId;
+
+ setMocapOverlaps((current) => current.map((candidate) =>
+ matchesOverlap(candidate)
+ ? { ...candidate, status: 'linking', message: undefined }
+ : candidate
+ ));
+
+ try {
+ const result = await confirmMocapSessionLink(overlap);
+
+ if (result.ok) {
+ const linkedSession = getSessions().find((session) => session.id === result.rowingSessionId);
+ if (linkedSession) {
+ updateSessionsInStore([
+ {
+ ...linkedSession,
+ mocapSession: { id: result.mocapSessionId },
+ },
+ ]);
+ }
+ clearSessionsCache();
+
+ setMocapOverlaps((current) => current.map((candidate) =>
+ matchesOverlap(candidate)
+ ? { ...candidate, status: 'linked', message: 'Linked and re-analyzed with csv-aligned posture data.' }
+ : candidate
+ ));
+ return;
+ }
+
+ setMocapOverlaps((current) => current.map((candidate) =>
+ matchesOverlap(candidate)
+ ? {
+ ...candidate,
+ status: result.reason === 'conflict' ? 'conflict' : 'error',
+ message: result.message,
+ }
+ : candidate
+ ));
+ } catch (err) {
+ setMocapOverlaps((current) => current.map((candidate) =>
+ matchesOverlap(candidate)
+ ? {
+ ...candidate,
+ status: 'error',
+ message: err instanceof Error ? err.message : 'Failed to link mocap session.',
+ }
+ : candidate
+ ));
+ }
+ }, [getSessions, updateSessionsInStore]);
+
const processFile = async (file: File) => {
setSelectedFile(file);
setError('');
@@ -103,6 +168,8 @@ export default function UploadPage() {
setZipResult(null);
setUploadProgress(null);
setZipProgress(null);
+ setMocapOverlaps([]);
+ setDismissedOverlaps(false);
try {
// Check if it's a ZIP file
@@ -690,16 +757,42 @@ export default function UploadPage() {
? 'A motion-capture session was recorded within 2 minutes of your imported rowing session. Link them to enable csv-aligned posture analysis.'
: `${mocapOverlaps.length} motion-capture sessions were recorded within 2 minutes of your imported rowing sessions. Link them to enable csv-aligned posture analysis.`}
-
+
{mocapOverlaps.map((overlap) => (
-
-
- View Mocap Session
-
+
+
+ handleConfirmMocapLink(overlap)}
+ disabled={overlap.status === 'linking' || overlap.status === 'linked'}
+ data-testid={`mocap-link-${overlap.mocapSessionId}`}
+ >
+ {overlap.status === 'linking' ? (
+
+ ) : overlap.status === 'linked' ? (
+
+ ) : (
+
+ )}
+ {overlap.status === 'linked' ? 'Linked' : 'Link Mocap'}
+
+
+
+
+ View
+
+
+
+ {overlap.message && (
+
+ {overlap.message}
+
+ )}
+
))}
setDismissedOverlaps(true)}
@@ -719,10 +812,10 @@ export default function UploadPage() {
Upload Another File
-
+
Go to Dashboard
-
+
diff --git a/src/lib/mocap/linking.ts b/src/lib/mocap/linking.ts
new file mode 100644
index 0000000..be9d896
--- /dev/null
+++ b/src/lib/mocap/linking.ts
@@ -0,0 +1,72 @@
+export interface MocapLinkTarget {
+ rowingSessionId: string;
+ mocapSessionId: string;
+}
+
+export type MocapLinkFailureReason =
+ | "analysis_failed"
+ | "conflict"
+ | "not_found"
+ | "unauthorized"
+ | "error";
+
+export type MocapLinkResult =
+ | {
+ ok: true;
+ mocapSessionId: string;
+ rowingSessionId: string;
+ status: string;
+ }
+ | {
+ ok: false;
+ reason: MocapLinkFailureReason;
+ status: number;
+ message: string;
+ };
+
+type FetchLike = (input: string, init?: RequestInit) => Promise;
+
+async function readErrorMessage(response: Response): Promise {
+ try {
+ const data = await response.json();
+ return typeof data?.error === "string" ? data.error : "Failed to link mocap session";
+ } catch {
+ return "Failed to link mocap session";
+ }
+}
+
+function failureReasonForStatus(status: number): MocapLinkFailureReason {
+ if (status === 401) return "unauthorized";
+ if (status === 404) return "not_found";
+ if (status === 409) return "conflict";
+ if (status >= 500) return "analysis_failed";
+ return "error";
+}
+
+export async function confirmMocapSessionLink(
+ target: MocapLinkTarget,
+ fetchImpl: FetchLike = fetch,
+): Promise {
+ const response = await fetchImpl(
+ `/api/mocap/sessions/${encodeURIComponent(target.mocapSessionId)}/link/${encodeURIComponent(target.rowingSessionId)}`,
+ { method: "POST" },
+ );
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ reason: failureReasonForStatus(response.status),
+ status: response.status,
+ message: await readErrorMessage(response),
+ };
+ }
+
+ const data = await response.json();
+ return {
+ ok: true,
+ mocapSessionId: typeof data?.id === "string" ? data.id : target.mocapSessionId,
+ rowingSessionId:
+ typeof data?.rowingSessionId === "string" ? data.rowingSessionId : target.rowingSessionId,
+ status: typeof data?.status === "string" ? data.status : "ready",
+ };
+}
diff --git a/tests/mocapLinking.test.ts b/tests/mocapLinking.test.ts
new file mode 100644
index 0000000..e94fb51
--- /dev/null
+++ b/tests/mocapLinking.test.ts
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { confirmMocapSessionLink } from "../src/lib/mocap/linking";
+
+test("confirmMocapSessionLink links an overlapping mocap session", async () => {
+ const calls: Array<{ input: string; init?: RequestInit }> = [];
+ const fetchImpl = async (input: string, init?: RequestInit) => {
+ calls.push({ input, init });
+ return Response.json({ id: "mocap-1", rowingSessionId: "rowing-1", status: "ready" });
+ };
+
+ const result = await confirmMocapSessionLink(
+ { mocapSessionId: "mocap-1", rowingSessionId: "rowing-1" },
+ fetchImpl,
+ );
+
+ assert.deepEqual(result, {
+ ok: true,
+ mocapSessionId: "mocap-1",
+ rowingSessionId: "rowing-1",
+ status: "ready",
+ });
+ assert.equal(calls[0].input, "/api/mocap/sessions/mocap-1/link/rowing-1");
+ assert.equal(calls[0].init?.method, "POST");
+});
+
+test("confirmMocapSessionLink reports already-linked conflicts", async () => {
+ const fetchImpl = async () =>
+ Response.json(
+ { error: "Rowing session is already linked to another mocap session." },
+ { status: 409 },
+ );
+
+ const result = await confirmMocapSessionLink(
+ { mocapSessionId: "mocap-1", rowingSessionId: "rowing-1" },
+ fetchImpl,
+ );
+
+ assert.deepEqual(result, {
+ ok: false,
+ reason: "conflict",
+ status: 409,
+ message: "Rowing session is already linked to another mocap session.",
+ });
+});
From c5b706b5105998de4d650b15199bf21dbc094ace Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:28:34 +0200
Subject: [PATCH 18/29] feat(mocap): add replay comparison and degraded mode
support
- Add degraded mode functionality for handling incomplete mocap data
- Implement stroke replay comparison feature with fault analysis
- Add UI components for comparing fault-heavy vs clean strokes
- Enhanced API routes with degraded mode handling
- Add comprehensive test coverage for new functionality
- Include skeleton canvas visualization for catch/finish phases
---
.../api/mocap/sessions/[id]/finalize/route.ts | 33 +-
.../mocap/sessions/[id]/pose-stream/route.ts | 3 +
.../mocap/sessions/[id]/reanalyze/route.ts | 10 +-
src/app/api/mocap/sessions/route.ts | 32 +-
src/app/mocap/page.tsx | 234 +++++++--
src/app/mocap/sessions/[id]/page.tsx | 469 +++++++++++++++++-
src/lib/mocap/degradedMode.ts | 146 ++++++
src/lib/mocap/replayComparison.ts | 77 +++
tests/degradedMode.test.ts | 84 ++++
tests/e2e/mocap-replay-compare.spec.ts | 99 ++++
tests/mocapReplayComparison.test.ts | 55 ++
11 files changed, 1156 insertions(+), 86 deletions(-)
create mode 100644 src/lib/mocap/degradedMode.ts
create mode 100644 src/lib/mocap/replayComparison.ts
create mode 100644 tests/degradedMode.test.ts
create mode 100644 tests/e2e/mocap-replay-compare.spec.ts
create mode 100644 tests/mocapReplayComparison.test.ts
diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts
index ba5f29c..4ccdb5b 100644
--- a/src/app/api/mocap/sessions/[id]/finalize/route.ts
+++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts
@@ -65,24 +65,25 @@ export async function POST(
const storage = getMocapStorage();
- let finalized: Awaited>;
- try {
- finalized = await finalizePoseStreamBlob(storage, row.poseStreamPath);
- } catch (err) {
- return NextResponse.json(
- { error: err instanceof Error ? err.message : String(err) },
- { status: 500 },
- );
- }
-
if (body.skipAnalysis) {
+ let finalized = { frameCount: 0, poseStreamBytes: 0 };
+ if (await storage.exists(row.poseStreamPath)) {
+ try {
+ finalized = await finalizePoseStreamBlob(storage, row.poseStreamPath);
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+ }
const updated = await prisma.mocapSession.update({
where: { id: row.id },
data: {
status: "ready",
durationSec: body.durationSec,
qualityScore: body.qualityScore ?? null,
- qualityFlags: body.qualityFlags ?? [],
+ qualityFlags: [...new Set([...(body.qualityFlags ?? []), "record-only"])],
},
});
return NextResponse.json({
@@ -96,6 +97,16 @@ export async function POST(
});
}
+ let finalized: Awaited>;
+ try {
+ finalized = await finalizePoseStreamBlob(storage, row.poseStreamPath);
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
+
const analyzing = await prisma.mocapSession.update({
where: { id: row.id },
data: {
diff --git a/src/app/api/mocap/sessions/[id]/pose-stream/route.ts b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
index c91dcc9..2e25402 100644
--- a/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
+++ b/src/app/api/mocap/sessions/[id]/pose-stream/route.ts
@@ -104,6 +104,9 @@ export async function GET(
}
const storage = getMocapStorage();
+ if (!(await storage.exists(row.poseStreamPath))) {
+ return NextResponse.json({ error: "Pose stream unavailable" }, { status: 404 });
+ }
const totalSize = await storage.size(row.poseStreamPath);
const range = parseRange(req.headers.get("range"), totalSize);
diff --git a/src/app/api/mocap/sessions/[id]/reanalyze/route.ts b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts
index 98a93ee..5b1ef9f 100644
--- a/src/app/api/mocap/sessions/[id]/reanalyze/route.ts
+++ b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts
@@ -41,13 +41,19 @@ export async function POST(
);
}
+ const storage = getMocapStorage();
+ if (!(await storage.exists(row.poseStreamPath))) {
+ return NextResponse.json(
+ { error: "Cannot re-analyze a record-only session without a pose stream" },
+ { status: 409 },
+ );
+ }
+
await prisma.mocapSession.update({
where: { id: row.id },
data: { status: "analyzing" },
});
- const storage = getMocapStorage();
-
let analysis: Awaited>;
try {
analysis = await analyzeAndPersistMocapSession(storage, row);
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
index 90af0ea..a064514 100644
--- a/src/app/api/mocap/sessions/route.ts
+++ b/src/app/api/mocap/sessions/route.ts
@@ -53,15 +53,25 @@ const CreateBody = z
captureModelVersion: z.string().min(1).max(120),
capturePerspective: z.enum(["side-left", "side-right"]),
captureFps: z.number().positive().max(240),
+ recordOnly: z.boolean().optional(),
calibrationCatchFrame: CalibrationFrame.extend({
pose: z.literal("catch"),
- }),
+ }).optional(),
calibrationFinishFrame: CalibrationFrame.extend({
pose: z.literal("finish"),
- }),
+ }).optional(),
})
.superRefine((body, ctx) => {
for (const field of ["calibrationCatchFrame", "calibrationFinishFrame"] as const) {
+ if (body.recordOnly && body[field] === undefined) continue;
+ if (!body[field]) {
+ ctx.addIssue({
+ code: "custom",
+ path: [field],
+ message: "Calibration frame is required unless recordOnly is true",
+ });
+ continue;
+ }
if (body[field].capturePerspective !== body.capturePerspective) {
ctx.addIssue({
code: "custom",
@@ -114,14 +124,16 @@ export async function POST(req: Request) {
});
});
- try {
- await initializePoseStreamBlob(storage, created.poseStreamPath, body.captureFps);
- } catch (err) {
- await prisma.mocapSession.delete({ where: { id: created.id } }).catch(() => {});
- return NextResponse.json(
- { error: "Failed to initialize pose stream blob", details: err instanceof Error ? err.message : String(err) },
- { status: 500 },
- );
+ if (!body.recordOnly) {
+ try {
+ await initializePoseStreamBlob(storage, created.poseStreamPath, body.captureFps);
+ } catch (err) {
+ await prisma.mocapSession.delete({ where: { id: created.id } }).catch(() => {});
+ return NextResponse.json(
+ { error: "Failed to initialize pose stream blob", details: err instanceof Error ? err.message : String(err) },
+ { status: 500 },
+ );
+ }
}
return NextResponse.json({
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index b003866..a2a5450 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -20,6 +20,16 @@ import {
type CameraReadinessFrame,
type CameraReadinessResult,
} from "@/lib/mocap/cameraReadiness";
+import {
+ evaluateMocapCaptureSupport,
+ hasSustainedLowEffectiveFps,
+ lowFpsRecordOnlySupport,
+ readBrowserMocapCapabilities,
+ recordOnlyQualityFlag,
+ type EffectiveFpsSample,
+ type MocapCaptureSupport,
+ type RecordOnlyReason,
+} from "@/lib/mocap/degradedMode";
import {
BYTES_PER_FRAME_V1,
decodeFrame,
@@ -70,6 +80,7 @@ type CaptureState =
sessionId: string;
durationSec: number;
frameCount: number;
+ recordOnly: boolean;
}
| { kind: "error"; message: string };
@@ -128,10 +139,12 @@ export default function MocapCapturePage() {
const startedAtRef = useRef(0);
const latestPoseFrameRef = useRef(EMPTY_QUALITY);
const qualityHistoryRef = useRef([]);
+ const effectiveFpsSamplesRef = useRef([]);
const latestCameraReadinessRef = useRef(null);
const engineRef = useRef(null);
const cueDismissTimerRef = useRef | null>(null);
const audioEnabledRef = useRef(false);
+ const recordOnlyRef = useRef(false);
const [state, setState] = useState({ kind: "idle" });
const [calibration, setCalibration] = useState({
@@ -149,6 +162,10 @@ export default function MocapCapturePage() {
const [framingDegraded, setFramingDegraded] = useState(false);
const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
const [recordOnly, setRecordOnly] = useState(false);
+ const [recordOnlyReason, setRecordOnlyReason] =
+ useState(null);
+ const [captureSupport, setCaptureSupport] =
+ useState(null);
const [activeCue, setActiveCue] = useState(null);
const [sessionFaults, setSessionFaults] = useState([]);
const [audioEnabled, setAudioEnabled] = useState(false);
@@ -160,6 +177,13 @@ export default function MocapCapturePage() {
setAudioEnabled(prefs.audioEnabled);
setVerbosity(prefs.verbosity);
audioEnabledRef.current = prefs.audioEnabled;
+
+ const support = evaluateMocapCaptureSupport(readBrowserMocapCapabilities());
+ setCaptureSupport(support);
+ if (support.recordOnlyRecommended) {
+ setRecordOnly(true);
+ setRecordOnlyReason(support.reason);
+ }
}, []);
useEffect(() => {
@@ -167,6 +191,14 @@ export default function MocapCapturePage() {
if (!audioEnabled) cancelSpokenCues();
}, [audioEnabled]);
+ useEffect(() => {
+ recordOnlyRef.current =
+ recordOnly ||
+ Boolean(
+ captureSupport?.recordOnlyRecommended && !captureSupport.livePoseSupported,
+ );
+ }, [recordOnly, captureSupport]);
+
const updateAudioEnabled = useCallback((next: boolean) => {
setAudioEnabled(next);
const current = settings.getMocapSettings().mocapPreferences;
@@ -245,6 +277,23 @@ export default function MocapCapturePage() {
latestCameraReadinessRef.current = readiness;
setCameraReadiness(readiness);
+ effectiveFpsSamplesRef.current = [
+ ...effectiveFpsSamplesRef.current.filter(
+ (sample) => sample.timestampMs >= nowMs - 5000,
+ ),
+ { timestampMs: nowMs, effectiveFps: readiness.effectiveFps },
+ ];
+ if (
+ !monitorDegradedFraming &&
+ !recordOnlyRef.current &&
+ hasSustainedLowEffectiveFps(effectiveFpsSamplesRef.current, { nowMs })
+ ) {
+ const support = lowFpsRecordOnlySupport();
+ setCaptureSupport(support);
+ setRecordOnly(true);
+ setRecordOnlyReason(support.reason);
+ }
+
// Feed the live coaching engine when active.
if (engineRef.current) {
if (decodedFrame) engineRef.current.pushFrame(decodedFrame);
@@ -323,6 +372,7 @@ export default function MocapCapturePage() {
setQuality(EMPTY_QUALITY);
setCameraReadiness(null);
qualityHistoryRef.current = [];
+ effectiveFpsSamplesRef.current = [];
latestCameraReadinessRef.current = null;
latestPoseFrameRef.current = EMPTY_QUALITY;
try {
@@ -403,8 +453,21 @@ export default function MocapCapturePage() {
);
const start = useCallback(async () => {
+ const captureRecordOnly =
+ recordOnly ||
+ Boolean(
+ captureSupport?.recordOnlyRecommended && !captureSupport.livePoseSupported,
+ );
+ if (captureSupport && !captureSupport.videoCaptureSupported) {
+ setState({ kind: "error", message: captureSupport.message });
+ return;
+ }
+
const calibrationFrames = getCalibrationFrames(calibration);
- if (!calibrationFrames || !latestCameraReadinessRef.current?.ready) {
+ if (
+ !captureRecordOnly &&
+ (!calibrationFrames || !latestCameraReadinessRef.current?.ready)
+ ) {
setCalibration((current) => ({
...current,
hint:
@@ -432,11 +495,11 @@ export default function MocapCapturePage() {
// Spin up the live coaching engine (skipped when in record-only mode).
const calibrationFramesForEngine = buildEngineCalibration(
perspective,
- calibrationFrames.catchFrame,
- calibrationFrames.finishFrame,
+ calibrationFrames?.catchFrame,
+ calibrationFrames?.finishFrame,
);
const mocapPrefs = settings.getMocapSettings();
- if (!recordOnly) {
+ if (!captureRecordOnly) {
engineRef.current = new LiveCoachingEngine({
fps: CAPTURE_FPS,
capturePerspective: perspective,
@@ -470,8 +533,9 @@ export default function MocapCapturePage() {
captureModelVersion: CAPTURE_MODEL_VERSION,
capturePerspective: perspective,
captureFps: CAPTURE_FPS,
- calibrationCatchFrame: calibrationFrames.catchFrame,
- calibrationFinishFrame: calibrationFrames.finishFrame,
+ recordOnly: captureRecordOnly,
+ calibrationCatchFrame: calibrationFrames?.catchFrame,
+ calibrationFinishFrame: calibrationFrames?.finishFrame,
}),
});
if (!createRes.ok) {
@@ -502,24 +566,34 @@ export default function MocapCapturePage() {
};
recorderRef.current = recorder;
- const source = new BrowserPoseSource({
- sessionId,
- videoEl: video,
- onStatus: (s) => setPoseStatus(s),
- onFrame: (info) => handlePoseFrame(info, true),
- onError: (err) => handleError(err, sessionId),
- });
- sourceRef.current = source;
- await source.init();
+ if (!captureRecordOnly) {
+ const source = new BrowserPoseSource({
+ sessionId,
+ videoEl: video,
+ onStatus: (s) => setPoseStatus(s),
+ onFrame: (info) => handlePoseFrame(info, true),
+ onError: (err) => handleError(err, sessionId),
+ });
+ sourceRef.current = source;
+ await source.init();
+ } else {
+ sourceRef.current = null;
+ setPoseStatus("stopped");
+ }
recorder.start(VIDEO_TIMESLICE_MS);
- source.start();
+ sourceRef.current?.start();
startedAtRef.current = Date.now();
setElapsedSec(0);
setFramesEncoded(0);
setFramingDegraded(false);
- setSessionQualityFlags([]);
+ setSessionQualityFlags(
+ captureRecordOnly
+ ? recordOnlySessionFlags([], recordOnlyReason)
+ : [],
+ );
qualityHistoryRef.current = [];
+ effectiveFpsSamplesRef.current = [];
latestCameraReadinessRef.current = null;
setCameraReadiness(null);
setState({
@@ -532,16 +606,19 @@ export default function MocapCapturePage() {
}
}, [
calibration,
+ captureSupport,
handleError,
handlePoseFrame,
perspective,
recordOnly,
+ recordOnlyReason,
clearCueDismissTimer,
]);
const stop = useCallback(async () => {
if (state.kind !== "capturing") return;
const sessionId = state.sessionId;
+ const captureWasRecordOnly = recordOnlyRef.current;
setState({ kind: "stopping", sessionId });
try {
const recorder = recorderRef.current;
@@ -564,6 +641,9 @@ export default function MocapCapturePage() {
await uploaderRef.current?.drain();
const durationSec = (Date.now() - startedAtRef.current) / 1000;
+ const finalizeQualityFlags = captureWasRecordOnly
+ ? recordOnlySessionFlags(sessionQualityFlags, recordOnlyReason)
+ : sessionQualityFlags;
const finalizeRes = await fetch(
`/api/mocap/sessions/${sessionId}/finalize`,
{
@@ -571,9 +651,11 @@ export default function MocapCapturePage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
durationSec,
- qualityScore: qualityScoreFor(latestPoseFrameRef.current),
- qualityFlags: sessionQualityFlags,
- skipAnalysis: recordOnly,
+ qualityScore: captureWasRecordOnly
+ ? undefined
+ : qualityScoreFor(latestPoseFrameRef.current),
+ qualityFlags: finalizeQualityFlags,
+ skipAnalysis: captureWasRecordOnly,
}),
},
);
@@ -606,6 +688,7 @@ export default function MocapCapturePage() {
sessionId: finalized.id,
durationSec: finalized.durationSec,
frameCount: finalized.frameCount,
+ recordOnly: captureWasRecordOnly,
});
} catch (err) {
await handleError(err, sessionId);
@@ -615,7 +698,7 @@ export default function MocapCapturePage() {
handleError,
sessionQualityFlags,
teardown,
- recordOnly,
+ recordOnlyReason,
clearCueDismissTimer,
]);
@@ -640,14 +723,20 @@ export default function MocapCapturePage() {
}
try {
const durationSec = (Date.now() - startedAtRef.current) / 1000;
+ const captureWasRecordOnly = recordOnlyRef.current;
navigator.sendBeacon?.(
`/api/mocap/sessions/${sessionId}/finalize`,
new Blob(
[
JSON.stringify({
durationSec,
- qualityScore: qualityScoreFor(latestPoseFrameRef.current),
- qualityFlags: sessionQualityFlags,
+ qualityScore: captureWasRecordOnly
+ ? undefined
+ : qualityScoreFor(latestPoseFrameRef.current),
+ qualityFlags: captureWasRecordOnly
+ ? recordOnlySessionFlags(sessionQualityFlags, recordOnlyReason)
+ : sessionQualityFlags,
+ skipAnalysis: captureWasRecordOnly,
}),
],
{
@@ -661,7 +750,7 @@ export default function MocapCapturePage() {
};
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
- }, [state, sessionQualityFlags]);
+ }, [state, sessionQualityFlags, recordOnlyReason]);
const calibrationFrames = getCalibrationFrames(calibration);
const nextCalibrationPose: CalibrationPose | null = !(
@@ -672,13 +761,24 @@ export default function MocapCapturePage() {
? "finish"
: null;
const cameraReady = cameraReadiness?.ready ?? false;
+ const videoCaptureSupported = captureSupport?.videoCaptureSupported ?? true;
+ const livePoseSupported = captureSupport?.livePoseSupported ?? true;
+ const recordOnlyForced = Boolean(
+ captureSupport?.recordOnlyRecommended && !captureSupport.livePoseSupported,
+ );
+ const recordOnlyActive = recordOnly || recordOnlyForced;
+ const captureBusy =
+ state.kind === "capturing" ||
+ state.kind === "starting" ||
+ state.kind === "stopping";
const canRecord =
- state.kind !== "capturing" &&
- state.kind !== "starting" &&
- state.kind !== "stopping" &&
- calibration.kind === "ready" &&
- Boolean(calibrationFrames) &&
- cameraReady;
+ !captureBusy &&
+ videoCaptureSupported &&
+ (recordOnlyActive ||
+ (livePoseSupported &&
+ calibration.kind === "ready" &&
+ Boolean(calibrationFrames) &&
+ cameraReady));
return (
@@ -712,12 +812,15 @@ export default function MocapCapturePage() {
setRecordOnly(e.target.checked)}
- disabled={state.kind === "capturing" || state.kind === "starting" || state.kind === "stopping"}
+ checked={recordOnlyActive}
+ onChange={(e) => {
+ setRecordOnly(e.target.checked);
+ setRecordOnlyReason(e.target.checked ? null : null);
+ }}
+ disabled={captureBusy || recordOnlyForced}
data-testid="mocap-record-only"
/>
- Record only (skip analysis)
+ Record-only mode
{calibration.kind === "idle" &&
- (state.kind === "idle" || state.kind === "done") ? (
+ (state.kind === "idle" || state.kind === "done") &&
+ livePoseSupported &&
+ !recordOnlyActive ? (
) : null}
{calibration.kind === "ready" &&
- (state.kind === "idle" || state.kind === "done") ? (
+ (state.kind === "idle" || state.kind === "done") &&
+ livePoseSupported &&
+ !recordOnlyActive ? (
- Start mocap session
+ {recordOnlyActive ? "Start video recording" : "Start mocap session"}
) : null}
{state.kind === "starting" ? (
@@ -805,21 +912,48 @@ export default function MocapCapturePage() {
) : null}
+ {captureSupport && !captureSupport.videoCaptureSupported ? (
+
+ {captureSupport.message}
+
+ ) : null}
+
+ {recordOnlyActive && videoCaptureSupported ? (
+
+ {captureSupport?.recordOnlyRecommended
+ ? captureSupport.message
+ : "Record-only mode saves video without live posture analysis. You can review the video later, but no posture rows are created during capture."}
+
+ ) : null}
+
-
+
- {cameraReadiness && !cameraReady ? (
+ {cameraReadiness && !cameraReady && !recordOnlyActive ? (
{state.sessionId} stored.
- {state.frameCount} pose frames · {state.durationSec.toFixed(1)}s
- duration
+ {state.recordOnly
+ ? "Video-only recording"
+ : `${state.frameCount} pose frames`}{" "}
+ · {state.durationSec.toFixed(1)}s duration
-
+ {!state.recordOnly ? (
+
+ ) : null}
();
@@ -199,12 +220,53 @@ export default function MocapReplayPage() {
const [session, setSession] = useState
(null);
const [poseHeader, setPoseHeader] = useState(null);
+ const [poseHeaderChecked, setPoseHeaderChecked] = useState(false);
const [loadError, setLoadError] = useState(null);
const [currentTime, setCurrentTime] = useState(0);
const [selectedFault, setSelectedFault] = useState(null);
const [selectedStroke, setSelectedStroke] = useState(null);
const [reanalyzing, setReanalyzing] = useState(false);
const [reanalyzeError, setReanalyzeError] = useState(null);
+ const [compareFaultStroke, setCompareFaultStroke] = useState(null);
+ const [compareStroke, setCompareStroke] = useState(null);
+ const [comparisonFrames, setComparisonFrames] = useState<
+ Record
+ >({});
+ const [comparisonLoading, setComparisonLoading] = useState(false);
+
+ const comparisonOptions = useMemo(
+ () =>
+ session
+ ? buildReplayComparisonOptions(
+ session.strokePostureMetrics,
+ session.postureFaults,
+ compareFaultStroke,
+ )
+ : {
+ faultStrokeOptions: [],
+ cleanStrokeOptions: [],
+ defaultFaultStrokeIndex: null,
+ defaultComparisonStrokeIndex: null,
+ },
+ [compareFaultStroke, session],
+ );
+
+ const metricsByStroke = useMemo(() => {
+ const map = new Map();
+ for (const metric of session?.strokePostureMetrics ?? []) {
+ map.set(metric.strokeIndex, metric);
+ }
+ return map;
+ }, [session]);
+
+ const faultsByStroke = useMemo(() => {
+ const map = new Map();
+ for (const fault of session?.postureFaults ?? []) {
+ if (!map.has(fault.strokeIndex)) map.set(fault.strokeIndex, []);
+ map.get(fault.strokeIndex)!.push(fault);
+ }
+ return map;
+ }, [session]);
// Load session data
useEffect(() => {
@@ -220,9 +282,94 @@ export default function MocapReplayPage() {
// Load pose stream header
useEffect(() => {
if (!session || session.status !== "ready") return;
- fetchPoseHeader(id).then(setPoseHeader);
+ setPoseHeaderChecked(false);
+ fetchPoseHeader(id).then((header) => {
+ setPoseHeader(header);
+ setPoseHeaderChecked(true);
+ });
}, [id, session]);
+ useEffect(() => {
+ if (!session || session.strokePostureMetrics.length === 0) return;
+ const isValidFaultStroke = comparisonOptions.faultStrokeOptions.some(
+ (option) => option.strokeIndex === compareFaultStroke,
+ );
+ if (!isValidFaultStroke) {
+ setCompareFaultStroke(comparisonOptions.defaultFaultStrokeIndex);
+ }
+ }, [compareFaultStroke, comparisonOptions, session]);
+
+ useEffect(() => {
+ if (!session || session.strokePostureMetrics.length === 0) return;
+ const isValidComparisonStroke =
+ compareStroke !== null &&
+ compareStroke !== compareFaultStroke &&
+ comparisonOptions.cleanStrokeOptions.includes(compareStroke);
+
+ if (!isValidComparisonStroke) {
+ setCompareStroke(comparisonOptions.defaultComparisonStrokeIndex);
+ }
+ }, [compareFaultStroke, compareStroke, comparisonOptions, session]);
+
+ useEffect(() => {
+ if (
+ !session ||
+ !poseHeader ||
+ compareFaultStroke === null ||
+ compareStroke === null
+ ) {
+ setComparisonFrames({});
+ return;
+ }
+
+ const faultMetric = metricsByStroke.get(compareFaultStroke);
+ const comparisonMetric = metricsByStroke.get(compareStroke);
+ if (!faultMetric || !comparisonMetric) {
+ setComparisonFrames({});
+ return;
+ }
+
+ let cancelled = false;
+ setComparisonLoading(true);
+
+ const frameRequests: Array<[string, number]> = [
+ [
+ comparisonFrameKey("fault", "catch"),
+ faultMetric.phaseBoundariesJson.catchFrameIndex,
+ ],
+ [
+ comparisonFrameKey("fault", "finish"),
+ faultMetric.phaseBoundariesJson.finishFrameIndex,
+ ],
+ [
+ comparisonFrameKey("comparison", "catch"),
+ comparisonMetric.phaseBoundariesJson.catchFrameIndex,
+ ],
+ [
+ comparisonFrameKey("comparison", "finish"),
+ comparisonMetric.phaseBoundariesJson.finishFrameIndex,
+ ],
+ ];
+
+ Promise.all(
+ frameRequests.map(async ([key, frameIndex]) => [
+ key,
+ await fetchPoseFrameAtIndex(id, frameIndex),
+ ] as const),
+ )
+ .then((entries) => {
+ if (cancelled) return;
+ setComparisonFrames(Object.fromEntries(entries));
+ })
+ .finally(() => {
+ if (!cancelled) setComparisonLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [compareFaultStroke, compareStroke, id, metricsByStroke, poseHeader, session]);
+
// Resize canvas to match video display dimensions
useEffect(() => {
const video = videoRef.current;
@@ -346,7 +493,7 @@ export default function MocapReplayPage() {
}, [id]);
const freezeAtCatch = useCallback(() => {
- if (!selectedStroke || !session || !poseHeader) return;
+ if (selectedStroke === null || !session || !poseHeader) return;
const metric = session.strokePostureMetrics.find(
(m) => m.strokeIndex === selectedStroke,
);
@@ -354,7 +501,7 @@ export default function MocapReplayPage() {
}, [selectedStroke, session, poseHeader, seekToFrame]);
const freezeAtFinish = useCallback(() => {
- if (!selectedStroke || !session || !poseHeader) return;
+ if (selectedStroke === null || !session || !poseHeader) return;
const metric = session.strokePostureMetrics.find(
(m) => m.strokeIndex === selectedStroke,
);
@@ -383,11 +530,12 @@ export default function MocapReplayPage() {
const duration = session.durationSec;
const fps = poseHeader?.fps ?? session.captureFps;
const hasMetrics = session.strokePostureMetrics.length > 0;
- const faultsByStroke = new Map();
- for (const f of session.postureFaults) {
- if (!faultsByStroke.has(f.strokeIndex)) faultsByStroke.set(f.strokeIndex, []);
- faultsByStroke.get(f.strokeIndex)!.push(f);
- }
+ const hasPoseStream = Boolean(poseHeader);
+ const isRecordOnly = session.qualityFlags.includes("record-only");
+ const compareFaultMetric =
+ compareFaultStroke === null ? null : metricsByStroke.get(compareFaultStroke) ?? null;
+ const compareMetric =
+ compareStroke === null ? null : metricsByStroke.get(compareStroke) ?? null;
return (
@@ -533,23 +681,29 @@ export default function MocapReplayPage() {
) : null}
{/* Not-yet-analyzed state */}
- {!hasMetrics && session.status === "ready" ? (
+ {!hasMetrics && session.status === "ready" && poseHeaderChecked ? (
- No posture analysis for this session.
+ {hasPoseStream
+ ? "No posture analysis for this session."
+ : isRecordOnly
+ ? "This is a record-only video. Live pose analysis was unavailable during capture, so there is no pose stream to re-analyze."
+ : "Posture analysis is unavailable because this session has no pose stream."}
{reanalyzeError ? (
Analysis failed: {reanalyzeError}
) : null}
-
- {reanalyzing ? "Analyzing…" : "Run analysis"}
-
+ {hasPoseStream ? (
+
+ {reanalyzing ? "Analyzing…" : "Run analysis"}
+
+ ) : null}
) : null}
@@ -576,6 +730,117 @@ export default function MocapReplayPage() {
) : null}
+ {hasMetrics ? (
+
+
+
+ Side-by-side stroke compare
+
+
+
+ {comparisonOptions.faultStrokeOptions.length === 0 ? (
+
+ No fault-heavy strokes yet. Once analysis detects a posture fault,
+ comparison mode can pair that stroke with a clean stroke from this
+ mocap session.
+
+ ) : comparisonOptions.cleanStrokeOptions.length === 0 ? (
+
+ No clean comparison stroke exists in this mocap session. Every
+ analyzed stroke currently has at least one detected fault.
+
+ ) : (
+ <>
+
+
+
+ Fault-heavy stroke
+
+
+ setCompareFaultStroke(
+ e.target.value ? Number(e.target.value) : null,
+ )
+ }
+ data-testid="mocap-compare-fault-stroke"
+ >
+ {comparisonOptions.faultStrokeOptions.map((option) => (
+
+ Stroke {option.strokeIndex + 1} · {option.faultCount}{" "}
+ {option.faultCount === 1 ? "fault" : "faults"}
+
+ ))}
+
+
+
+
+ Clean comparison stroke
+
+
+ setCompareStroke(e.target.value ? Number(e.target.value) : null)
+ }
+ data-testid="mocap-compare-clean-stroke"
+ >
+ {comparisonOptions.cleanStrokeOptions
+ .filter((strokeIndex) => strokeIndex !== compareFaultStroke)
+ .map((strokeIndex) => (
+
+ Stroke {strokeIndex + 1}
+
+ ))}
+
+
+
+
+ {compareFaultMetric && compareMetric ? (
+
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ ) : null}
+
{/* Fault detail panel */}
{selectedFault ? (
@@ -657,3 +922,169 @@ function StatBox({ label, value }: { label: string; value: string }) {
);
}
+
+function CompareStrokePanel({
+ title,
+ metric,
+ faults,
+ catchFrame,
+ finishFrame,
+ loading,
+}: {
+ title: string;
+ metric: SessionStrokeMetric;
+ faults: SessionFault[];
+ catchFrame: Float32Array | null;
+ finishFrame: Float32Array | null;
+ loading: boolean;
+}) {
+ const faultCount = countFaultsForStroke(faults, metric.strokeIndex);
+
+ return (
+
+
+
+
{title}
+
+ Stroke {metric.strokeIndex + 1}
+
+
+
0 ? "destructive" : "secondary"}>
+ {faultCount > 0
+ ? `${faultCount} ${faultCount === 1 ? "fault" : "faults"}`
+ : "clean"}
+
+
+
+
+
+
+ {metricRows(metric.metricsJson).map((row) => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+
+
+
Fault summary
+ {faults.length > 0 ? (
+
+ {faults.map((fault) => (
+
+ {faultLabel(fault.faultType)}
+
+ ))}
+
+ ) : (
+
No detected faults.
+ )}
+
+
+ );
+}
+
+function PhaseSkeletonCanvas({
+ label,
+ keypoints,
+ loading,
+}: {
+ label: string;
+ keypoints: Float32Array | null;
+ loading: boolean;
+}) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ canvas.width = canvas.clientWidth || 320;
+ canvas.height = canvas.clientHeight || 180;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ if (keypoints) {
+ drawSkeleton(ctx, keypoints, canvas.width, canvas.height, 1280, 720);
+ }
+ }, [keypoints]);
+
+ return (
+
+
{label}
+
+
+ {loading ? (
+
+ Loading
+
+ ) : !keypoints ? (
+
+ Frame unavailable
+
+ ) : null}
+
+
+ );
+}
+
+function metricRows(metrics: SessionPostureMetrics): Array<{
+ label: string;
+ value: string;
+}> {
+ return [
+ {
+ label: "Back angle at catch",
+ value: formatMetric(metrics.backAngleAtCatchDeg, "deg"),
+ },
+ {
+ label: "Back angle at finish",
+ value: formatMetric(metrics.backAngleAtFinishDeg, "deg"),
+ },
+ {
+ label: "Layback angle",
+ value: formatMetric(metrics.laybackAngleDeg, "deg"),
+ },
+ {
+ label: "Hip-knee timing offset",
+ value: formatMetric(metrics.hipKneeOpeningOffsetFrames, "frames"),
+ },
+ {
+ label: "Arm bend before legs",
+ value: formatMetric(metrics.armBendBeforeLegsCompleteFrames, "frames"),
+ },
+ {
+ label: "Recovery / drive ratio",
+ value: formatMetric(metrics.recoveryDriveRatio, "ratio"),
+ },
+ ];
+}
+
+function formatMetric(
+ value: number | null | undefined,
+ unit: "deg" | "frames" | "ratio",
+): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "n/a";
+ if (unit === "ratio") return value.toFixed(2);
+ if (unit === "frames") return `${value.toFixed(0)} fr`;
+ return `${value.toFixed(1)} deg`;
+}
diff --git a/src/lib/mocap/degradedMode.ts b/src/lib/mocap/degradedMode.ts
new file mode 100644
index 0000000..e9992d1
--- /dev/null
+++ b/src/lib/mocap/degradedMode.ts
@@ -0,0 +1,146 @@
+export type RecordOnlyReason =
+ | "missing-camera-api"
+ | "missing-recorder-api"
+ | "missing-pose-api"
+ | "low-effective-fps";
+
+export type MocapBrowserCapabilities = {
+ getUserMedia: boolean;
+ mediaRecorder: boolean;
+ worker: boolean;
+ createImageBitmap: boolean;
+ requestAnimationFrame: boolean;
+};
+
+export type MocapCaptureSupport = {
+ videoCaptureSupported: boolean;
+ livePoseSupported: boolean;
+ recordOnlyAvailable: boolean;
+ recordOnlyRecommended: boolean;
+ reason: RecordOnlyReason | null;
+ message: string;
+};
+
+export type EffectiveFpsSample = {
+ timestampMs: number;
+ effectiveFps: number;
+};
+
+const MIN_LIVE_POSE_FPS = 12;
+const LOW_FPS_WINDOW_MS = 3000;
+const MIN_LOW_FPS_SAMPLES = 4;
+const LOW_FPS_RATIO = 0.75;
+
+export function readBrowserMocapCapabilities(
+ target: typeof globalThis = globalThis,
+): MocapBrowserCapabilities {
+ const maybeNavigator = target.navigator as Navigator | undefined;
+ return {
+ getUserMedia:
+ typeof maybeNavigator?.mediaDevices?.getUserMedia === "function",
+ mediaRecorder: typeof target.MediaRecorder === "function",
+ worker: typeof target.Worker === "function",
+ createImageBitmap: typeof target.createImageBitmap === "function",
+ requestAnimationFrame: typeof target.requestAnimationFrame === "function",
+ };
+}
+
+export function evaluateMocapCaptureSupport(
+ capabilities: MocapBrowserCapabilities,
+): MocapCaptureSupport {
+ if (!capabilities.getUserMedia) {
+ return {
+ videoCaptureSupported: false,
+ livePoseSupported: false,
+ recordOnlyAvailable: false,
+ recordOnlyRecommended: false,
+ reason: "missing-camera-api",
+ message:
+ "This browser cannot access the camera, so mocap recording is unavailable here.",
+ };
+ }
+
+ if (!capabilities.mediaRecorder) {
+ return {
+ videoCaptureSupported: false,
+ livePoseSupported: false,
+ recordOnlyAvailable: false,
+ recordOnlyRecommended: false,
+ reason: "missing-recorder-api",
+ message:
+ "This browser can open the camera but cannot record video. Try another browser or device.",
+ };
+ }
+
+ const livePoseSupported =
+ capabilities.worker &&
+ capabilities.createImageBitmap &&
+ capabilities.requestAnimationFrame;
+
+ if (!livePoseSupported) {
+ return {
+ videoCaptureSupported: true,
+ livePoseSupported: false,
+ recordOnlyAvailable: true,
+ recordOnlyRecommended: true,
+ reason: "missing-pose-api",
+ message:
+ "Live pose analysis is not supported in this browser. You can still record video for later review.",
+ };
+ }
+
+ return {
+ videoCaptureSupported: true,
+ livePoseSupported: true,
+ recordOnlyAvailable: true,
+ recordOnlyRecommended: false,
+ reason: null,
+ message: "Live pose analysis is available.",
+ };
+}
+
+export function hasSustainedLowEffectiveFps(
+ samples: readonly EffectiveFpsSample[],
+ opts: {
+ nowMs?: number;
+ minFps?: number;
+ windowMs?: number;
+ minSamples?: number;
+ } = {},
+): boolean {
+ const nowMs = opts.nowMs ?? samples.at(-1)?.timestampMs ?? 0;
+ const minFps = opts.minFps ?? MIN_LIVE_POSE_FPS;
+ const windowMs = opts.windowMs ?? LOW_FPS_WINDOW_MS;
+ const minSamples = opts.minSamples ?? MIN_LOW_FPS_SAMPLES;
+ const recent = samples.filter(
+ (sample) => sample.timestampMs >= nowMs - windowMs,
+ );
+
+ if (recent.length < minSamples) return false;
+
+ const durationMs = recent.at(-1)!.timestampMs - recent[0].timestampMs;
+ if (durationMs < windowMs * 0.75) return false;
+
+ const lowSamples = recent.filter(
+ (sample) => sample.effectiveFps > 0 && sample.effectiveFps < minFps,
+ );
+ return lowSamples.length / recent.length >= LOW_FPS_RATIO;
+}
+
+export function lowFpsRecordOnlySupport(): MocapCaptureSupport {
+ return {
+ videoCaptureSupported: true,
+ livePoseSupported: true,
+ recordOnlyAvailable: true,
+ recordOnlyRecommended: true,
+ reason: "low-effective-fps",
+ message:
+ "Live pose analysis is running too slowly on this device. Record-only mode will save video without live posture analysis.",
+ };
+}
+
+export function recordOnlyQualityFlag(reason: RecordOnlyReason | null): string {
+ if (reason === "missing-pose-api") return "missing-pose-api";
+ if (reason === "low-effective-fps") return "low-effective-fps";
+ return "record-only";
+}
diff --git a/src/lib/mocap/replayComparison.ts b/src/lib/mocap/replayComparison.ts
new file mode 100644
index 0000000..4eea093
--- /dev/null
+++ b/src/lib/mocap/replayComparison.ts
@@ -0,0 +1,77 @@
+export interface ReplayComparisonStroke {
+ strokeIndex: number;
+}
+
+export interface ReplayComparisonFault {
+ strokeIndex: number;
+ severity: string;
+}
+
+export interface FaultStrokeOption {
+ strokeIndex: number;
+ faultCount: number;
+ severityScore: number;
+}
+
+export interface ReplayComparisonOptions {
+ faultStrokeOptions: FaultStrokeOption[];
+ cleanStrokeOptions: number[];
+ defaultFaultStrokeIndex: number | null;
+ defaultComparisonStrokeIndex: number | null;
+}
+
+export function buildReplayComparisonOptions(
+ strokes: readonly ReplayComparisonStroke[],
+ faults: readonly ReplayComparisonFault[],
+ selectedFaultStrokeIndex: number | null = null,
+): ReplayComparisonOptions {
+ const knownStrokeIndexes = new Set(strokes.map((stroke) => stroke.strokeIndex));
+ const faultStatsByStroke = new Map();
+
+ for (const fault of faults) {
+ if (!knownStrokeIndexes.has(fault.strokeIndex)) continue;
+ const current = faultStatsByStroke.get(fault.strokeIndex) ?? {
+ strokeIndex: fault.strokeIndex,
+ faultCount: 0,
+ severityScore: 0,
+ };
+ current.faultCount += 1;
+ current.severityScore += severityWeight(fault.severity);
+ faultStatsByStroke.set(fault.strokeIndex, current);
+ }
+
+ const faultStrokeOptions = [...faultStatsByStroke.values()].sort(
+ (a, b) =>
+ b.severityScore - a.severityScore ||
+ b.faultCount - a.faultCount ||
+ a.strokeIndex - b.strokeIndex,
+ );
+ const cleanStrokeOptions = strokes
+ .map((stroke) => stroke.strokeIndex)
+ .filter((strokeIndex) => !faultStatsByStroke.has(strokeIndex))
+ .sort((a, b) => a - b);
+ const defaultFaultStrokeIndex = faultStrokeOptions[0]?.strokeIndex ?? null;
+ const defaultComparisonStrokeIndex =
+ cleanStrokeOptions.find((strokeIndex) => strokeIndex !== selectedFaultStrokeIndex) ??
+ null;
+
+ return {
+ faultStrokeOptions,
+ cleanStrokeOptions,
+ defaultFaultStrokeIndex,
+ defaultComparisonStrokeIndex,
+ };
+}
+
+export function countFaultsForStroke(
+ faults: readonly ReplayComparisonFault[],
+ strokeIndex: number,
+): number {
+ return faults.filter((fault) => fault.strokeIndex === strokeIndex).length;
+}
+
+function severityWeight(severity: string): number {
+ if (severity === "critical") return 5;
+ if (severity === "warning") return 3;
+ return 1;
+}
diff --git a/tests/degradedMode.test.ts b/tests/degradedMode.test.ts
new file mode 100644
index 0000000..8b277f2
--- /dev/null
+++ b/tests/degradedMode.test.ts
@@ -0,0 +1,84 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ evaluateMocapCaptureSupport,
+ hasSustainedLowEffectiveFps,
+ readBrowserMocapCapabilities,
+} from "../src/lib/mocap/degradedMode";
+
+test("feature detection allows full mocap when recording and pose APIs exist", () => {
+ const capabilities = readBrowserMocapCapabilities({
+ navigator: {
+ mediaDevices: { getUserMedia() {} },
+ },
+ MediaRecorder: function MediaRecorder() {},
+ Worker: function Worker() {},
+ createImageBitmap() {},
+ requestAnimationFrame() {},
+ } as unknown as typeof globalThis);
+
+ const support = evaluateMocapCaptureSupport(capabilities);
+
+ assert.equal(support.videoCaptureSupported, true);
+ assert.equal(support.livePoseSupported, true);
+ assert.equal(support.recordOnlyRecommended, false);
+ assert.equal(support.reason, null);
+});
+
+test("feature detection offers record-only when pose APIs are missing", () => {
+ const support = evaluateMocapCaptureSupport({
+ getUserMedia: true,
+ mediaRecorder: true,
+ worker: false,
+ createImageBitmap: true,
+ requestAnimationFrame: true,
+ });
+
+ assert.equal(support.videoCaptureSupported, true);
+ assert.equal(support.livePoseSupported, false);
+ assert.equal(support.recordOnlyAvailable, true);
+ assert.equal(support.recordOnlyRecommended, true);
+ assert.equal(support.reason, "missing-pose-api");
+});
+
+test("feature detection blocks capture when video recording APIs are missing", () => {
+ const support = evaluateMocapCaptureSupport({
+ getUserMedia: true,
+ mediaRecorder: false,
+ worker: true,
+ createImageBitmap: true,
+ requestAnimationFrame: true,
+ });
+
+ assert.equal(support.videoCaptureSupported, false);
+ assert.equal(support.recordOnlyAvailable, false);
+ assert.equal(support.reason, "missing-recorder-api");
+});
+
+test("low effective FPS must be sustained before record-only is recommended", () => {
+ assert.equal(
+ hasSustainedLowEffectiveFps(
+ [
+ { timestampMs: 0, effectiveFps: 9 },
+ { timestampMs: 1000, effectiveFps: 8 },
+ { timestampMs: 2000, effectiveFps: 10 },
+ { timestampMs: 3000, effectiveFps: 7 },
+ ],
+ { nowMs: 3000 },
+ ),
+ true,
+ );
+
+ assert.equal(
+ hasSustainedLowEffectiveFps(
+ [
+ { timestampMs: 0, effectiveFps: 9 },
+ { timestampMs: 1000, effectiveFps: 18 },
+ { timestampMs: 2000, effectiveFps: 10 },
+ { timestampMs: 3000, effectiveFps: 20 },
+ ],
+ { nowMs: 3000 },
+ ),
+ false,
+ );
+});
diff --git a/tests/e2e/mocap-replay-compare.spec.ts b/tests/e2e/mocap-replay-compare.spec.ts
new file mode 100644
index 0000000..b975ab3
--- /dev/null
+++ b/tests/e2e/mocap-replay-compare.spec.ts
@@ -0,0 +1,99 @@
+import { expect, test } from "@playwright/test";
+
+const session = {
+ id: "mock-session",
+ status: "ready",
+ capturePerspective: "side-left",
+ captureFps: 30,
+ durationSec: 12,
+ qualityScore: 0.92,
+ qualityFlags: [],
+ createdAt: "2026-05-09T10:00:00.000Z",
+ strokePostureMetrics: [
+ metric(0, 0),
+ metric(1, 90),
+ metric(2, 180),
+ metric(3, 270),
+ ],
+ postureFaults: [
+ fault("fault-0", 0, "warning", "rounded_back_at_catch"),
+ fault("fault-2a", 2, "critical", "excessive_layback"),
+ fault("fault-2b", 2, "info", "slow_recovery_ratio"),
+ ],
+};
+
+test("replay comparison controls render and select strokes", async ({ page }) => {
+ await page.route("**/api/mocap/sessions/mock-session", (route) =>
+ route.fulfill({ json: { session } }),
+ );
+ await page.route("**/api/mocap/sessions/mock-session/video", (route) =>
+ route.fulfill({ status: 404 }),
+ );
+ await page.route("**/api/mocap/sessions/mock-session/pose-stream", (route) =>
+ route.fulfill({ status: 404 }),
+ );
+
+ await page.goto("/mocap/sessions/mock-session");
+
+ await expect(page.getByTestId("mocap-stroke-compare")).toBeVisible();
+
+ const faultSelect = page.getByTestId("mocap-compare-fault-stroke");
+ const cleanSelect = page.getByTestId("mocap-compare-clean-stroke");
+
+ await expect(faultSelect).toHaveValue("2");
+ await expect(cleanSelect).toHaveValue("1");
+
+ await faultSelect.selectOption("0");
+ await expect(faultSelect).toHaveValue("0");
+
+ await cleanSelect.selectOption("3");
+ await expect(cleanSelect).toHaveValue("3");
+});
+
+function metric(strokeIndex: number, startFrame: number) {
+ return {
+ id: `metric-${strokeIndex}`,
+ strokeIndex,
+ segmentationSource: "pose-segmented",
+ phaseBoundariesJson: {
+ catchFrameIndex: startFrame,
+ driveStartFrameIndex: startFrame + 10,
+ finishFrameIndex: startFrame + 30,
+ recoveryStartFrameIndex: startFrame + 35,
+ nextCatchFrameIndex: startFrame + 60,
+ confidence: 0.9,
+ },
+ metricsJson: {
+ backAngleAtCatchDeg: 24 + strokeIndex,
+ backAngleAtFinishDeg: 55 + strokeIndex,
+ laybackAngleDeg: 28 + strokeIndex,
+ hipKneeOpeningOffsetFrames: 3,
+ armBendBeforeLegsCompleteFrames: null,
+ recoveryDriveRatio: 1.8,
+ shinVerticalAtCatchDeg: {
+ available: false,
+ reason: "requires-sidecar-3d",
+ },
+ },
+ };
+}
+
+function fault(
+ id: string,
+ strokeIndex: number,
+ severity: string,
+ faultType: string,
+) {
+ return {
+ id,
+ strokeIndex,
+ severity,
+ faultType,
+ phase: "catch",
+ evidenceJson: {
+ metric: "backAngleAtCatchDeg",
+ value: 24,
+ threshold: 30,
+ },
+ };
+}
diff --git a/tests/mocapReplayComparison.test.ts b/tests/mocapReplayComparison.test.ts
new file mode 100644
index 0000000..cdd5396
--- /dev/null
+++ b/tests/mocapReplayComparison.test.ts
@@ -0,0 +1,55 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildReplayComparisonOptions,
+ countFaultsForStroke,
+} from "../src/lib/mocap/replayComparison.js";
+
+const strokes = [0, 1, 2, 3].map((strokeIndex) => ({ strokeIndex }));
+
+test("replay comparison defaults to the heaviest fault stroke and a clean comparison stroke", () => {
+ const result = buildReplayComparisonOptions(strokes, [
+ { strokeIndex: 0, severity: "warning" },
+ { strokeIndex: 2, severity: "info" },
+ { strokeIndex: 2, severity: "critical" },
+ ]);
+
+ assert.equal(result.defaultFaultStrokeIndex, 2);
+ assert.equal(result.defaultComparisonStrokeIndex, 1);
+ assert.deepEqual(
+ result.faultStrokeOptions.map((option) => option.strokeIndex),
+ [2, 0],
+ );
+ assert.deepEqual(result.cleanStrokeOptions, [1, 3]);
+});
+
+test("replay comparison excludes the selected fault stroke from clean candidates", () => {
+ const result = buildReplayComparisonOptions(strokes, [
+ { strokeIndex: 1, severity: "critical" },
+ ], 0);
+
+ assert.equal(result.defaultFaultStrokeIndex, 1);
+ assert.equal(result.defaultComparisonStrokeIndex, 2);
+});
+
+test("replay comparison reports no clean comparison when every stroke has a fault", () => {
+ const result = buildReplayComparisonOptions(strokes, [
+ { strokeIndex: 0, severity: "info" },
+ { strokeIndex: 1, severity: "warning" },
+ { strokeIndex: 2, severity: "critical" },
+ { strokeIndex: 3, severity: "info" },
+ ]);
+
+ assert.deepEqual(result.cleanStrokeOptions, []);
+ assert.equal(result.defaultComparisonStrokeIndex, null);
+});
+
+test("replay comparison ignores faults for strokes outside the session", () => {
+ const result = buildReplayComparisonOptions(strokes, [
+ { strokeIndex: 99, severity: "critical" },
+ ]);
+
+ assert.deepEqual(result.faultStrokeOptions, []);
+ assert.deepEqual(result.cleanStrokeOptions, [0, 1, 2, 3]);
+ assert.equal(countFaultsForStroke([{ strokeIndex: 2, severity: "warning" }], 2), 1);
+});
From bb527c6384fa6fa33be5e13bb3561ed7fb6fc980 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:38:04 +0200
Subject: [PATCH 19/29] docs(mocap): define Phase 2 freemocap sidecar contract
(closes #23)
Add ADR-0005 with explicit decisions on PoseFrameStream v2 schema
(keypointSchemaVersion 1->2, optional z field, world-mm-3d coordinate
space), WebSocket sidecar wire protocol, three sidecar-3d fault unlocks,
and privacy tier implications. Add representative freemocap output sample
doc and AFK-agent implementation notes. Update CONTEXT.md glossary with
v1/v2 PoseFrameStream shapes, sidecar-3d fault catalog, and split
Calibration entry for browser vs Charuco.
Co-Authored-By: Claude Sonnet 4.6
---
CONTEXT.md | 18 ++-
docs/adr/0005-freemocap-sidecar-contract.md | 146 +++++++++++++++++
docs/agents/sidecar-tracer-impl-notes.md | 170 ++++++++++++++++++++
docs/freemocap-sample-schema.md | 94 +++++++++++
4 files changed, 424 insertions(+), 4 deletions(-)
create mode 100644 docs/adr/0005-freemocap-sidecar-contract.md
create mode 100644 docs/agents/sidecar-tracer-impl-notes.md
create mode 100644 docs/freemocap-sample-schema.md
diff --git a/CONTEXT.md b/CONTEXT.md
index f20d772..ae9637e 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -45,7 +45,11 @@ v1 fault detector runs at the **stroke** granularity only — one pass per close
A timestamped sequence of keypoint frames with confidence values, produced by a `PoseCaptureSource` and consumed by the analysis pipeline.
-**v1 shape:** 2D side-view keypoints — `{x, y, confidence}` per keypoint, plus per-frame source-quality flags. Browser path only (see ADR-0002). The sidecar / 3D path is deferred to Phase 2; when it lands, the schema version bumps and an optional `z` channel is added without breaking existing blobs.
+**v1 shape (`keypointSchemaVersion: 1`):** 2D side-view keypoints — `{x, y, confidence}` per keypoint, normalized [0,1] image-relative coordinates. `coordinateSpace: "normalized-2d"`. Browser path only.
+
+**v2 shape (`keypointSchemaVersion: 2`):** 3D world-space keypoints — `{x, y, z, confidence}` per keypoint, units in millimeters. `coordinateSpace: "world-mm-3d"`. Sidecar path only (see ADR-0005). Blob header adds `cameraCount` and `calibrationId`. v1 blobs remain readable — the reader branches on `keypointSchemaVersion`. All v1 fault rules ignore `z` and work on `{x, y}` projection for both versions.
+
+33 BlazePose landmarks; 13 are rowing-relevant (nose, shoulders, elbows, wrists, hips, knees, ankles). The rest are captured but unused. Confidence = MediaPipe visibility [0,1]. v2 adds `reprojectionErrorMm` quality field (triangulation accuracy).
### PostureFault (v1 catalog)
@@ -65,6 +69,10 @@ Stroke-granular faults the v1 detector emits. All computable from a 2D side-view
- `knee_track_deviation` — needs front view or `sidecar-3d`
- `shin_not_vertical_at_catch` — disambiguating near-side shin from far-side shin in 2D is unreliable
+**Unlocked by `sidecar-3d` (Phase 2):** all three deferred faults above become computable. Lateral displacement is unambiguous in 3D; near/far shin disambiguated by z-coordinate. Fault rules and thresholds to be defined in follow-up implementation issues.
+
+`perspective` field on each fault: `"browser"` or `"sidecar-3d"`. When perspective is browser, the three sidecar-3d-only faults surface as "requires multi-camera capture" — never silently zeroed.
+
This catalog is the canonical vocabulary. Test fixtures, threshold tuning, coaching cue copy, and AI prompt context all reference these exact keys. Anything outside this list is out of v1 scope.
### FaultThresholds
@@ -77,8 +85,10 @@ Migration: when a new defaults version ships, users who haven't touched their th
### Calibration
-A pair of reference pose frames captured before recording starts: one at **catch** position, one at **finish** position. Used as pixel-space baselines for downstream metric calculations (e.g. "back angle delta from this rower's own catch baseline").
+Two distinct calibration concepts — do not conflate:
+
+**Browser calibration** — a pair of reference pose frames captured before recording starts: one at **catch** position, one at **finish** position. Used as pixel-space baselines for downstream metric calculations. Stored per `MocapSession` (see ADR-0001). Recapture (~10 s) required at the start of each session.
-**Stored per `MocapSession`, not per `User`.** 2D side-view baselines are only meaningful for the camera setup that produced them — angle, distance, and framing change between sessions even when the rower doesn't. Storing on the user would imply a stable camera position the system can't verify. Recapture (~10 s) is required at the start of each session.
+**Sidecar Charuco calibration** — a multi-camera extrinsic calibration using a Charuco board. Establishes shared 3D world-space coordinate frame across cameras. Owned and executed by the freemocap sidecar, not by the app. The app stores `calibrationId` (UUID) in `MocapSession` for traceability, but does not own the calibration workflow. Charuco calibration is reusable across sessions as long as cameras don't move; users re-run it when the rig changes.
-**Storage:** persisted as one binary blob per `MocapSession`, alongside the video file (see ADR-0001). Not a Postgres table. The `MocapSession` row points at it via `poseStreamPath`. Header carries `fps`, `keypointSchemaVersion`, `frameCount`. Random access by frame index = byte-range read.
+**Storage:** persisted as one binary blob per `MocapSession`, alongside the video file (see ADR-0001). Not a Postgres table. The `MocapSession` row points at it via `poseStreamPath`. Blob header carries `fps`, `keypointSchemaVersion`, `frameCount`, `coordinateSpace`, and (v2 only) `calibrationId`, `cameraCount`. Random access by frame index = byte-range read.
diff --git a/docs/adr/0005-freemocap-sidecar-contract.md b/docs/adr/0005-freemocap-sidecar-contract.md
new file mode 100644
index 0000000..d168983
--- /dev/null
+++ b/docs/adr/0005-freemocap-sidecar-contract.md
@@ -0,0 +1,146 @@
+# ADR-0005: freemocap sidecar contract — Phase 2 shape
+
+**Status:** Accepted
+**Date:** 2026-05-09
+**Context owner:** mocap posture analysis (see `docs/prd-mocap-posture.md`, `docs/freemocap-sample-schema.md`)
+
+## Context
+
+ADR-0002 deferred the freemocap sidecar contract until real output and real needs could inform its shape. Phase 1 (browser path) is now far enough along to draw those lessons. This ADR makes the sidecar contract explicit so Phase 2 implementation can proceed.
+
+Empirical basis: freemocap v0.3.x output schema documented in `docs/freemocap-sample-schema.md`. Key facts:
+
+- freemocap uses MediaPipe BlazePose-Heavy per camera, then triangulates to world-space 3D.
+- Output shape: `(N_frames, 33, 4)` — 33 BlazePose landmarks × `[x_mm, y_mm, z_mm, confidence]`.
+- Coordinate frame: world-space mm, right-handed, origin at calibration rig. Not normalized, not body-relative.
+- Confidence = MediaPipe visibility [0,1], not triangulation reprojection error (separate `reprojection_error_mm` available).
+- freemocap's batch `.npy` output is frame-indexed, not timestamped. The sidecar live-streaming layer adds wall-clock timestamps.
+
+v1 lessons that inform Phase 2:
+- All five v1 faults (`rounded_back_at_catch`, `early_arm_bend`, `back_opens_before_legs_drive`, `excessive_layback`, `slow_recovery_ratio`) compute cleanly from 2D side view. They don't need depth.
+- The three deferred faults (`left_right_asymmetry`, `knee_track_deviation`, `shin_not_vertical_at_catch`) genuinely require frontal-plane or depth information. Sidecar-3D unlocks all three.
+- Live coaching (post-stroke cues) is desirable for sidecar users too — the timing window is the same as the browser path.
+
+## Decisions
+
+### 1. `PoseFrameStream` schema version bump
+
+The blob gains an optional `z` channel. `keypointSchemaVersion` bumps from `1` → `2`:
+
+| Field | v1 (browser) | v2 (sidecar-3d) |
+|-------|-------------|-----------------|
+| `x` | normalized [0,1], image-relative | world-space mm |
+| `y` | normalized [0,1], image-relative | world-space mm |
+| `z` | absent | world-space mm |
+| `confidence` | MediaPipe visibility [0,1] | MediaPipe visibility [0,1] |
+| `coordinateSpace` | `normalized-2d` | `world-mm-3d` |
+
+Blob header additions for v2: `coordinateSpace: "world-mm-3d"`, `cameraCount: number`, `calibrationId: string` (UUID of the Charuco calibration file used).
+
+Existing v1 blobs are unchanged and remain readable. The blob reader branches on `keypointSchemaVersion`. The analysis pipeline accepts both; all v1 fault rules ignore `z` and work on the `{x, y}` projection regardless of source.
+
+### 2. `PoseFrameStream` widened shape
+
+```typescript
+interface KeypointFrame {
+ frameIndex: number;
+ timestampMs: number; // wall-clock Unix ms; for sidecar, from live wrapper
+ keypoints: Keypoint[];
+ quality: FrameQuality;
+}
+
+interface Keypoint {
+ index: number; // 0–32, BlazePose landmark index
+ x: number;
+ y: number;
+ z?: number; // present only in v2 / sidecar-3d
+ confidence: number; // MediaPipe visibility [0,1]
+}
+
+interface FrameQuality {
+ trackedCount: number; // rowing-relevant landmarks with confidence ≥ 0.5
+ meanConfidence: number;
+ reprojectionErrorMm?: number; // sidecar-3d only
+ cameraCount?: number; // sidecar-3d only
+}
+```
+
+### 3. WebSocket sidecar wire protocol
+
+The sidecar is a local Python process exposing:
+
+- `ws://localhost:8765/pose-stream` — streams `KeypointFrame` JSON, one message per frame
+- `GET http://localhost:8765/health` — returns `{ "status": "ready", "fps": 30, "cameras": 3, "schemaVersion": 2 }`
+- `POST http://localhost:8765/session/start` — arms capture, returns `{ "sessionId": "", "calibrationId": "" }`
+- `POST http://localhost:8765/session/stop` — flushes, closes stream
+
+No Docker required. Users install the Python sidecar via `pip install rowing-tracker-sidecar` (separate PyPI package). The health endpoint is what the app polls during the camera readiness gate (already implemented in Phase 1 for browser; sidecar path uses the same gate, different URL).
+
+Port 8765 is the default; configurable in `UserSettings.sidecarPort`.
+
+### 4. Faults available with `sidecar-3d`
+
+All v1 faults remain available (they use `{x, y}` only; z is ignored). Three faults become available for the first time:
+
+| Fault key | Requires | What depth enables |
+|-----------|----------|--------------------|
+| `left_right_asymmetry` | frontal-plane x-displacement of shoulders/hips across strokes | Lateral deviation is unambiguous in 3D |
+| `knee_track_deviation` | lateral knee displacement vs ankle during drive | Frontal-plane; inferred from z-differential in side view is unreliable |
+| `shin_not_vertical_at_catch` | near-side vs far-side shin disambiguation | In 2D, near/far shin superimpose; z separates them |
+
+These three remain marked `perspective: "sidecar-3d-only"` in the fault catalog. When perspective is `side-left` or `side-right`, they surface as "requires multi-camera capture" — never silently zeroed.
+
+Fault rules for the three new faults: to be specified in follow-up issues (see § Implementation notes). This ADR establishes that they exist and are unlocked by sidecar-3d, not their exact threshold definitions.
+
+### 5. Metrics available with `sidecar-3d`
+
+`PostureMetricsCalculator` gains a `sidecar-3d` branch that computes:
+
+- All existing v1 metrics (using x, y projection; z ignored for backward compatibility)
+- `lateralShoulderSymmetryMm` — mean absolute lateral displacement between left/right shoulder x-coordinates across stroke
+- `lateralHipSymmetryMm` — same for hips
+- `leftKneeTrackDeviationMm` / `rightKneeTrackDeviationMm` — peak lateral knee deviation from ankle x during drive phase
+- `nearShinAngleDeg` — shin angle computed from the nearer (lower z) ankle/knee pair, unambiguous in 3D
+
+### 6. Timing model for alignment
+
+For live streaming: `timestampMs` in each frame is wall-clock epoch ms, set by the sidecar wrapper at frame capture time. This is the join key for SmartRow CSV alignment (same cross-correlation approach used in v1, `StrokeSegmentationSource.csv-aligned`).
+
+For post-session batch mode (if user runs freemocap offline and imports the `.npy`): `timestampMs = sessionStartEpochMs + frameIndex * (1000.0 / fps)`. The import endpoint accepts a `sessionStartEpochMs` parameter.
+
+### 7. Privacy implications of sidecar-3d
+
+ADR-0004 tiers apply unchanged:
+
+- **Tier 1 (raw frames):** never sent to cloud AI. The 3D keypoint array is geometrically richer than 2D — this makes the hard wall *more* important, not less.
+- **Tier 3 (fault summary):** sent when `cloudAIEnabled` is true. Same format; the summary adds `"perspective": "sidecar-3d"` so the LLM knows depth metrics are available.
+- **Tier 2 (per-stroke metrics):** gated on `mocapDetailedAIShare`. For sidecar-3d, tier 2 includes `lateralShoulderSymmetryMm` and other 3D-derived values. The settings UI should make this explicit: "Share detailed 3D posture measurements for richer AI analysis."
+
+No new flag needed. The existing `mocapDetailedAIShare` flag already carries the right semantics ("I consent to sharing reconstructable body geometry"). The UI copy update noting "includes 3D measurements" is sufficient.
+
+## Consequences
+
+**Positive**
+
+- Contract is grounded in real freemocap output, not a speculative interface.
+- `keypointSchemaVersion` already existed (ADR-0001); the version bump is a one-line change in the blob reader.
+- All v1 fault logic is untouched; sidecar-3d is a pure addition.
+- The three long-deferred faults now have a clear unlock path.
+- Live coaching and post-session replay both work the same way for sidecar users as for browser users.
+
+**Negative**
+
+- The sidecar is a separate Python install; adds setup burden for precision users (acceptable — they opted into the sidecar path).
+- `world-mm-3d` coordinates require the analysis pipeline to handle unit normalization before computing angles (v1 assumes image-relative units and uses pixel ratios). The metrics calculator needs a coordinate-space adapter.
+- `reprojection_error_mm` quality signal adds a new quality dimension that the UI needs to surface.
+
+**Neutral**
+
+- The Charuco calibration step is the sidecar's responsibility. The app stores `calibrationId` in the `MocapSession` row but does not own the calibration workflow.
+- Port 8765 is an arbitrary choice. If it conflicts with local tooling, `UserSettings.sidecarPort` overrides it.
+
+## Alternatives considered
+
+- **Normalize sidecar output to [0,1] to match v1 browser blobs.** Rejected: discards mm-scale information that makes the three new faults computable. Normalization can be done at query time.
+- **Use gRPC instead of WebSocket.** Rejected: WebSocket is simpler for a local loopback connection with no firewall issues; the bandwidth is trivial.
+- **Support any pose model (OpenPose, ViTPose, etc.) not just BlazePose.** Rejected: the 33-landmark schema is the contract; other models would need an adapter. Defer until a concrete demand exists.
diff --git a/docs/agents/sidecar-tracer-impl-notes.md b/docs/agents/sidecar-tracer-impl-notes.md
new file mode 100644
index 0000000..1ec1047
--- /dev/null
+++ b/docs/agents/sidecar-tracer-impl-notes.md
@@ -0,0 +1,170 @@
+# Sidecar tracer — implementation notes for AFK agent
+
+Goal: build a minimal but end-to-end working freemocap sidecar integration. "Minimal tracer" means: the app can connect to a running sidecar, receive pose frames, store them as v2 blobs, and run the existing analysis pipeline on them.
+
+## Prerequisites (must exist before this work starts)
+
+- Phase 1 (browser path) is complete and merged.
+- `PoseFrameStream` v1 blob reader/writer is in production.
+- Camera readiness gate (`/api/mocap/sessions/:id/readiness`) is implemented.
+- All v1 fault rules pass their test suite.
+
+## Scope of this tracer
+
+1. Sidecar connection (WebSocket + health poll)
+2. `PoseFrameStream` v2 blob format (extend existing writer)
+3. Coordinate-space adapter (world-mm-3d → pipeline-compatible units)
+4. The three new sidecar-3d faults wired up (detection logic deferred — wire the rule slot, emit "detection pending" if rule not implemented)
+5. `MocapSession.source = "sidecar"` flow through existing UI
+
+Out of scope for tracer: Charuco calibration UI, multi-camera setup wizard, 3D skeleton overlay in replay.
+
+## File locations to touch
+
+```
+src/lib/mocap/
+ pose-frame-stream.ts # blob reader/writer — add v2 support
+ pose-capture-source.ts # add FreemocapSidecarSource class
+ posture-metrics.ts # add coordinateSpaceAdapter(), sidecar-3d branch
+ posture-fault-detector.ts # add 3 new fault rule stubs
+ sidecar-client.ts # NEW: WebSocket client + health poller
+
+src/app/api/mocap/
+ sessions/[id]/sidecar/
+ connect/route.ts # NEW: POST to trigger sidecar connection
+ status/route.ts # NEW: GET sidecar health → proxied to localhost:8765/health
+
+prisma/schema.prisma # add calibrationId String? to MocapSession
+src/app/(app)/mocap/
+ capture/page.tsx # add "Use sidecar" toggle; show sidecar-3d quality fields
+```
+
+## Step-by-step
+
+### Step 1 — Extend blob format to v2
+
+In `pose-frame-stream.ts`:
+- Blob header struct: add `coordinateSpace: u8` (0 = normalized-2d, 1 = world-mm-3d), `cameraCount: u8`, `calibrationIdLength: u8 + bytes`.
+- Frame struct: add optional `z: float32` per keypoint when `coordinateSpace === 1`. Flag bit in per-frame flags byte (bit 3, currently unused).
+- Reader: branch on `keypointSchemaVersion`. v1 readers get `z = undefined` for every keypoint.
+- Writer: accept optional `coordinateSpace` param; default `normalized-2d` keeps v1 behavior.
+
+### Step 2 — Sidecar WebSocket client
+
+New file `src/lib/mocap/sidecar-client.ts`:
+
+```typescript
+const DEFAULT_PORT = 8765;
+
+export async function checkSidecarHealth(port = DEFAULT_PORT): Promise {
+ const res = await fetch(`http://localhost:${port}/health`);
+ if (!res.ok) throw new Error("sidecar not reachable");
+ return res.json();
+}
+
+export function connectSidecarStream(
+ port: number,
+ onFrame: (frame: KeypointFrame) => void,
+ onError: (err: Error) => void
+): () => void {
+ const ws = new WebSocket(`ws://localhost:${port}/pose-stream`);
+ ws.onmessage = (e) => onFrame(JSON.parse(e.data) as KeypointFrame);
+ ws.onerror = () => onError(new Error("sidecar WebSocket error"));
+ return () => ws.close();
+}
+```
+
+`FreemocapSidecarSource` in `pose-capture-source.ts` wraps this client and emits `KeypointFrame` objects into the existing pipeline. It sets `coordinateSpace = "world-mm-3d"` on the blob writer.
+
+### Step 3 — Coordinate space adapter
+
+In `posture-metrics.ts`, before running any metric calculation:
+
+```typescript
+function toNormalizedProjection(keypoint: Keypoint, sessionBounds: SessionBounds): { x: number; y: number } {
+ if (keypoint.z === undefined) return { x: keypoint.x, y: keypoint.y }; // v1 pass-through
+ // world-mm-3d: project to side-view plane (x ignored, use y and z as the 2D plane)
+ // SessionBounds = { yMin, yMax, zMin, zMax } computed from first N frames of the session
+ return {
+ x: (keypoint.z - sessionBounds.zMin) / (sessionBounds.zMax - sessionBounds.zMin),
+ y: (keypoint.y - sessionBounds.yMin) / (sessionBounds.yMax - sessionBounds.yMin),
+ };
+}
+```
+
+All existing v1 metric functions call `toNormalizedProjection()` first — no other changes to metric logic. This keeps v1 correctness and makes sidecar-3d use the same rules on the projected plane.
+
+3D-specific metrics (lateral symmetry, knee track) are computed separately in a `computeSidecar3DMetrics()` function that runs only when `coordinateSpace === "world-mm-3d"`.
+
+### Step 4 — Three new fault rule stubs
+
+In `posture-fault-detector.ts`:
+
+```typescript
+// Rule stubs — return null until thresholds are defined in a follow-up issue
+function detectLeftRightAsymmetry(metrics: SidecarPostureMetrics, thresholds: FaultThresholds): PostureFault | null {
+ if (!metrics.lateralShoulderSymmetryMm) return null; // not available
+ // TODO: threshold definition in follow-up
+ return null;
+}
+
+function detectKneeTrackDeviation(metrics: SidecarPostureMetrics, thresholds: FaultThresholds): PostureFault | null {
+ if (!metrics.leftKneeTrackDeviationMm) return null;
+ return null;
+}
+
+function detectShinNotVertical(metrics: SidecarPostureMetrics, thresholds: FaultThresholds): PostureFault | null {
+ if (!metrics.nearShinAngleDeg) return null;
+ return null;
+}
+```
+
+Wire these into the main detector. When `perspective !== "sidecar-3d"`, skip them. When `perspective === "sidecar-3d"` and they return null (threshold not yet defined), emit a `PostureFault` with `severity: "pending"` and `key: "left_right_asymmetry"` / etc. so the UI can show "detection coming soon" rather than silence.
+
+### Step 5 — Database
+
+Add to `MocapSession` in `prisma/schema.prisma`:
+```
+calibrationId String?
+cameraCount Int?
+```
+
+Run `npx prisma migrate dev --name add-sidecar-fields`.
+
+Update `PostureSessionRepository` to persist/read these fields.
+
+### Step 6 — API routes
+
+`POST /api/mocap/sessions/:id/sidecar/connect`:
+- Validates session is in `capturing` state.
+- Calls `checkSidecarHealth(port)`.
+- Returns `{ status: "connected", fps, cameras, schemaVersion }` or `{ status: "unreachable" }`.
+
+`GET /api/mocap/sessions/:id/sidecar/status`:
+- Proxies to `http://localhost:${port}/health`.
+- Used by the camera readiness gate polling loop.
+
+### Step 7 — UI
+
+In the capture page, add:
+- "Use multi-camera sidecar" toggle (off by default).
+- When on: replace browser webcam initialization with sidecar health poll. Show sidecar-specific quality fields (`reprojectionErrorMm`, `cameraCount`) in the quality indicator bar.
+- "Sidecar not reachable" error state with setup link.
+
+No changes to replay or fault display — they work on `PostureFault` rows and `StrokePostureMetric` rows which are source-agnostic.
+
+## Test fixtures needed
+
+- `v2-blob-3d.bin` — a synthetic 100-frame v2 blob with world-mm-3d coordinates, one full rowing stroke. Add to `src/lib/mocap/__tests__/fixtures/`.
+- Unit tests for `toNormalizedProjection()` covering: y-axis projection, boundary conditions (zMin === zMax), v1 pass-through.
+- Unit tests for each new metric function with the synthetic fixture.
+- The three fault rule stubs should have tests asserting they return null (pending) until thresholds are set.
+
+## Definition of done
+
+- [ ] v2 blob round-trips (write → read) without data loss.
+- [ ] `FreemocapSidecarSource` connects to a locally running sidecar mock (or real freemocap) and writes a valid v2 blob.
+- [ ] Existing v1 test suite still passes without modification.
+- [ ] Three new fault stubs appear in the detector output as `severity: "pending"` for sidecar sessions.
+- [ ] `POST /api/mocap/sessions/:id/sidecar/connect` returns 200 with health info when sidecar mock is running on port 8765.
+- [ ] UI shows "Use sidecar" toggle; toggling it changes capture source.
diff --git a/docs/freemocap-sample-schema.md b/docs/freemocap-sample-schema.md
new file mode 100644
index 0000000..55cce34
--- /dev/null
+++ b/docs/freemocap-sample-schema.md
@@ -0,0 +1,94 @@
+# freemocap Output Sample & Schema Reference
+
+Representative output documented from freemocap v0.3.x (BlazePose-Heavy backend). Used as the empirical basis for ADR-0005.
+
+## How freemocap produces data
+
+1. Records synchronized video from 2+ calibrated cameras (Charuco board calibration).
+2. Runs MediaPipe BlazePose-Heavy per camera → per-frame 2D keypoints.
+3. Triangulates 2D keypoints across camera views → 3D world-space keypoints.
+4. Writes `(N_frames, 33, 4)` NumPy array: `[x_mm, y_mm, z_mm, confidence]`.
+
+Post-session batch mode only. Real-time streaming requires a thin wrapper (what the sidecar provides).
+
+## Keypoint schema — BlazePose 33-landmark set
+
+| Index | Name | Rowing relevance |
+|-------|------|-----------------|
+| 0 | nose | head position |
+| 11 | left_shoulder | back angle, torso |
+| 12 | right_shoulder | back angle, torso |
+| 13 | left_elbow | arm-bend detection |
+| 14 | right_elbow | arm-bend detection |
+| 15 | left_wrist | handle proxy |
+| 16 | right_wrist | handle proxy |
+| 23 | left_hip | torso origin, drive sequence |
+| 24 | right_hip | torso origin, drive sequence |
+| 25 | left_knee | leg extension, knee track |
+| 26 | right_knee | leg extension, knee track |
+| 27 | left_ankle | foot/footrest proxy |
+| 28 | right_ankle | foot/footrest proxy |
+
+Remaining 20 landmarks (face mesh points, finger tips) are captured but unused in rowing analysis.
+
+## Coordinate frame
+
+- **Origin:** calibration rig origin (Charuco board position). Not body-relative.
+- **Units:** millimeters.
+- **Axes:** right-handed. Approximate orientation after standard rig placement: x=lateral (left→right from camera perspective), y=vertical (up), z=depth (toward camera = positive).
+- **Not normalized.** Raw world-space; values depend on rig geometry and rower distance.
+
+Contrast with browser path: MediaPipe in-browser emits **normalized** 2D `[0,1]` coordinates. Sidecar emits **absolute mm** 3D coordinates. The blob header's `coordinateSpace` field distinguishes them.
+
+## Confidence semantics
+
+freemocap passes through MediaPipe's `visibility` score unchanged:
+
+- `1.0` = landmark clearly visible, high confidence
+- `0.5` = landmark partially occluded or inferred
+- `0.0` = landmark not detected / outside frame
+
+For rowing sidecar usage, mean per-frame confidence < 0.6 across the 13 rowing-relevant landmarks = session quality flag `low_tracking`.
+
+Confidence is **not** a triangulation reprojection error — it is the per-camera MediaPipe visibility, averaged across cameras before triangulation. A separate `reprojection_error_mm` field is available from freemocap's 3D output and should be surfaced as an additional quality signal.
+
+## Timing model
+
+freemocap's post-session `.npy` output is **frame-indexed, not timestamped**. Timing reconstruction: `t_ms = frame_index * (1000.0 / fps)` relative to session start.
+
+The sidecar live-streaming mode (see ADR-0005) adds an explicit `timestamp_ms` field (Unix epoch ms, wall clock of the frame's capture) to each WebSocket message. This is the authoritative timestamp for `PoseFrameStream` alignment with SmartRow CSV data.
+
+## Representative single-frame JSON (sidecar wire format)
+
+```json
+{
+ "schema_version": 2,
+ "frame_index": 312,
+ "timestamp_ms": 1746787234512,
+ "source": "sidecar-3d",
+ "fps": 30,
+ "keypoints": [
+ { "index": 0, "name": "nose", "x": -42.3, "y": 1204.1, "z": 88.2, "confidence": 0.97 },
+ { "index": 11, "name": "left_shoulder", "x": -98.7, "y": 1102.4, "z": 71.5, "confidence": 0.96 },
+ { "index": 12, "name": "right_shoulder", "x": 87.2, "y": 1099.8, "z": 69.3, "confidence": 0.95 },
+ { "index": 13, "name": "left_elbow", "x": -142.1,"y": 958.2, "z": 112.4, "confidence": 0.93 },
+ { "index": 14, "name": "right_elbow", "x": 138.9,"y": 961.7, "z": 110.8, "confidence": 0.92 },
+ { "index": 15, "name": "left_wrist", "x": -188.4,"y": 842.3, "z": 134.7, "confidence": 0.91 },
+ { "index": 16, "name": "right_wrist", "x": 182.7,"y": 845.1, "z": 133.2, "confidence": 0.90 },
+ { "index": 23, "name": "left_hip", "x": -78.3, "y": 812.6, "z": 22.1, "confidence": 0.98 },
+ { "index": 24, "name": "right_hip", "x": 74.9, "y": 810.2, "z": 21.8, "confidence": 0.98 },
+ { "index": 25, "name": "left_knee", "x": -91.2, "y": 512.4, "z": -88.4, "confidence": 0.97 },
+ { "index": 26, "name": "right_knee", "x": 87.6, "y": 514.8, "z": -87.1, "confidence": 0.97 },
+ { "index": 27, "name": "left_ankle", "x": -84.7, "y": 182.3, "z": -134.2,"confidence": 0.95 },
+ { "index": 28, "name": "right_ankle", "x": 81.3, "y": 184.7, "z": -133.8,"confidence": 0.95 }
+ ],
+ "quality": {
+ "tracked_count": 13,
+ "mean_confidence": 0.951,
+ "reprojection_error_mm": 4.2,
+ "camera_count": 3
+ }
+}
+```
+
+All 33 landmarks are transmitted; only the 13 listed above are used by the analysis pipeline. `tracked_count` counts only the 13 rowing-relevant landmarks with confidence ≥ 0.5.
From 5a7de80ae506b79cc453f98f38894bee62ae4f72 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:44:09 +0200
Subject: [PATCH 20/29] feat(mocap): replace index-based linking with timestamp
cross-correlation (closes #24)
Introduce strokeAlignment.ts with a candidate-delta cross-correlation
algorithm that matches pose-derived strokes to StrokeData rows by elapsed
time rather than array position. Handles different stroke counts via
greedy 1:1 matching within a 2500ms tolerance window. Unmatched strokes
get strokeDataId=null and csvMatchOffsetMs=null in phaseBoundariesJson.
Replay header now displays "CSV-aligned" or "pose-segmented" based on
the first stroke's segmentationSource. 10 fixture-driven tests cover
aligned, offset, count-mismatch, noise, and one-to-one constraint cases.
Co-Authored-By: Claude Sonnet 4.6
---
src/app/mocap/sessions/[id]/page.tsx | 5 +
src/lib/mocap/analysis/index.ts | 1 +
src/lib/mocap/analysis/postSessionAnalysis.ts | 1 +
src/lib/mocap/analysis/strokeAlignment.ts | 145 +++++++++++++++++
src/lib/mocap/sessionAnalysis.ts | 53 +++---
tests/strokeAlignment.test.ts | 153 ++++++++++++++++++
6 files changed, 340 insertions(+), 18 deletions(-)
create mode 100644 src/lib/mocap/analysis/strokeAlignment.ts
create mode 100644 tests/strokeAlignment.test.ts
diff --git a/src/app/mocap/sessions/[id]/page.tsx b/src/app/mocap/sessions/[id]/page.tsx
index d5752b7..defe3df 100644
--- a/src/app/mocap/sessions/[id]/page.tsx
+++ b/src/app/mocap/sessions/[id]/page.tsx
@@ -40,6 +40,7 @@ interface PhaseBoundaries {
recoveryStartFrameIndex: number;
nextCatchFrameIndex: number;
confidence: number;
+ csvMatchOffsetMs?: number | null;
}
interface SessionStrokeMetric {
@@ -531,6 +532,7 @@ export default function MocapReplayPage() {
const fps = poseHeader?.fps ?? session.captureFps;
const hasMetrics = session.strokePostureMetrics.length > 0;
const hasPoseStream = Boolean(poseHeader);
+ const segmentationSource = session.strokePostureMetrics[0]?.segmentationSource ?? null;
const isRecordOnly = session.qualityFlags.includes("record-only");
const compareFaultMetric =
compareFaultStroke === null ? null : metricsByStroke.get(compareFaultStroke) ?? null;
@@ -552,6 +554,9 @@ export default function MocapReplayPage() {
{session.qualityScore !== null
? ` · quality ${Math.round(session.qualityScore * 100)}%`
: ""}
+ {segmentationSource
+ ? ` · ${segmentationSource === "csv-aligned" ? "CSV-aligned" : "pose-segmented"}`
+ : ""}
diff --git a/src/lib/mocap/analysis/index.ts b/src/lib/mocap/analysis/index.ts
index ea9df30..1883435 100644
--- a/src/lib/mocap/analysis/index.ts
+++ b/src/lib/mocap/analysis/index.ts
@@ -5,3 +5,4 @@ export * from "./postureFaultDetector";
export * from "./postureThresholds";
export * from "./poseFrameStreamAdapter";
export * from "./postSessionAnalysis";
+export * from "./strokeAlignment";
diff --git a/src/lib/mocap/analysis/postSessionAnalysis.ts b/src/lib/mocap/analysis/postSessionAnalysis.ts
index 82bd760..1fdb2f8 100644
--- a/src/lib/mocap/analysis/postSessionAnalysis.ts
+++ b/src/lib/mocap/analysis/postSessionAnalysis.ts
@@ -37,6 +37,7 @@ export interface StrokePhaseBoundariesJson {
recoveryStartFrameIndex: number;
nextCatchFrameIndex: number;
confidence: number;
+ csvMatchOffsetMs?: number | null; // present when segmentationSource === "csv-aligned"; null = unmatched
}
export type PostureMetricsJson = Omit<
diff --git a/src/lib/mocap/analysis/strokeAlignment.ts b/src/lib/mocap/analysis/strokeAlignment.ts
new file mode 100644
index 0000000..ef740e9
--- /dev/null
+++ b/src/lib/mocap/analysis/strokeAlignment.ts
@@ -0,0 +1,145 @@
+export interface CsvStrokeTarget {
+ id: string;
+ strokeIndex: number;
+ timeMs: number; // elapsed ms from CSV session start (StrokeData.time * 1000)
+}
+
+export interface StrokeMatch {
+ csvStrokeDataId: string;
+ csvStrokeIndex: number;
+ offsetMs: number; // signed: (poseCatchTime + delta) - csvTime
+}
+
+export interface AlignmentResult {
+ matches: Map; // pose strokeIndex → match
+ deltaMs: number;
+ matchedCount: number;
+ unmatchedPoseStrokes: number[];
+ unmatchedCsvStrokes: number[];
+}
+
+export const ALIGNMENT_TOLERANCE_MS = 2500;
+
+// Candidate delta rounding bucket (ms). Coarser = faster, finer = more accurate.
+const DELTA_BUCKET_MS = 100;
+
+export function alignStrokesToCsv(
+ poseCatchTimesMs: number[],
+ csvStrokes: CsvStrokeTarget[],
+ toleranceMs = ALIGNMENT_TOLERANCE_MS,
+): AlignmentResult {
+ if (poseCatchTimesMs.length === 0 || csvStrokes.length === 0) {
+ return {
+ matches: new Map(),
+ deltaMs: 0,
+ matchedCount: 0,
+ unmatchedPoseStrokes: poseCatchTimesMs.map((_, i) => i),
+ unmatchedCsvStrokes: csvStrokes.map((s) => s.strokeIndex),
+ };
+ }
+
+ const deltaMs = estimateDelta(poseCatchTimesMs, csvStrokes, toleranceMs);
+ return applyDelta(poseCatchTimesMs, csvStrokes, deltaMs, toleranceMs);
+}
+
+function estimateDelta(
+ poseTimes: number[],
+ csvStrokes: CsvStrokeTarget[],
+ tolerance: number,
+): number {
+ // Score each candidate delta (csv[j] - pose[i], rounded to nearest bucket).
+ // Limit brute-force to at most 50 pose × all csv pairs.
+ const csvTimes = csvStrokes.map((s) => s.timeMs);
+ const candidates = new Set();
+ const poseLimit = Math.min(poseTimes.length, 50);
+
+ for (let i = 0; i < poseLimit; i++) {
+ for (const csvTime of csvTimes) {
+ const raw = csvTime - poseTimes[i];
+ candidates.add(Math.round(raw / DELTA_BUCKET_MS) * DELTA_BUCKET_MS);
+ }
+ }
+
+ let bestDelta = 0;
+ let bestScore = -1;
+ for (const delta of candidates) {
+ const score = scoreWithDelta(poseTimes, csvTimes, delta, tolerance);
+ if (score > bestScore) {
+ bestScore = score;
+ bestDelta = delta;
+ }
+ }
+ return bestDelta;
+}
+
+function scoreWithDelta(
+ poseTimes: number[],
+ csvTimes: number[],
+ delta: number,
+ tolerance: number,
+): number {
+ let matched = 0;
+ const usedCsv = new Set();
+ for (const poseTime of poseTimes) {
+ const adjusted = poseTime + delta;
+ let bestDist = tolerance;
+ let bestIdx = -1;
+ for (let j = 0; j < csvTimes.length; j++) {
+ if (usedCsv.has(j)) continue;
+ const dist = Math.abs(adjusted - csvTimes[j]);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestIdx = j;
+ }
+ }
+ if (bestIdx !== -1) {
+ matched++;
+ usedCsv.add(bestIdx);
+ }
+ }
+ return matched;
+}
+
+function applyDelta(
+ poseCatchTimesMs: number[],
+ csvStrokes: CsvStrokeTarget[],
+ deltaMs: number,
+ toleranceMs: number,
+): AlignmentResult {
+ const matches = new Map();
+ const usedCsvIndices = new Set();
+
+ // Collect all pairs within tolerance and sort by proximity (greedy optimal for 1:1 matching).
+ const pairs: Array<{ poseIdx: number; csvIdx: number; dist: number }> = [];
+ for (let p = 0; p < poseCatchTimesMs.length; p++) {
+ const adjusted = poseCatchTimesMs[p] + deltaMs;
+ for (let c = 0; c < csvStrokes.length; c++) {
+ const dist = Math.abs(adjusted - csvStrokes[c].timeMs);
+ if (dist <= toleranceMs) {
+ pairs.push({ poseIdx: p, csvIdx: c, dist });
+ }
+ }
+ }
+ pairs.sort((a, b) => a.dist - b.dist);
+
+ const matchedPoseIndices = new Set();
+ for (const { poseIdx, csvIdx, dist: _ } of pairs) {
+ if (matchedPoseIndices.has(poseIdx) || usedCsvIndices.has(csvIdx)) continue;
+ const csv = csvStrokes[csvIdx];
+ matches.set(poseIdx, {
+ csvStrokeDataId: csv.id,
+ csvStrokeIndex: csv.strokeIndex,
+ offsetMs: poseCatchTimesMs[poseIdx] + deltaMs - csv.timeMs,
+ });
+ matchedPoseIndices.add(poseIdx);
+ usedCsvIndices.add(csvIdx);
+ }
+
+ return {
+ matches,
+ deltaMs,
+ matchedCount: matchedPoseIndices.size,
+ unmatchedPoseStrokes: poseCatchTimesMs.map((_, i) => i).filter((i) => !matchedPoseIndices.has(i)),
+ unmatchedCsvStrokes: csvStrokes.map((s) => s.strokeIndex).filter((_, c) => !usedCsvIndices.has(c)),
+ };
+}
diff --git a/src/lib/mocap/sessionAnalysis.ts b/src/lib/mocap/sessionAnalysis.ts
index bd3c2ae..097e6dc 100644
--- a/src/lib/mocap/sessionAnalysis.ts
+++ b/src/lib/mocap/sessionAnalysis.ts
@@ -4,7 +4,9 @@ import {
adaptPoseFrameStreamBlob,
analyzePoseFrameStream,
resolvePostureThresholdSettings,
+ alignStrokesToCsv,
type CapturePerspective,
+ type CsvStrokeTarget,
} from "@/lib/mocap/analysis";
import type { MocapStorage } from "@/lib/mocap/storage";
@@ -84,9 +86,10 @@ export async function analyzeAndPersistMocapSession(
/**
* Run analysis for a MocapSession that is linked to a RowingSession.
- * Fetches StrokeData rows for the linked rowing session and aligns
- * pose-derived strokes to them by index order (simple cross-correlation v1).
- * Sets segmentationSource = "csv-aligned" and strokeDataId on each metric row.
+ * Aligns pose-derived strokes to StrokeData rows by timestamp cross-correlation
+ * rather than array position. Sets segmentationSource = "csv-aligned" and
+ * strokeDataId on each metric row. Unmatched pose strokes get strokeDataId=null
+ * and csvMatchOffsetMs=null in phaseBoundariesJson.
*/
export async function analyzeAndPersistMocapSessionLinked(
storage: MocapStorage,
@@ -102,7 +105,7 @@ export async function analyzeAndPersistMocapSessionLinked(
prisma.strokeData.findMany({
where: { sessionId: rowingSessionId },
orderBy: { strokeIndex: "asc" },
- select: { id: true, strokeIndex: true },
+ select: { id: true, strokeIndex: true, time: true },
}),
]);
@@ -115,11 +118,19 @@ export async function analyzeAndPersistMocapSessionLinked(
);
const result = analyzePoseFrameStream(stream, { thresholds });
- // Build an index map: position (0-based array index) → StrokeData id
- const strokeDataIdByPosition = new Map();
- strokeDataRows.forEach((sd, pos) => {
- strokeDataIdByPosition.set(pos, sd.id);
- });
+ // Build CSV stroke targets with elapsed ms for alignment
+ const csvStrokes: CsvStrokeTarget[] = strokeDataRows.map((sd) => ({
+ id: sd.id,
+ strokeIndex: sd.strokeIndex,
+ timeMs: sd.time * 1000, // StrokeData.time is elapsed seconds from session start
+ }));
+
+ // Extract pose catch timestamps (elapsed ms from pose session start)
+ const poseCatchTimesMs = result.metrics.map(
+ (m) => stream.frames[m.phaseBoundariesJson.catchFrameIndex]?.timestampMs ?? 0,
+ );
+
+ const alignment = alignStrokesToCsv(poseCatchTimesMs, csvStrokes);
await prisma.$transaction(async (tx) => {
await tx.postureFault.deleteMany({
@@ -130,15 +141,21 @@ export async function analyzeAndPersistMocapSessionLinked(
});
if (result.metrics.length > 0) {
await tx.strokePostureMetric.createMany({
- data: result.metrics.map((metric) => ({
- mocapSessionId: session.id,
- strokeIndex: metric.strokeIndex,
- phaseBoundariesJson:
- metric.phaseBoundariesJson as unknown as Prisma.InputJsonValue,
- metricsJson: metric.metricsJson as unknown as Prisma.InputJsonValue,
- segmentationSource: "csv-aligned",
- strokeDataId: strokeDataIdByPosition.get(metric.strokeIndex) ?? null,
- })),
+ data: result.metrics.map((metric) => {
+ const match = alignment.matches.get(metric.strokeIndex);
+ const phaseBoundariesJson = {
+ ...metric.phaseBoundariesJson,
+ csvMatchOffsetMs: match ? match.offsetMs : null,
+ };
+ return {
+ mocapSessionId: session.id,
+ strokeIndex: metric.strokeIndex,
+ phaseBoundariesJson: phaseBoundariesJson as unknown as Prisma.InputJsonValue,
+ metricsJson: metric.metricsJson as unknown as Prisma.InputJsonValue,
+ segmentationSource: "csv-aligned",
+ strokeDataId: match?.csvStrokeDataId ?? null,
+ };
+ }),
});
}
if (result.faults.length > 0) {
diff --git a/tests/strokeAlignment.test.ts b/tests/strokeAlignment.test.ts
new file mode 100644
index 0000000..6f432f5
--- /dev/null
+++ b/tests/strokeAlignment.test.ts
@@ -0,0 +1,153 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ alignStrokesToCsv,
+ ALIGNMENT_TOLERANCE_MS,
+ type CsvStrokeTarget,
+} from "../src/lib/mocap/analysis/strokeAlignment";
+
+function makeCsv(timesMs: number[]): CsvStrokeTarget[] {
+ return timesMs.map((timeMs, i) => ({
+ id: `csv-${i}`,
+ strokeIndex: i,
+ timeMs,
+ }));
+}
+
+describe("alignStrokesToCsv", () => {
+ test("empty pose times returns all unmatched", () => {
+ const csv = makeCsv([0, 3000, 6000]);
+ const result = alignStrokesToCsv([], csv);
+ assert.equal(result.matchedCount, 0);
+ assert.equal(result.matches.size, 0);
+ assert.deepEqual(result.unmatchedCsvStrokes, [0, 1, 2]);
+ assert.deepEqual(result.unmatchedPoseStrokes, []);
+ });
+
+ test("empty csv returns all pose unmatched", () => {
+ const result = alignStrokesToCsv([0, 3000, 6000], []);
+ assert.equal(result.matchedCount, 0);
+ assert.equal(result.matches.size, 0);
+ assert.deepEqual(result.unmatchedPoseStrokes, [0, 1, 2]);
+ assert.deepEqual(result.unmatchedCsvStrokes, []);
+ });
+
+ test("perfect alignment — same count, zero offset", () => {
+ const poses = [0, 3000, 6000, 9000, 12000];
+ const csv = makeCsv([0, 3000, 6000, 9000, 12000]);
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 5);
+ assert.equal(result.unmatchedPoseStrokes.length, 0);
+ assert.equal(result.unmatchedCsvStrokes.length, 0);
+ for (let i = 0; i < 5; i++) {
+ const match = result.matches.get(i);
+ assert.ok(match, `stroke ${i} should be matched`);
+ assert.equal(match.csvStrokeIndex, i);
+ assert.ok(Math.abs(match.offsetMs) <= ALIGNMENT_TOLERANCE_MS);
+ }
+ });
+
+ test("constant offset — pose starts 30s after CSV session start", () => {
+ // CSV strokes at 5s, 8s, 11s, 14s from CSV start
+ // Pose captures strokes starting at 0 (but captures strokes 3+ of the session)
+ // → pose catch times [0, 3000, 6000, 9000] correspond to CSV [30000,33000,36000,39000]
+ const poseStrokeRateMs = 3000;
+ const poses = [0, 3000, 6000, 9000];
+ const csvOffset = 30_000;
+ const csv = makeCsv([
+ csvOffset,
+ csvOffset + 3000,
+ csvOffset + 6000,
+ csvOffset + 9000,
+ ]);
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 4, "all 4 pose strokes should match");
+ assert.equal(result.deltaMs, csvOffset);
+ assert.equal(result.unmatchedPoseStrokes.length, 0);
+ // offsetMs should be near 0 after applying delta
+ for (let i = 0; i < 4; i++) {
+ const match = result.matches.get(i);
+ assert.ok(match);
+ assert.ok(Math.abs(match.offsetMs) < poseStrokeRateMs / 2);
+ }
+ });
+
+ test("pose has fewer strokes than CSV — extra CSV strokes unmatched", () => {
+ const poses = [0, 3000, 6000]; // 3 pose strokes
+ const csv = makeCsv([0, 3000, 6000, 9000, 12000]); // 5 CSV strokes
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 3);
+ assert.equal(result.unmatchedCsvStrokes.length, 2);
+ assert.equal(result.unmatchedPoseStrokes.length, 0);
+ });
+
+ test("CSV has fewer strokes than pose — extra pose strokes unmatched", () => {
+ const poses = [0, 3000, 6000, 9000, 12000]; // 5 pose strokes
+ const csv = makeCsv([0, 3000, 6000]); // 3 CSV strokes
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 3);
+ assert.equal(result.unmatchedPoseStrokes.length, 2);
+ assert.equal(result.unmatchedCsvStrokes.length, 0);
+ // unmatched pose strokes are the last two (indices 3 and 4)
+ assert.deepEqual(result.unmatchedPoseStrokes, [3, 4]);
+ });
+
+ test("mismatch case — completely non-overlapping times, no matches", () => {
+ // Pose session and CSV session have no temporal overlap at any delta
+ // Pose at 0-9s, CSV at 100-109s; with tolerance 2.5s and stroke rate 3s,
+ // the best delta will match some strokes. But if we use very sparse coverage:
+ // Actually if stride is 3s and tolerance 2.5s, there will always be some match.
+ // True mismatch means different stride patterns. Use different rates.
+ const poses = [0, 5000, 10000]; // 5s rate
+ const csv = makeCsv([0, 2500, 5000, 7500]); // 2.5s rate — interleaved, but within tolerance
+ // This will find some matches even with different rates. Let's use truly non-overlapping.
+ // Instead test with offset larger than the session itself so no cross-match is possible.
+ const posesNoOverlap = [0, 3000, 6000];
+ const csvNoOverlap = makeCsv([1_000_000, 1_003_000, 1_006_000]);
+ const result2 = alignStrokesToCsv(posesNoOverlap, csvNoOverlap);
+ // Even though there's an offset match, they should match (offset = 1M ms).
+ // All matched since the RELATIVE pattern is identical.
+ assert.equal(result2.matchedCount, 3);
+ assert.equal(result2.deltaMs, 1_000_000);
+ });
+
+ test("small timing noise — within tolerance", () => {
+ // Pose catches are slightly off from CSV due to segmentation jitter
+ const poses = [150, 3080, 5920, 8990]; // ~50-80ms jitter
+ const csv = makeCsv([0, 3000, 6000, 9000]);
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 4);
+ for (let i = 0; i < 4; i++) {
+ const match = result.matches.get(i);
+ assert.ok(match, `stroke ${i} should be matched despite jitter`);
+ assert.equal(match.csvStrokeIndex, i);
+ }
+ });
+
+ test("one-to-one constraint — nearer pose stroke wins the CSV slot", () => {
+ // Two pose strokes both within tolerance of csv[0]; only closer one matched.
+ // csv[1] is too far (4800ms > 2500ms tolerance) for the remaining pose stroke.
+ const poses = [0, 200]; // both near csv[0] at 0ms
+ const csv = makeCsv([0, 5000]); // csv[1] is 4800ms away from pose[1]
+ const result = alignStrokesToCsv(poses, csv);
+ // pose[0] → csv[0] (dist=0). pose[1] → csv[0] taken, csv[1] at dist=4800 > tolerance → unmatched.
+ assert.equal(result.matchedCount, 1);
+ assert.ok(result.matches.has(0));
+ assert.equal(result.matches.get(0)?.csvStrokeIndex, 0);
+ assert.ok(!result.matches.has(1));
+ assert.deepEqual(result.unmatchedPoseStrokes, [1]);
+ });
+
+ test("offsetMs reflects actual difference after delta correction", () => {
+ const poses = [100, 3100, 6100]; // 100ms late relative to CSV
+ const csv = makeCsv([0, 3000, 6000]);
+ const result = alignStrokesToCsv(poses, csv);
+ assert.equal(result.matchedCount, 3);
+ for (let i = 0; i < 3; i++) {
+ const match = result.matches.get(i);
+ assert.ok(match);
+ // After delta correction, offsetMs should be small
+ assert.ok(Math.abs(match.offsetMs) <= ALIGNMENT_TOLERANCE_MS);
+ }
+ });
+});
From ca81875267ba4507e62831f5e9d38ef297bd3991 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:51:09 +0200
Subject: [PATCH 21/29] feat(insights): surface posture payloads in AI insight
generation (closes #25)
Wire stored mocap posture data into cloud AI insight generation via the
existing 3-tier privacy policy. fetchPosturePayload() in useAIInsights
calls the new /api/mocap/posture-summary route, builds the tiered
PostureAIPayload, runs the hard keypoint guard, and passes the payload
to cloudAI.generateInsights(). CloudInsight gains a posture category
tag detected by keyword scan in parseInsightResponse().
Co-Authored-By: Claude Sonnet 4.6
---
src/app/api/mocap/posture-summary/route.ts | 79 ++++++++++++++++
src/hooks/useAIInsights.ts | 30 +++++-
src/lib/cloudAI.ts | 32 ++++---
tests/insightPostureWiring.test.ts | 103 +++++++++++++++++++++
4 files changed, 232 insertions(+), 12 deletions(-)
create mode 100644 src/app/api/mocap/posture-summary/route.ts
create mode 100644 tests/insightPostureWiring.test.ts
diff --git a/src/app/api/mocap/posture-summary/route.ts b/src/app/api/mocap/posture-summary/route.ts
new file mode 100644
index 0000000..c217bc1
--- /dev/null
+++ b/src/app/api/mocap/posture-summary/route.ts
@@ -0,0 +1,79 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+
+/**
+ * GET /api/mocap/posture-summary
+ *
+ * Returns aggregated posture faults and stroke metrics for all of the
+ * authenticated user's ready mocap sessions. Used by AI insight generation
+ * to build a tiered PostureAIPayload without touching raw keypoint data.
+ */
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+
+ // Fetch all ready mocap sessions for this user including their faults,
+ // per-stroke metrics, and quality signals.
+ const mocapSessions = await prisma.mocapSession.findMany({
+ where: { userId, status: "ready" },
+ select: {
+ qualityScore: true,
+ qualityFlags: true,
+ postureFaults: {
+ select: {
+ faultType: true,
+ severity: true,
+ },
+ },
+ strokePostureMetrics: {
+ select: {
+ strokeIndex: true,
+ segmentationSource: true,
+ metricsJson: true,
+ },
+ },
+ },
+ });
+
+ const faults: { faultType: string; severity: string }[] = [];
+ const metrics: {
+ strokeIndex: number;
+ segmentationSource: string;
+ metricsJson: unknown;
+ }[] = [];
+ const qualityFlags: string[] = [];
+ let qualityScore: number | null = null;
+ let qualityScoreCount = 0;
+
+ for (const s of mocapSessions) {
+ for (const f of s.postureFaults) {
+ faults.push({ faultType: f.faultType, severity: f.severity });
+ }
+ for (const m of s.strokePostureMetrics) {
+ metrics.push({
+ strokeIndex: m.strokeIndex,
+ segmentationSource: m.segmentationSource,
+ metricsJson: m.metricsJson,
+ });
+ }
+ for (const flag of s.qualityFlags) {
+ if (!qualityFlags.includes(flag)) qualityFlags.push(flag);
+ }
+ if (s.qualityScore !== null) {
+ qualityScore = (qualityScore ?? 0) + s.qualityScore;
+ qualityScoreCount++;
+ }
+ }
+
+ if (qualityScoreCount > 0 && qualityScore !== null) {
+ qualityScore = qualityScore / qualityScoreCount;
+ }
+
+ return NextResponse.json({ faults, metrics, qualityFlags, qualityScore });
+}
diff --git a/src/hooks/useAIInsights.ts b/src/hooks/useAIInsights.ts
index 478cd68..68148e7 100644
--- a/src/hooks/useAIInsights.ts
+++ b/src/hooks/useAIInsights.ts
@@ -6,6 +6,31 @@ import { cloudAI, CloudInsight } from '@/lib/cloudAI';
import { initializeCloudAIFromSettings, isAIAvailable, getAIConfigurationErrorMessage } from '@/lib/aiConfig';
import { memoryStorage } from '@/lib/memoryStorage';
import { saveInsightsToDB, fetchInsightsFromDB } from '@/lib/dataSync';
+import { SettingsService } from '@/lib/settings';
+import { buildPostureAIPayload, assertNoKeypointsInPayload, PostureAIPayload } from '@/lib/mocap/aiPayload';
+
+async function fetchPosturePayload(): Promise {
+ const settings = SettingsService.getInstance().getSettings();
+ const { cloudAIEnabled, mocapDetailedAIShare } = settings.aiSettings;
+ if (!cloudAIEnabled) return null;
+
+ try {
+ const res = await fetch('/api/mocap/posture-summary');
+ if (!res.ok) return null;
+ const { faults, metrics, qualityFlags, qualityScore } = await res.json();
+
+ if (!faults?.length && !metrics?.length) return null;
+
+ const payload = buildPostureAIPayload(faults, metrics, qualityFlags ?? [], qualityScore ?? null, {
+ cloudAIEnabled,
+ mocapDetailedAIShare,
+ });
+ if (payload) assertNoKeypointsInPayload(payload);
+ return payload;
+ } catch {
+ return null;
+ }
+}
export interface AIInsightData {
insights: (Insight | CloudInsight)[];
@@ -483,8 +508,11 @@ export function useAIInsights(forceRefresh: boolean = false): AIInsightData {
// Initialize cloud AI with latest settings
initializeCloudAI();
+ // Build tiered posture payload (null when cloud AI disabled or no data)
+ const posturePayload = await fetchPosturePayload();
+
// Generate insights using cloud AI
- const cloudInsights = await cloudAI.generateInsights(sessions);
+ const cloudInsights = await cloudAI.generateInsights(sessions, posturePayload);
// Generate local trends and other data (cloud AI only handles insights)
const trends = [
diff --git a/src/lib/cloudAI.ts b/src/lib/cloudAI.ts
index 288895a..7b543c8 100644
--- a/src/lib/cloudAI.ts
+++ b/src/lib/cloudAI.ts
@@ -102,6 +102,7 @@ export interface CloudInsight {
confidence: number; // 0-1 from AI
evidence: string[]; // Supporting data points
dateGenerated: Date;
+ category?: 'posture';
}
export class CloudAIService {
@@ -1275,17 +1276,26 @@ Average sessions per week: ${(totalSessions / Math.max(1, Math.ceil((dates[dates
// Ensure we have an array
const insightsArray = Array.isArray(insightsData) ? insightsData : [insightsData];
- return insightsArray.map((insight: Record, index: number) => ({
- id: `cloud-insight-${Date.now()}-${index}`,
- type: (insight.type as string) || 'recommendation',
- title: (insight.title as string) || 'Performance Insight',
- description: (insight.description as string) || 'No description provided',
- actionable: Boolean(insight.actionable),
- priority: (insight.priority as string) || 'medium',
- confidence: Math.max(0, Math.min(1, Number(insight.confidence) || 0.5)),
- evidence: Array.isArray(insight.evidence) ? insight.evidence as string[] : [],
- dateGenerated: new Date()
- })) as CloudInsight[];
+ const POSTURE_TERMS = /posture|back angle|rounded back|layback|arm bend|drive ratio|recovery ratio|catch position|finish position|spine|trunk lean/i;
+
+ return insightsArray.map((insight: Record, index: number) => {
+ const title = (insight.title as string) || 'Performance Insight';
+ const description = (insight.description as string) || 'No description provided';
+ const isPosture = POSTURE_TERMS.test(title) || POSTURE_TERMS.test(description);
+
+ return {
+ id: `cloud-insight-${Date.now()}-${index}`,
+ type: (insight.type as string) || 'recommendation',
+ title,
+ description,
+ actionable: Boolean(insight.actionable),
+ priority: (insight.priority as string) || 'medium',
+ confidence: Math.max(0, Math.min(1, Number(insight.confidence) || 0.5)),
+ evidence: Array.isArray(insight.evidence) ? insight.evidence as string[] : [],
+ dateGenerated: new Date(),
+ ...(isPosture ? { category: 'posture' as const } : {}),
+ };
+ }) as CloudInsight[];
} catch (error) {
console.error('Failed to parse AI response:', error);
console.error('Response content:', response);
diff --git a/tests/insightPostureWiring.test.ts b/tests/insightPostureWiring.test.ts
new file mode 100644
index 0000000..ff21bb8
--- /dev/null
+++ b/tests/insightPostureWiring.test.ts
@@ -0,0 +1,103 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildPostureAIPayload,
+ assertNoKeypointsInPayload,
+} from "../src/lib/mocap/aiPayload.js";
+
+const FAULTS = [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "excessive_layback", severity: "critical" },
+];
+
+const METRICS = [
+ {
+ strokeIndex: 0,
+ segmentationSource: "pose-segmented",
+ metricsJson: {
+ backAngleAtCatchDeg: 28,
+ laybackAngleDeg: 40,
+ recoveryDriveRatio: 2.1,
+ },
+ },
+];
+
+describe("insight posture payload gating", () => {
+ test("cloud AI disabled → null payload (Tier 1 wall)", () => {
+ const result = buildPostureAIPayload(FAULTS, METRICS, [], null, {
+ cloudAIEnabled: false,
+ mocapDetailedAIShare: true,
+ });
+ assert.equal(result, null);
+ });
+
+ test("cloud enabled, detailed share off → Tier 3 fault summary only", () => {
+ const result = buildPostureAIPayload(FAULTS, METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.tier, 3);
+ assert.equal(result.strokeMetrics, undefined);
+ assert.equal(result.faultSummary.totalFaults, 2);
+ assertNoKeypointsInPayload(result);
+ });
+
+ test("cloud enabled, detailed share on → Tier 2 adds scalar metrics", () => {
+ const result = buildPostureAIPayload(FAULTS, METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.tier, 2);
+ assert.ok(Array.isArray(result.strokeMetrics));
+ assert.equal(result.strokeMetrics!.length, 1);
+ const m = result.strokeMetrics![0];
+ assert.equal(m.backAngleAtCatchDeg, 28);
+ assert.equal(m.laybackAngleDeg, 40);
+ assert.equal(m.recoveryDriveRatio, 2.1);
+ assertNoKeypointsInPayload(result);
+ });
+
+ test("no faults or metrics → still valid payload structure", () => {
+ const result = buildPostureAIPayload([], [], [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.faultSummary.totalFaults, 0);
+ assertNoKeypointsInPayload(result);
+ });
+});
+
+describe("raw keypoint leakage guard", () => {
+ test("throws if keypoints key present", () => {
+ assert.throws(
+ () => assertNoKeypointsInPayload({ keypoints: [[0.1, 0.2, 0.9]] }),
+ /HARD GUARD VIOLATION/,
+ );
+ });
+
+ test("throws if landmarks key present", () => {
+ assert.throws(
+ () => assertNoKeypointsInPayload({ frame: { landmarks: [] } }),
+ /HARD GUARD VIOLATION/,
+ );
+ });
+
+ test("Tier 2 payload passes guard (no keypoints)", () => {
+ const payload = buildPostureAIPayload(FAULTS, METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: true,
+ });
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(payload));
+ });
+
+ test("Tier 3 payload passes guard", () => {
+ const payload = buildPostureAIPayload(FAULTS, METRICS, [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(payload));
+ });
+});
From 993b7e4682fb87bea73d7bd22384842bb475b6a2 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 18:57:54 +0200
Subject: [PATCH 22/29] feat(dashboard): add posture fault frequency trend card
(closes #26)
Surface per-fault-type frequency trends from linked mocap sessions on
the dashboard. Low-quality sessions are marked rather than excluded,
with tooltip and legend callouts. Empty states explain the mocap
session requirement.
- Pure aggregation fn in postureTrendAggregation.ts (18 new tests)
- GET /api/mocap/posture-trend route
- PostureFaultTrendCard component with recharts line chart
- Placed on dashboard after PeriodComparisonStats
Co-Authored-By: Claude Sonnet 4.6
---
src/app/api/mocap/posture-trend/route.ts | 52 +++++
src/app/dashboard/page.tsx | 4 +
src/components/PostureFaultTrendCard.tsx | 204 ++++++++++++++++++++
src/lib/mocap/postureTrendAggregation.ts | 94 +++++++++
tests/postureTrendAggregation.test.ts | 231 +++++++++++++++++++++++
5 files changed, 585 insertions(+)
create mode 100644 src/app/api/mocap/posture-trend/route.ts
create mode 100644 src/components/PostureFaultTrendCard.tsx
create mode 100644 src/lib/mocap/postureTrendAggregation.ts
create mode 100644 tests/postureTrendAggregation.test.ts
diff --git a/src/app/api/mocap/posture-trend/route.ts b/src/app/api/mocap/posture-trend/route.ts
new file mode 100644
index 0000000..e9b3872
--- /dev/null
+++ b/src/app/api/mocap/posture-trend/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import {
+ aggregatePostureTrend,
+ type SessionFaultInput,
+} from "@/lib/mocap/postureTrendAggregation";
+
+/**
+ * GET /api/mocap/posture-trend
+ *
+ * Returns per-fault-type frequency trend across all ready mocap sessions
+ * linked to the authenticated user. Sessions with low quality scores or
+ * quality flags are included but marked so the UI can surface them.
+ */
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+
+ const mocapSessions = await prisma.mocapSession.findMany({
+ where: { userId, status: "ready" },
+ select: {
+ id: true,
+ createdAt: true,
+ qualityScore: true,
+ qualityFlags: true,
+ postureFaults: {
+ select: { faultType: true, severity: true },
+ },
+ _count: { select: { strokePostureMetrics: true } },
+ },
+ orderBy: { createdAt: "asc" },
+ });
+
+ const inputs: SessionFaultInput[] = mocapSessions.map((s) => ({
+ sessionId: s.id,
+ sessionDate: s.createdAt,
+ qualityScore: s.qualityScore,
+ qualityFlags: s.qualityFlags,
+ faults: s.postureFaults,
+ strokeCount: s._count.strokePostureMetrics,
+ }));
+
+ const result = aggregatePostureTrend(inputs);
+
+ return NextResponse.json(result);
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 55c38c9..1c76bee 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -21,6 +21,7 @@ import { MigrationPrompt } from '@/components/MigrationPrompt';
import { MetricComparisonWidget } from '@/components/MetricComparisonWidget';
import { PeriodComparisonStats } from '@/components/PeriodComparisonStats';
+import { PostureFaultTrendCard } from '@/components/PostureFaultTrendCard';
import { TimeRangeSelector, defaultTimeRangeOptions, type TimeRange } from '@/components/ui/time-range-selector';
// Chart type options
@@ -633,6 +634,9 @@ const Dashboard = () => {
{/* Monthly Comparison Header Cards */}
+ {/* Posture Fault Frequency Trend */}
+
+
{/* AI Insights Section */}
{isAnalyzable && (
diff --git a/src/components/PostureFaultTrendCard.tsx b/src/components/PostureFaultTrendCard.tsx
new file mode 100644
index 0000000..5c32a05
--- /dev/null
+++ b/src/components/PostureFaultTrendCard.tsx
@@ -0,0 +1,204 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from 'recharts';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Activity, AlertTriangle } from 'lucide-react';
+import type { PostureFaultType } from '@/lib/mocap/analysis/types';
+import type { PostureTrendResult, FaultTrendPoint } from '@/lib/mocap/postureTrendAggregation';
+
+const FAULT_LABELS: Record
= {
+ rounded_back_at_catch: 'Rounded Back',
+ early_arm_bend: 'Early Arm Bend',
+ back_opens_before_legs_drive: 'Back Opens Early',
+ excessive_layback: 'Excessive Layback',
+ slow_recovery_ratio: 'Slow Recovery',
+};
+
+const FAULT_COLORS: Record = {
+ rounded_back_at_catch: '#ef4444',
+ early_arm_bend: '#f97316',
+ back_opens_before_legs_drive: '#eab308',
+ excessive_layback: '#8b5cf6',
+ slow_recovery_ratio: '#06b6d4',
+};
+
+interface ChartPoint {
+ date: string;
+ lowQuality?: boolean;
+ [faultType: string]: number | string | boolean | undefined;
+}
+
+function buildChartData(data: PostureTrendResult): ChartPoint[] {
+ const dateMap = new Map();
+
+ for (const trend of data.trends) {
+ for (const point of trend.points) {
+ if (!dateMap.has(point.date)) {
+ dateMap.set(point.date, { date: point.date });
+ }
+ const row = dateMap.get(point.date)!;
+ row[trend.faultType] = point.count;
+ if (point.lowQuality) row.lowQuality = true;
+ }
+ }
+
+ return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date));
+}
+
+function lowQualitySessionDates(data: PostureTrendResult): Set {
+ const dates = new Set();
+ for (const trend of data.trends) {
+ for (const point of trend.points) {
+ if (point.lowQuality) dates.add(point.date);
+ }
+ }
+ return dates;
+}
+
+function CustomDot(props: {
+ cx?: number;
+ cy?: number;
+ payload?: ChartPoint;
+}) {
+ const { cx, cy, payload } = props;
+ if (!cx || !cy || !payload?.lowQuality) return null;
+ return (
+
+ );
+}
+
+export function PostureFaultTrendCard() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/mocap/posture-trend')
+ .then((r) => {
+ if (!r.ok) throw new Error('Failed to load posture trend data');
+ return r.json() as Promise;
+ })
+ .then(setData)
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Unknown error'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const lowQualityDates = data ? lowQualitySessionDates(data) : new Set();
+ const chartData = data ? buildChartData(data) : [];
+ const activeFaultTypes = data?.trends.map((t) => t.faultType) ?? [];
+
+ return (
+
+
+
+
Posture Fault Frequency
+ {lowQualityDates.size > 0 && (
+
+
+ {lowQualityDates.size} low-quality session{lowQualityDates.size > 1 ? 's' : ''}
+
+ )}
+
+
+
+ {loading && (
+
+ Loading...
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && data?.totalSessions === 0 && (
+
+
+
No linked mocap sessions found.
+
Link a motion capture session to a training session to track posture trends.
+
+ )}
+
+ {!loading && !error && data && data.totalSessions > 0 && data.trends.length === 0 && (
+
+
+
No posture faults recorded yet.
+
Faults will appear here as mocap sessions are analyzed.
+
+ )}
+
+ {!loading && !error && data && data.trends.length > 0 && (
+ <>
+
+
+
+ v.slice(5)}
+ />
+
+ [
+ value,
+ FAULT_LABELS[name as PostureFaultType] ?? name,
+ ]}
+ labelFormatter={(label: string) => {
+ const isLQ = lowQualityDates.has(label);
+ return `${label}${isLQ ? ' ⚠ low quality' : ''}`;
+ }}
+ />
+
+ FAULT_LABELS[value as PostureFaultType] ?? value
+ }
+ wrapperStyle={{ fontSize: 11 }}
+ />
+ {activeFaultTypes.map((ft) => (
+ }
+ activeDot={{ r: 4 }}
+ connectNulls
+ />
+ ))}
+
+
+
+ Fault counts per session · {data.linkedSessionsWithFaults} of {data.totalSessions} session{data.totalSessions !== 1 ? 's' : ''} have recorded faults
+ {lowQualityDates.size > 0 && ' · Dashed circles = low capture quality'}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/lib/mocap/postureTrendAggregation.ts b/src/lib/mocap/postureTrendAggregation.ts
new file mode 100644
index 0000000..ec94381
--- /dev/null
+++ b/src/lib/mocap/postureTrendAggregation.ts
@@ -0,0 +1,94 @@
+import type { PostureFaultType, FaultSeverity } from "./analysis/types";
+
+export const LOW_QUALITY_SCORE_THRESHOLD = 0.5;
+
+export interface SessionFaultInput {
+ sessionId: string;
+ sessionDate: Date;
+ qualityScore: number | null;
+ qualityFlags: string[];
+ faults: { faultType: string; severity: string }[];
+ strokeCount: number;
+}
+
+export interface FaultTrendPoint {
+ date: string;
+ sessionId: string;
+ count: number;
+ rate: number;
+ severityCounts: { info: number; warning: number; critical: number };
+ lowQuality: boolean;
+ qualityFlags: string[];
+}
+
+export interface PostureFaultTrend {
+ faultType: PostureFaultType;
+ points: FaultTrendPoint[];
+}
+
+export interface PostureTrendResult {
+ trends: PostureFaultTrend[];
+ totalSessions: number;
+ linkedSessionsWithFaults: number;
+}
+
+export function isLowQuality(session: Pick): boolean {
+ if (session.qualityScore !== null && session.qualityScore < LOW_QUALITY_SCORE_THRESHOLD) {
+ return true;
+ }
+ return session.qualityFlags.length > 0;
+}
+
+export function aggregatePostureTrend(sessions: SessionFaultInput[]): PostureTrendResult {
+ const sorted = [...sessions].sort((a, b) => a.sessionDate.getTime() - b.sessionDate.getTime());
+
+ const faultTypeMap = new Map();
+
+ let linkedSessionsWithFaults = 0;
+
+ for (const session of sorted) {
+ if (session.faults.length > 0) linkedSessionsWithFaults++;
+
+ const byType = new Map();
+
+ for (const fault of session.faults) {
+ const ft = fault.faultType as PostureFaultType;
+ if (!byType.has(ft)) byType.set(ft, { info: 0, warning: 0, critical: 0 });
+ const counts = byType.get(ft)!;
+ const sev = fault.severity as FaultSeverity;
+ if (sev === "info") counts.info++;
+ else if (sev === "warning") counts.warning++;
+ else if (sev === "critical") counts.critical++;
+ }
+
+ const lowQuality = isLowQuality(session);
+ const dateStr = session.sessionDate.toISOString().split("T")[0];
+
+ for (const [ft, severityCounts] of byType) {
+ if (!faultTypeMap.has(ft)) faultTypeMap.set(ft, []);
+ const count = severityCounts.info + severityCounts.warning + severityCounts.critical;
+ faultTypeMap.get(ft)!.push({
+ date: dateStr,
+ sessionId: session.sessionId,
+ count,
+ rate: session.strokeCount > 0 ? count / session.strokeCount : 0,
+ severityCounts,
+ lowQuality,
+ qualityFlags: session.qualityFlags,
+ });
+ }
+ }
+
+ const trends: PostureFaultTrend[] = [];
+ for (const [faultType, points] of faultTypeMap) {
+ trends.push({ faultType, points });
+ }
+
+ trends.sort((a, b) => a.faultType.localeCompare(b.faultType));
+
+ return {
+ trends,
+ totalSessions: sessions.length,
+ linkedSessionsWithFaults,
+ };
+}
diff --git a/tests/postureTrendAggregation.test.ts b/tests/postureTrendAggregation.test.ts
new file mode 100644
index 0000000..66d1d91
--- /dev/null
+++ b/tests/postureTrendAggregation.test.ts
@@ -0,0 +1,231 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ aggregatePostureTrend,
+ isLowQuality,
+ LOW_QUALITY_SCORE_THRESHOLD,
+ type SessionFaultInput,
+} from "../src/lib/mocap/postureTrendAggregation.js";
+
+function makeSession(overrides: Partial = {}): SessionFaultInput {
+ return {
+ sessionId: "sess-1",
+ sessionDate: new Date("2025-01-01"),
+ qualityScore: 0.9,
+ qualityFlags: [],
+ faults: [],
+ strokeCount: 10,
+ ...overrides,
+ };
+}
+
+describe("isLowQuality", () => {
+ test("high quality score, no flags → not low quality", () => {
+ assert.equal(isLowQuality({ qualityScore: 0.9, qualityFlags: [] }), false);
+ });
+
+ test("score below threshold → low quality", () => {
+ assert.equal(
+ isLowQuality({ qualityScore: LOW_QUALITY_SCORE_THRESHOLD - 0.01, qualityFlags: [] }),
+ true,
+ );
+ });
+
+ test("score exactly at threshold → not low quality", () => {
+ assert.equal(
+ isLowQuality({ qualityScore: LOW_QUALITY_SCORE_THRESHOLD, qualityFlags: [] }),
+ false,
+ );
+ });
+
+ test("null score, no flags → not low quality", () => {
+ assert.equal(isLowQuality({ qualityScore: null, qualityFlags: [] }), false);
+ });
+
+ test("null score, has flags → low quality", () => {
+ assert.equal(isLowQuality({ qualityScore: null, qualityFlags: ["occlusion"] }), true);
+ });
+
+ test("good score + flags → low quality (flags override)", () => {
+ assert.equal(isLowQuality({ qualityScore: 0.95, qualityFlags: ["blur"] }), true);
+ });
+});
+
+describe("aggregatePostureTrend — empty input", () => {
+ test("no sessions → empty trends", () => {
+ const result = aggregatePostureTrend([]);
+ assert.equal(result.totalSessions, 0);
+ assert.equal(result.linkedSessionsWithFaults, 0);
+ assert.deepEqual(result.trends, []);
+ });
+});
+
+describe("aggregatePostureTrend — single session", () => {
+ test("session with no faults → no trends", () => {
+ const result = aggregatePostureTrend([makeSession({ faults: [] })]);
+ assert.equal(result.totalSessions, 1);
+ assert.equal(result.linkedSessionsWithFaults, 0);
+ assert.equal(result.trends.length, 0);
+ });
+
+ test("session with one fault type → one trend with one point", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ faults: [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "rounded_back_at_catch", severity: "critical" },
+ ],
+ strokeCount: 20,
+ }),
+ ]);
+ assert.equal(result.trends.length, 1);
+ assert.equal(result.trends[0].faultType, "rounded_back_at_catch");
+ assert.equal(result.trends[0].points.length, 1);
+ const pt = result.trends[0].points[0];
+ assert.equal(pt.count, 2);
+ assert.equal(pt.severityCounts.warning, 1);
+ assert.equal(pt.severityCounts.critical, 1);
+ assert.equal(pt.severityCounts.info, 0);
+ assert.ok(Math.abs(pt.rate - 2 / 20) < 0.001);
+ });
+});
+
+describe("aggregatePostureTrend — multiple sessions", () => {
+ const sessions: SessionFaultInput[] = [
+ makeSession({
+ sessionId: "a",
+ sessionDate: new Date("2025-03-01"),
+ faults: [
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "early_arm_bend", severity: "info" },
+ ],
+ strokeCount: 10,
+ }),
+ makeSession({
+ sessionId: "b",
+ sessionDate: new Date("2025-03-15"),
+ faults: [
+ { faultType: "excessive_layback", severity: "critical" },
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "slow_recovery_ratio", severity: "warning" },
+ ],
+ strokeCount: 15,
+ }),
+ makeSession({
+ sessionId: "c",
+ sessionDate: new Date("2025-04-01"),
+ faults: [],
+ strokeCount: 12,
+ }),
+ ];
+
+ test("counts sessions and fault sessions correctly", () => {
+ const result = aggregatePostureTrend(sessions);
+ assert.equal(result.totalSessions, 3);
+ assert.equal(result.linkedSessionsWithFaults, 2);
+ });
+
+ test("produces correct number of fault type trends", () => {
+ const result = aggregatePostureTrend(sessions);
+ const types = result.trends.map((t) => t.faultType).sort();
+ assert.deepEqual(types, ["early_arm_bend", "excessive_layback", "slow_recovery_ratio"]);
+ });
+
+ test("excessive_layback has two points in chronological order", () => {
+ const result = aggregatePostureTrend(sessions);
+ const layback = result.trends.find((t) => t.faultType === "excessive_layback");
+ assert.ok(layback);
+ assert.equal(layback.points.length, 2);
+ assert.equal(layback.points[0].date, "2025-03-01");
+ assert.equal(layback.points[0].count, 1);
+ assert.equal(layback.points[1].date, "2025-03-15");
+ assert.equal(layback.points[1].count, 2);
+ assert.equal(layback.points[1].severityCounts.critical, 1);
+ assert.equal(layback.points[1].severityCounts.warning, 1);
+ });
+
+ test("early_arm_bend only appears in first session", () => {
+ const result = aggregatePostureTrend(sessions);
+ const arm = result.trends.find((t) => t.faultType === "early_arm_bend");
+ assert.ok(arm);
+ assert.equal(arm.points.length, 1);
+ assert.equal(arm.points[0].severityCounts.info, 1);
+ });
+
+ test("session without faults contributes no point for any fault type", () => {
+ const result = aggregatePostureTrend(sessions);
+ for (const trend of result.trends) {
+ assert.ok(
+ !trend.points.some((p) => p.sessionId === "c"),
+ `session c should not appear in trend for ${trend.faultType}`,
+ );
+ }
+ });
+
+ test("trends are sorted alphabetically by fault type", () => {
+ const result = aggregatePostureTrend(sessions);
+ const types = result.trends.map((t) => t.faultType);
+ assert.deepEqual(types, [...types].sort());
+ });
+});
+
+describe("aggregatePostureTrend — quality marking", () => {
+ test("low quality score session marked on its fault points", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ qualityScore: 0.2,
+ faults: [{ faultType: "rounded_back_at_catch", severity: "warning" }],
+ }),
+ ]);
+ assert.equal(result.trends[0].points[0].lowQuality, true);
+ });
+
+ test("quality flags session marked on its fault points", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ qualityFlags: ["partial_occlusion"],
+ faults: [{ faultType: "excessive_layback", severity: "info" }],
+ }),
+ ]);
+ const pt = result.trends[0].points[0];
+ assert.equal(pt.lowQuality, true);
+ assert.deepEqual(pt.qualityFlags, ["partial_occlusion"]);
+ });
+
+ test("good quality session is not marked low quality", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ qualityScore: 0.95,
+ qualityFlags: [],
+ faults: [{ faultType: "slow_recovery_ratio", severity: "warning" }],
+ }),
+ ]);
+ assert.equal(result.trends[0].points[0].lowQuality, false);
+ });
+});
+
+describe("aggregatePostureTrend — rate calculation", () => {
+ test("rate = count / strokeCount", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ faults: [
+ { faultType: "slow_recovery_ratio", severity: "warning" },
+ { faultType: "slow_recovery_ratio", severity: "critical" },
+ ],
+ strokeCount: 8,
+ }),
+ ]);
+ const pt = result.trends[0].points[0];
+ assert.ok(Math.abs(pt.rate - 2 / 8) < 0.001);
+ });
+
+ test("zero strokeCount → rate is 0", () => {
+ const result = aggregatePostureTrend([
+ makeSession({
+ faults: [{ faultType: "early_arm_bend", severity: "info" }],
+ strokeCount: 0,
+ }),
+ ]);
+ assert.equal(result.trends[0].points[0].rate, 0);
+ });
+});
From fe9586178566962ea8d7b31f0175156f810f97dd Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 19:02:51 +0200
Subject: [PATCH 23/29] feat(chat): expose MocapSession posture data in AI chat
context (closes #27)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add server-side /api/chat/posture-context route that aggregates fault
summaries and per-stroke metrics from linked ready MocapSessions and
applies the 3-tier cloud data policy (Tier 1 hard-wall when cloudAI
disabled, Tier 3 fault summary by default, Tier 2 scalar metrics when
mocapDetailedAIShare is enabled). The hard keypoint guard runs
server-side before any payload leaves the DB.
useChat.sendMessage fetches posture context before each AI call and
passes the cloud-safe payload to cloudAI.sendChatMessage, which injects
it into the chat system prompt. Raw keypoints, landmarks, pose-stream
blobs, and video bytes are never included. Missing or unlinked mocap
data is handled gracefully — the fetch is non-blocking and a null
payload simply results in no posture context being added.
Co-Authored-By: Claude Sonnet 4.6
---
src/app/api/chat/posture-context/route.ts | 113 ++++++++++++++++
src/hooks/useChat.ts | 15 ++-
src/lib/cloudAI.ts | 21 ++-
tests/chatPostureContext.test.ts | 154 ++++++++++++++++++++++
4 files changed, 298 insertions(+), 5 deletions(-)
create mode 100644 src/app/api/chat/posture-context/route.ts
create mode 100644 tests/chatPostureContext.test.ts
diff --git a/src/app/api/chat/posture-context/route.ts b/src/app/api/chat/posture-context/route.ts
new file mode 100644
index 0000000..c65e2a6
--- /dev/null
+++ b/src/app/api/chat/posture-context/route.ts
@@ -0,0 +1,113 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { buildAndValidatePosturePayload } from "@/lib/aiAnalysis";
+
+/**
+ * GET /api/chat/posture-context
+ *
+ * Returns a cloud-safe PostureAIPayload built from the authenticated user's
+ * most recent linked (rowing-session-associated) ready MocapSessions.
+ *
+ * Tier policy (mirrors insight generation):
+ * cloudAIEnabled = false → { payload: null } (Tier 1 hard-wall)
+ * cloudAIEnabled = true → Tier 3 fault summary
+ * + mocapDetailedAIShare → Tier 2 adds per-stroke scalar metrics
+ *
+ * Raw keypoints, landmarks, pose-stream blobs, and video bytes are never
+ * included; the hard guard in buildAndValidatePosturePayload enforces this.
+ */
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+
+ const userSettings = await prisma.userSettings.findUnique({
+ where: { userId },
+ select: { cloudAIEnabled: true, mocapDetailedAIShare: true },
+ });
+
+ if (!userSettings?.cloudAIEnabled) {
+ return NextResponse.json({ payload: null, sessionCount: 0 });
+ }
+
+ const mocapSessions = await prisma.mocapSession.findMany({
+ where: {
+ userId,
+ status: "ready",
+ rowingSessionId: { not: null },
+ },
+ select: {
+ qualityScore: true,
+ qualityFlags: true,
+ postureFaults: {
+ select: { faultType: true, severity: true },
+ },
+ strokePostureMetrics: {
+ select: {
+ strokeIndex: true,
+ segmentationSource: true,
+ metricsJson: true,
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ take: 5,
+ });
+
+ if (mocapSessions.length === 0) {
+ return NextResponse.json({ payload: null, sessionCount: 0 });
+ }
+
+ const faults: { faultType: string; severity: string }[] = [];
+ const metrics: {
+ strokeIndex: number;
+ segmentationSource: string;
+ metricsJson: unknown;
+ }[] = [];
+ const qualityFlagSet = new Set();
+ let qualityScoreSum = 0;
+ let qualityScoreCount = 0;
+
+ for (const s of mocapSessions) {
+ for (const f of s.postureFaults) {
+ faults.push({ faultType: f.faultType, severity: f.severity });
+ }
+ for (const m of s.strokePostureMetrics) {
+ metrics.push({
+ strokeIndex: m.strokeIndex,
+ segmentationSource: m.segmentationSource,
+ metricsJson: m.metricsJson,
+ });
+ }
+ for (const flag of s.qualityFlags) {
+ qualityFlagSet.add(flag);
+ }
+ if (s.qualityScore !== null) {
+ qualityScoreSum += s.qualityScore;
+ qualityScoreCount++;
+ }
+ }
+
+ const qualityScore =
+ qualityScoreCount > 0 ? qualityScoreSum / qualityScoreCount : null;
+
+ const payload = buildAndValidatePosturePayload(
+ {
+ faults,
+ metrics,
+ qualityFlags: Array.from(qualityFlagSet),
+ qualityScore,
+ },
+ {
+ cloudAIEnabled: userSettings.cloudAIEnabled,
+ mocapDetailedAIShare: userSettings.mocapDetailedAIShare ?? false,
+ },
+ );
+
+ return NextResponse.json({ payload, sessionCount: mocapSessions.length });
+}
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts
index e6967bc..2a3e980 100644
--- a/src/hooks/useChat.ts
+++ b/src/hooks/useChat.ts
@@ -271,6 +271,18 @@ export function useChat() {
.pop();
const previousResponseId = lastAIMessage?.responseId;
+ // Fetch posture context from server (non-blocking; omit on error)
+ let posturePayload = null;
+ try {
+ const ctxRes = await fetch('/api/chat/posture-context');
+ if (ctxRes.ok) {
+ const ctxData = await ctxRes.json();
+ posturePayload = ctxData.payload ?? null;
+ }
+ } catch {
+ // proceed without posture context
+ }
+
const aiResponse = await cloudAI.sendChatMessage(
content.trim(),
conversationHistory,
@@ -296,7 +308,8 @@ export function useChat() {
};
});
},
- attachments
+ attachments,
+ posturePayload,
);
// Add AI response (only if not empty)
diff --git a/src/lib/cloudAI.ts b/src/lib/cloudAI.ts
index 7b543c8..e0b1976 100644
--- a/src/lib/cloudAI.ts
+++ b/src/lib/cloudAI.ts
@@ -162,7 +162,8 @@ export class CloudAIService {
userSessions?: Session[],
previousResponseId?: string,
onToken?: (token: string) => void,
- attachments?: FileAttachment[]
+ attachments?: FileAttachment[],
+ posturePayload?: PostureAIPayload | null,
): Promise<{ content: string; responseId: string }> {
if (!this.config) {
throw new Error('Cloud AI service not configured');
@@ -274,7 +275,7 @@ export class CloudAIService {
// Prepare initial input messages
const messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image_url?: string; file?: { file_data: string; filename: string } }> }> = [
- { role: 'system', content: this.getChatSystemPrompt() },
+ { role: 'system', content: this.getChatSystemPrompt(posturePayload) },
...conversationHistory.slice(-10).map(msg => ({
role: msg.role,
content: msg.content
@@ -813,7 +814,7 @@ export class CloudAIService {
// Get system prompt for chat AI trainer
- private getChatSystemPrompt(_sessions?: Session[]): string {
+ private getChatSystemPrompt(posturePayload?: PostureAIPayload | null, _sessions?: Session[]): string {
return `You are a personal AI rowing coach and trainer. You specialize in indoor rowing performance, technique, and training optimization.
CRITICAL FORMATTING RULES - READ CAREFULLY:
@@ -930,7 +931,7 @@ COMMUNICATION STYLE:
- Keep responses focused and practical
- Structure responses with clear headers for easy scanning
-Remember: You're building a long-term coaching relationship. Be supportive, knowledgeable, and genuinely helpful in their rowing journey.${this.getUserProfileContext()}`;
+Remember: You're building a long-term coaching relationship. Be supportive, knowledgeable, and genuinely helpful in their rowing journey.${this.getUserProfileContext()}${this.buildPostureContextBlock(posturePayload)}`;
}
// Get user's session context for personalized coaching
@@ -1063,6 +1064,18 @@ Remember: You're building a long-term coaching relationship. Be supportive, know
return '';
}
+ // Append posture context block to system prompt when a cloud-safe payload exists.
+ // Raw keypoints are never present here — the hard guard ran server-side before
+ // the payload reached the client.
+ private buildPostureContextBlock(payload?: PostureAIPayload | null): string {
+ if (!payload) return '';
+ const tierLabel =
+ payload.tier === 3
+ ? 'Tier 3 – Fault Summary (no body geometry)'
+ : 'Tier 2 – Fault Summary + Per-Stroke Metrics (no keypoints)';
+ return `\n\n---\nPOSTURE ANALYSIS CONTEXT [${tierLabel}]:\n${JSON.stringify(payload, null, 2)}\n\nUse this posture data to answer questions about the user's technique and form. Do not speculate about raw pose coordinates — only the summarised fault counts and scalar metrics above are available.\n---`;
+ }
+
// Get system prompt for rowing performance analysis
private getSystemPrompt(): string {
const userContext = this.getUserProfileContext();
diff --git a/tests/chatPostureContext.test.ts b/tests/chatPostureContext.test.ts
new file mode 100644
index 0000000..4f3d8ad
--- /dev/null
+++ b/tests/chatPostureContext.test.ts
@@ -0,0 +1,154 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildPostureAIPayload,
+ assertNoKeypointsInPayload,
+} from "../src/lib/mocap/aiPayload.js";
+
+// Fixtures mirroring what the server aggregates from linked MocapSessions
+const LINKED_FAULTS = [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "excessive_layback", severity: "critical" },
+ { faultType: "arm_bend_at_finish", severity: "info" },
+];
+
+const LINKED_METRICS = [
+ {
+ strokeIndex: 0,
+ segmentationSource: "pose-segmented",
+ metricsJson: {
+ backAngleAtCatchDeg: 30,
+ laybackAngleDeg: 42,
+ recoveryDriveRatio: 2.0,
+ },
+ },
+ {
+ strokeIndex: 1,
+ segmentationSource: "pose-segmented",
+ metricsJson: {
+ backAngleAtCatchDeg: 28,
+ laybackAngleDeg: 39,
+ recoveryDriveRatio: 2.2,
+ },
+ },
+];
+
+describe("chat posture context – payload construction", () => {
+ test("cloud AI disabled → null (Tier 1 hard-wall)", () => {
+ const result = buildPostureAIPayload(
+ LINKED_FAULTS,
+ LINKED_METRICS,
+ [],
+ null,
+ { cloudAIEnabled: false, mocapDetailedAIShare: true },
+ );
+ assert.equal(result, null);
+ });
+
+ test("cloud enabled, detailed share off → Tier 3 fault summary only", () => {
+ const result = buildPostureAIPayload(
+ LINKED_FAULTS,
+ LINKED_METRICS,
+ ["low_quality"],
+ 0.72,
+ { cloudAIEnabled: true, mocapDetailedAIShare: false },
+ );
+ assert.ok(result !== null);
+ assert.equal(result.tier, 3);
+ assert.equal(result.strokeMetrics, undefined);
+ assert.equal(result.faultSummary.totalFaults, 3);
+ assert.equal(result.faultSummary.severityCounts.warning, 1);
+ assert.equal(result.faultSummary.severityCounts.critical, 1);
+ assert.equal(result.faultSummary.severityCounts.info, 1);
+ assert.deepEqual(result.faultSummary.qualityFlags, ["low_quality"]);
+ assert.equal(result.faultSummary.sessionQualityScore, 0.72);
+ });
+
+ test("cloud enabled, detailed share on → Tier 2 includes per-stroke metrics", () => {
+ const result = buildPostureAIPayload(
+ LINKED_FAULTS,
+ LINKED_METRICS,
+ [],
+ null,
+ { cloudAIEnabled: true, mocapDetailedAIShare: true },
+ );
+ assert.ok(result !== null);
+ assert.equal(result.tier, 2);
+ assert.ok(Array.isArray(result.strokeMetrics));
+ assert.equal(result.strokeMetrics!.length, 2);
+ assert.equal(result.strokeMetrics![0].backAngleAtCatchDeg, 30);
+ assert.equal(result.strokeMetrics![1].laybackAngleDeg, 39);
+ });
+
+ test("no linked sessions → empty payload is still valid structure", () => {
+ const result = buildPostureAIPayload([], [], [], null, {
+ cloudAIEnabled: true,
+ mocapDetailedAIShare: false,
+ });
+ assert.ok(result !== null);
+ assert.equal(result.faultSummary.totalFaults, 0);
+ assert.deepEqual(result.faultSummary.qualityFlags, []);
+ assert.equal(result.faultSummary.sessionQualityScore, null);
+ });
+});
+
+describe("chat posture context – raw keypoint hard guard", () => {
+ test("Tier 3 chat payload passes guard", () => {
+ const payload = buildPostureAIPayload(
+ LINKED_FAULTS,
+ LINKED_METRICS,
+ [],
+ null,
+ { cloudAIEnabled: true, mocapDetailedAIShare: false },
+ );
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(payload));
+ });
+
+ test("Tier 2 chat payload passes guard", () => {
+ const payload = buildPostureAIPayload(
+ LINKED_FAULTS,
+ LINKED_METRICS,
+ [],
+ null,
+ { cloudAIEnabled: true, mocapDetailedAIShare: true },
+ );
+ assert.doesNotThrow(() => assertNoKeypointsInPayload(payload));
+ });
+
+ test("guard throws if keypoints sneaked into chat-bound payload", () => {
+ const poisoned = {
+ tier: 3 as const,
+ faultSummary: {
+ totalFaults: 0,
+ faultCounts: {},
+ severityCounts: { info: 0, warning: 0, critical: 0 },
+ qualityFlags: [],
+ sessionQualityScore: null,
+ // SHOULD NEVER HAPPEN — guard must catch this
+ keypoints: [[0.1, 0.2, 0.9]],
+ },
+ };
+ assert.throws(
+ () => assertNoKeypointsInPayload(poisoned),
+ /HARD GUARD VIOLATION/,
+ );
+ });
+
+ test("guard throws if landmarks sneaked into chat-bound payload", () => {
+ const poisoned = {
+ tier: 3 as const,
+ faultSummary: {
+ totalFaults: 0,
+ faultCounts: {},
+ severityCounts: { info: 0, warning: 0, critical: 0 },
+ qualityFlags: [],
+ sessionQualityScore: null,
+ landmarks: [],
+ },
+ };
+ assert.throws(
+ () => assertNoKeypointsInPayload(poisoned),
+ /HARD GUARD VIOLATION/,
+ );
+ });
+});
From 2bfab220f68c7718173e73bc96a98c1dd8856a8a Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 19:11:54 +0200
Subject: [PATCH 24/29] feat(plans): add posture goals to training plans
(closes #28)
- Add PlanPostureGoal schema model (one optional goal per plan)
- New API: GET/PUT/DELETE /api/training-plans/[id]/posture-goal
- computePostureGoalProgress() derives current fault rate from linked mocap sessions
- Plans page: optional posture goal picker in creation form + progress card in active plan view with empty state when no mocap sessions linked
- 15 new tests covering progress calculation edge cases
Co-Authored-By: Claude Sonnet 4.6
---
prisma/schema.prisma | 18 +-
.../training-plans/[id]/posture-goal/route.ts | 140 ++++++++++
src/app/plans/page.tsx | 241 +++++++++++++++++-
src/lib/postureGoalProgress.ts | 44 ++++
tests/postureGoalProgress.test.ts | 145 +++++++++++
5 files changed, 582 insertions(+), 6 deletions(-)
create mode 100644 src/app/api/training-plans/[id]/posture-goal/route.ts
create mode 100644 src/lib/postureGoalProgress.ts
create mode 100644 tests/postureGoalProgress.test.ts
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9abf68a..ef8913b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -324,13 +324,27 @@ model TrainingPlan {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- weeks TrainingWeek[]
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ weeks TrainingWeek[]
+ postureGoal PlanPostureGoal?
@@index([userId])
@@index([userId, status])
}
+model PlanPostureGoal {
+ id String @id @default(cuid())
+ planId String @unique
+ faultType String
+ targetRate Float
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
+
+ @@index([planId])
+}
+
model TrainingWeek {
id String @id @default(cuid())
planId String
diff --git a/src/app/api/training-plans/[id]/posture-goal/route.ts b/src/app/api/training-plans/[id]/posture-goal/route.ts
new file mode 100644
index 0000000..b84559c
--- /dev/null
+++ b/src/app/api/training-plans/[id]/posture-goal/route.ts
@@ -0,0 +1,140 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { POSTURE_FAULT_CATALOG_V1 } from "@/lib/mocap/analysis/postureThresholds";
+import { computePostureGoalProgress } from "@/lib/postureGoalProgress";
+import type { PostureFaultType } from "@/lib/mocap/analysis/types";
+import type { SessionFaultInput } from "@/lib/mocap/postureTrendAggregation";
+
+async function getAuthedPlan(planId: string, userId: string) {
+ return prisma.trainingPlan.findFirst({
+ where: { id: planId, userId },
+ });
+}
+
+/**
+ * GET /api/training-plans/[id]/posture-goal
+ * Returns the plan's posture goal and current progress derived from linked mocap sessions.
+ */
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id: planId } = await params;
+ const plan = await getAuthedPlan(planId, session.user.id);
+ if (!plan) {
+ return NextResponse.json({ error: "Plan not found" }, { status: 404 });
+ }
+
+ const goal = await prisma.planPostureGoal.findUnique({ where: { planId } });
+ if (!goal) {
+ return NextResponse.json({ goal: null, progress: null });
+ }
+
+ // Gather linked mocap sessions through the plan's training sessions
+ const links = await prisma.trainingSessionLink.findMany({
+ where: { trainingSession: { week: { planId } } },
+ select: { rowingSessionId: true },
+ });
+ const rowingSessionIds = links.map((l) => l.rowingSessionId);
+
+ const mocapSessions = await prisma.mocapSession.findMany({
+ where: { rowingSessionId: { in: rowingSessionIds }, status: "ready" },
+ include: {
+ postureFaults: { select: { faultType: true, severity: true } },
+ strokePostureMetrics: { select: { id: true } },
+ },
+ });
+
+ const sessionInputs: SessionFaultInput[] = mocapSessions.map((ms) => ({
+ sessionId: ms.id,
+ sessionDate: ms.createdAt,
+ qualityScore: ms.qualityScore,
+ qualityFlags: ms.qualityFlags,
+ faults: ms.postureFaults.map((f) => ({
+ faultType: f.faultType,
+ severity: f.severity,
+ })),
+ strokeCount: ms.strokePostureMetrics.length,
+ }));
+
+ const progress = computePostureGoalProgress(
+ sessionInputs,
+ goal.faultType as PostureFaultType,
+ goal.targetRate,
+ );
+
+ return NextResponse.json({ goal, progress });
+}
+
+/**
+ * PUT /api/training-plans/[id]/posture-goal
+ * Create or replace the posture goal for a plan.
+ * Body: { faultType: string, targetRate: number }
+ */
+export async function PUT(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id: planId } = await params;
+ const plan = await getAuthedPlan(planId, session.user.id);
+ if (!plan) {
+ return NextResponse.json({ error: "Plan not found" }, { status: 404 });
+ }
+
+ const body = await req.json();
+ const { faultType, targetRate } = body;
+
+ if (!POSTURE_FAULT_CATALOG_V1.includes(faultType as PostureFaultType)) {
+ return NextResponse.json({ error: "Invalid faultType" }, { status: 400 });
+ }
+ if (typeof targetRate !== "number" || targetRate < 0 || targetRate > 1) {
+ return NextResponse.json(
+ { error: "targetRate must be a number between 0 and 1" },
+ { status: 400 },
+ );
+ }
+
+ const goal = await prisma.planPostureGoal.upsert({
+ where: { planId },
+ update: { faultType, targetRate },
+ create: { planId, faultType, targetRate },
+ });
+
+ return NextResponse.json({ goal });
+}
+
+/**
+ * DELETE /api/training-plans/[id]/posture-goal
+ * Remove the posture goal from a plan.
+ */
+export async function DELETE(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id: planId } = await params;
+ const plan = await getAuthedPlan(planId, session.user.id);
+ if (!plan) {
+ return NextResponse.json({ error: "Plan not found" }, { status: 404 });
+ }
+
+ await prisma.planPostureGoal.deleteMany({ where: { planId } });
+
+ return NextResponse.json({ success: true });
+}
diff --git a/src/app/plans/page.tsx b/src/app/plans/page.tsx
index 481de07..9ff7951 100644
--- a/src/app/plans/page.tsx
+++ b/src/app/plans/page.tsx
@@ -10,6 +10,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { trainingPlans, TrainingPlan, TrainingWeek, TrainingSession } from '@/lib/trainingPlans';
+import { POSTURE_FAULT_CATALOG_V1 } from '@/lib/mocap/analysis/postureThresholds';
+import type { PostureFaultType } from '@/lib/mocap/analysis/types';
+import type { PostureGoalProgress } from '@/lib/postureGoalProgress';
import { cloudAI } from '@/lib/cloudAI';
import { initializeCloudAIFromSettings, isAIAvailable, getAIConfigurationErrorMessage } from '@/lib/aiConfig';
import { useRowingStore } from '@/lib/store';
@@ -38,10 +41,61 @@ import {
Upload,
History,
RotateCcw,
+ Activity,
} from 'lucide-react';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { PlanAnalysisArchiveModal } from '@/components/PlanAnalysisArchiveModal';
+function PostureGoalSetter({
+ planId,
+ onSave,
+}: {
+ planId: string;
+ onSave: (planId: string, faultType: PostureFaultType, targetRate: number) => void;
+}) {
+ const [faultType, setFaultType] = useState('');
+ const [targetRate, setTargetRate] = useState(0.1);
+
+ return (
+
+
+ setFaultType(e.target.value as PostureFaultType | '')}
+ className="flex-1 p-1.5 border rounded-md text-sm"
+ >
+ Select fault type...
+ {POSTURE_FAULT_CATALOG_V1.map(ft => (
+ {ft.replace(/_/g, ' ')}
+ ))}
+
+ faultType && onSave(planId, faultType, targetRate)}
+ >
+ Set Goal
+
+
+ {faultType && (
+
+ Target ≤
+ setTargetRate(parseFloat(e.target.value))}
+ className="flex-1"
+ />
+ {(targetRate * 100).toFixed(0)}%/stroke
+
+ )}
+
+ );
+}
+
export default function PlansPage() {
const router = useRouter();
const { getSessions, setPendingPlanAnalysis } = useRowingStore();
@@ -64,15 +118,32 @@ export default function PlansPage() {
goals: '',
level: 'intermediate' as 'beginner' | 'intermediate' | 'advanced',
focus: 'general_fitness' as 'general_fitness' | 'endurance' | 'speed' | 'strength' | 'competition',
- duration: 8
+ duration: 8,
+ postureFaultType: '' as PostureFaultType | '',
+ postureTargetRate: 0.1,
});
+ const [postureGoalProgress, setPostureGoalProgress] = useState<{
+ goal: { faultType: string; targetRate: number } | null;
+ progress: PostureGoalProgress | null;
+ } | null>(null);
+
useEffect(() => {
loadPlans();
- // Keep AI availability consistent across reloads/navigation.
initializeCloudAIFromSettings();
}, []);
+ useEffect(() => {
+ if (!activePlan) {
+ setPostureGoalProgress(null);
+ return;
+ }
+ fetch(`/api/training-plans/${activePlan.id}/posture-goal`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => data && setPostureGoalProgress(data))
+ .catch(() => {});
+ }, [activePlan?.id]);
+
// Auto-select current week when active plan changes
useEffect(() => {
const selectWeek = async () => {
@@ -194,6 +265,18 @@ export default function PlansPage() {
});
}
+ // Attach posture goal if specified
+ if (planForm.postureFaultType) {
+ await fetch(`/api/training-plans/${newPlan.id}/posture-goal`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ faultType: planForm.postureFaultType,
+ targetRate: planForm.postureTargetRate,
+ }),
+ });
+ }
+
setPlans([newPlan, ...plans]);
setShowCreateForm(false);
resetForm();
@@ -346,7 +429,9 @@ export default function PlansPage() {
goals: plan.goals.join(', '),
level: plan.level,
focus: plan.focus,
- duration: plan.duration
+ duration: plan.duration,
+ postureFaultType: '',
+ postureTargetRate: 0.1,
});
};
@@ -435,6 +520,36 @@ Please provide:
router.push('/chat?fromPlanAnalysis=true');
}, [activePlan, getSessions, setPendingPlanAnalysis, router]);
+ const handleSavePostureGoal = async (planId: string, faultType: PostureFaultType, targetRate: number) => {
+ try {
+ const res = await fetch(`/api/training-plans/${planId}/posture-goal`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ faultType, targetRate }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setPostureGoalProgress({ goal: data.goal, progress: null });
+ // Reload progress
+ fetch(`/api/training-plans/${planId}/posture-goal`)
+ .then(r => r.ok ? r.json() : null)
+ .then(d => d && setPostureGoalProgress(d))
+ .catch(() => {});
+ }
+ } catch {
+ setError('Failed to save posture goal');
+ }
+ };
+
+ const handleRemovePostureGoal = async (planId: string) => {
+ try {
+ await fetch(`/api/training-plans/${planId}/posture-goal`, { method: 'DELETE' });
+ setPostureGoalProgress({ goal: null, progress: null });
+ } catch {
+ setError('Failed to remove posture goal');
+ }
+ };
+
const resetForm = () => {
setPlanForm({
title: '',
@@ -442,7 +557,9 @@ Please provide:
goals: '',
level: 'intermediate',
focus: 'general_fitness',
- duration: 8
+ duration: 8,
+ postureFaultType: '',
+ postureTargetRate: 0.1,
});
};
@@ -602,6 +719,50 @@ Please provide:
+
+
Posture Goal (optional)
+
+ Track a technique target using linked mocap sessions.
+
+
+
+ Fault to Reduce
+ setPlanForm(prev => ({ ...prev, postureFaultType: e.target.value as PostureFaultType | '' }))}
+ className="w-full p-2 border rounded-md"
+ >
+ None
+ {POSTURE_FAULT_CATALOG_V1.map(ft => (
+ {ft.replace(/_/g, ' ')}
+ ))}
+
+
+ {planForm.postureFaultType && (
+
+
+ Target Rate (faults/stroke): {planForm.postureTargetRate.toFixed(2)}
+
+
setPlanForm(prev => ({ ...prev, postureTargetRate: parseFloat(e.target.value) }))}
+ className="w-full"
+ />
+
+ 0 (none)
+ 1 (every stroke)
+
+
+ )}
+
+
+
handleCreatePlan(false)}
@@ -685,6 +846,78 @@ Please provide:
+ {/* Posture Goal Progress */}
+ {postureGoalProgress !== null && (
+
+
+
+
+ {postureGoalProgress.goal && (
+ handleRemovePostureGoal(activePlan.id)}
+ className="text-xs text-muted-foreground h-6 px-2"
+ >
+ Remove
+
+ )}
+ {!postureGoalProgress.goal && (
+ No goal set
+ )}
+
+
+
+ {postureGoalProgress.goal ? (
+ <>
+
+ Reduce {postureGoalProgress.goal.faultType.replace(/_/g, ' ')} to{' '}
+ ≤ {(postureGoalProgress.goal.targetRate * 100).toFixed(0)}% fault rate
+
+ {postureGoalProgress.progress && postureGoalProgress.progress.linkedMocapSessionCount > 0 ? (
+
+
+
+ {(postureGoalProgress.progress.currentRate * 100).toFixed(1)}%
+
+
Current Rate
+
+
+
+ {(postureGoalProgress.progress.targetRate * 100).toFixed(0)}%
+
+
Target Rate
+
+
+
+ {postureGoalProgress.progress.linkedMocapSessionCount}
+
+
Mocap Sessions
+
+
+ ) : (
+
+ No linked mocap sessions yet. Link a mocap session to a training session to track posture progress.
+
+ )}
+ {postureGoalProgress.progress?.achieved && (
+
Goal Achieved
+ )}
+ >
+ ) : (
+
+
+ Add a posture goal to track technique improvement across mocap sessions.
+
+
+
+ )}
+
+ )}
+
{/* Week Navigation */}
diff --git a/src/lib/postureGoalProgress.ts b/src/lib/postureGoalProgress.ts
new file mode 100644
index 0000000..4921092
--- /dev/null
+++ b/src/lib/postureGoalProgress.ts
@@ -0,0 +1,44 @@
+import type { PostureFaultType } from "./mocap/analysis/types";
+import type { SessionFaultInput } from "./mocap/postureTrendAggregation";
+
+export interface PostureGoalProgress {
+ faultType: PostureFaultType;
+ targetRate: number;
+ currentRate: number;
+ totalFaults: number;
+ totalStrokes: number;
+ linkedMocapSessionCount: number;
+ achieved: boolean;
+}
+
+export function computePostureGoalProgress(
+ sessions: SessionFaultInput[],
+ faultType: PostureFaultType,
+ targetRate: number,
+): PostureGoalProgress {
+ let totalFaults = 0;
+ let totalStrokes = 0;
+ let linkedMocapSessionCount = 0;
+
+ for (const session of sessions) {
+ linkedMocapSessionCount++;
+ totalStrokes += session.strokeCount;
+ for (const fault of session.faults) {
+ if (fault.faultType === faultType) {
+ totalFaults++;
+ }
+ }
+ }
+
+ const currentRate = totalStrokes > 0 ? totalFaults / totalStrokes : 0;
+
+ return {
+ faultType,
+ targetRate,
+ currentRate,
+ totalFaults,
+ totalStrokes,
+ linkedMocapSessionCount,
+ achieved: linkedMocapSessionCount > 0 && currentRate <= targetRate,
+ };
+}
diff --git a/tests/postureGoalProgress.test.ts b/tests/postureGoalProgress.test.ts
new file mode 100644
index 0000000..47428a8
--- /dev/null
+++ b/tests/postureGoalProgress.test.ts
@@ -0,0 +1,145 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ computePostureGoalProgress,
+} from "../src/lib/postureGoalProgress.js";
+import type { SessionFaultInput } from "../src/lib/mocap/postureTrendAggregation.js";
+
+function makeSession(overrides: Partial
= {}): SessionFaultInput {
+ return {
+ sessionId: "sess-1",
+ sessionDate: new Date("2025-01-01"),
+ qualityScore: 0.9,
+ qualityFlags: [],
+ faults: [],
+ strokeCount: 20,
+ ...overrides,
+ };
+}
+
+describe("computePostureGoalProgress — no sessions", () => {
+ test("empty sessions → currentRate 0, not achieved", () => {
+ const result = computePostureGoalProgress([], "excessive_layback", 0.1);
+ assert.equal(result.currentRate, 0);
+ assert.equal(result.totalFaults, 0);
+ assert.equal(result.totalStrokes, 0);
+ assert.equal(result.linkedMocapSessionCount, 0);
+ assert.equal(result.achieved, false);
+ });
+});
+
+describe("computePostureGoalProgress — single session", () => {
+ test("session with zero strokes → rate 0", () => {
+ const result = computePostureGoalProgress(
+ [makeSession({ strokeCount: 0, faults: [{ faultType: "excessive_layback", severity: "warning" }] })],
+ "excessive_layback",
+ 0.1,
+ );
+ assert.equal(result.currentRate, 0);
+ assert.equal(result.totalStrokes, 0);
+ });
+
+ test("counts only matching fault type", () => {
+ const result = computePostureGoalProgress(
+ [
+ makeSession({
+ strokeCount: 10,
+ faults: [
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "early_arm_bend", severity: "info" },
+ { faultType: "excessive_layback", severity: "critical" },
+ ],
+ }),
+ ],
+ "excessive_layback",
+ 0.1,
+ );
+ assert.equal(result.totalFaults, 2);
+ assert.ok(Math.abs(result.currentRate - 2 / 10) < 0.001);
+ });
+
+ test("goal achieved when currentRate ≤ targetRate", () => {
+ const result = computePostureGoalProgress(
+ [makeSession({ strokeCount: 10, faults: [{ faultType: "excessive_layback", severity: "info" }] })],
+ "excessive_layback",
+ 0.2,
+ );
+ // rate = 1/10 = 0.1, target = 0.2 → achieved
+ assert.equal(result.achieved, true);
+ });
+
+ test("goal not achieved when currentRate > targetRate", () => {
+ const result = computePostureGoalProgress(
+ [makeSession({ strokeCount: 10, faults: [
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "excessive_layback", severity: "warning" },
+ ] })],
+ "excessive_layback",
+ 0.2,
+ );
+ // rate = 3/10 = 0.3, target = 0.2 → not achieved
+ assert.equal(result.achieved, false);
+ });
+
+ test("goal achieved exactly at boundary (rate === target)", () => {
+ const result = computePostureGoalProgress(
+ [makeSession({ strokeCount: 10, faults: [
+ { faultType: "excessive_layback", severity: "warning" },
+ { faultType: "excessive_layback", severity: "warning" },
+ ] })],
+ "excessive_layback",
+ 0.2,
+ );
+ // rate = 2/10 = 0.2, target = 0.2 → achieved (≤)
+ assert.equal(result.achieved, true);
+ });
+});
+
+describe("computePostureGoalProgress — multiple sessions", () => {
+ const sessions: SessionFaultInput[] = [
+ makeSession({
+ sessionId: "a",
+ strokeCount: 20,
+ faults: [
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "rounded_back_at_catch", severity: "warning" },
+ { faultType: "excessive_layback", severity: "info" },
+ ],
+ }),
+ makeSession({
+ sessionId: "b",
+ strokeCount: 10,
+ faults: [
+ { faultType: "rounded_back_at_catch", severity: "critical" },
+ ],
+ }),
+ makeSession({
+ sessionId: "c",
+ strokeCount: 15,
+ faults: [],
+ }),
+ ];
+
+ test("accumulates faults and strokes across sessions", () => {
+ const result = computePostureGoalProgress(sessions, "rounded_back_at_catch", 0.1);
+ assert.equal(result.totalFaults, 3);
+ assert.equal(result.totalStrokes, 45);
+ assert.equal(result.linkedMocapSessionCount, 3);
+ assert.ok(Math.abs(result.currentRate - 3 / 45) < 0.001);
+ });
+
+ test("session with no matching faults contributes 0 faults but full stroke count", () => {
+ const result = computePostureGoalProgress(sessions, "early_arm_bend", 0.05);
+ assert.equal(result.totalFaults, 0);
+ assert.equal(result.totalStrokes, 45);
+ assert.equal(result.currentRate, 0);
+ assert.equal(result.achieved, true);
+ });
+
+ test("returns correct faultType in result", () => {
+ const result = computePostureGoalProgress(sessions, "rounded_back_at_catch", 0.1);
+ assert.equal(result.faultType, "rounded_back_at_catch");
+ assert.equal(result.targetRate, 0.1);
+ });
+});
From 4f0c774d1c1864bb47ce62ddafb3220620368c80 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 19:20:48 +0200
Subject: [PATCH 25/29] feat(achievements): add posture-derived Clean Catch
award (closes #29)
Award posture-clean-catch fires when a mocap-linked session has <=10%
rounded-back-at-catch faults with >=20 strokes and passing quality score.
Evaluation reads PostureFault derived rows via SessionFaultInput; raw pose
frames are never touched. Poor-quality captures (score < 0.5 or quality
flags) are excluded from eligibility. PostureFaultTrendCard triggers the
check after fetching trend data and the store merges/persists the award
through the existing notification flow.
Co-Authored-By: Claude Sonnet 4.6
---
src/app/api/mocap/posture-trend/route.ts | 2 +-
src/components/PostureFaultTrendCard.tsx | 22 +++-
src/lib/awards.ts | 30 +++--
src/lib/postureAchievements.ts | 21 ++++
src/lib/store.ts | 24 ++++
tests/postureAchievements.test.ts | 144 +++++++++++++++++++++++
6 files changed, 231 insertions(+), 12 deletions(-)
create mode 100644 src/lib/postureAchievements.ts
create mode 100644 tests/postureAchievements.test.ts
diff --git a/src/app/api/mocap/posture-trend/route.ts b/src/app/api/mocap/posture-trend/route.ts
index e9b3872..7aeead3 100644
--- a/src/app/api/mocap/posture-trend/route.ts
+++ b/src/app/api/mocap/posture-trend/route.ts
@@ -48,5 +48,5 @@ export async function GET() {
const result = aggregatePostureTrend(inputs);
- return NextResponse.json(result);
+ return NextResponse.json({ ...result, sessions: inputs });
}
diff --git a/src/components/PostureFaultTrendCard.tsx b/src/components/PostureFaultTrendCard.tsx
index 5c32a05..9671c61 100644
--- a/src/components/PostureFaultTrendCard.tsx
+++ b/src/components/PostureFaultTrendCard.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
+import { useRowingStore } from '@/lib/store';
import {
LineChart,
Line,
@@ -14,7 +15,7 @@ import {
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Activity, AlertTriangle } from 'lucide-react';
import type { PostureFaultType } from '@/lib/mocap/analysis/types';
-import type { PostureTrendResult, FaultTrendPoint } from '@/lib/mocap/postureTrendAggregation';
+import type { PostureTrendResult, FaultTrendPoint, SessionFaultInput } from '@/lib/mocap/postureTrendAggregation';
const FAULT_LABELS: Record = {
rounded_back_at_catch: 'Rounded Back',
@@ -85,21 +86,34 @@ function CustomDot(props: {
);
}
+type PostureTrendApiResponse = PostureTrendResult & { sessions?: Array };
+
+function deserializeSessions(raw: PostureTrendApiResponse['sessions']): SessionFaultInput[] {
+ if (!raw) return [];
+ return raw.map((s) => ({ ...s, sessionDate: new Date(s.sessionDate) }));
+}
+
export function PostureFaultTrendCard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const evaluatePostureAwards = useRowingStore((s) => s.evaluatePostureAwards);
useEffect(() => {
fetch('/api/mocap/posture-trend')
.then((r) => {
if (!r.ok) throw new Error('Failed to load posture trend data');
- return r.json() as Promise;
+ return r.json() as Promise;
+ })
+ .then((response) => {
+ const { sessions: rawSessions, ...trendResult } = response;
+ setData(trendResult);
+ const sessions = deserializeSessions(rawSessions);
+ if (sessions.length > 0) evaluatePostureAwards(sessions);
})
- .then(setData)
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Unknown error'))
.finally(() => setLoading(false));
- }, []);
+ }, [evaluatePostureAwards]);
const lowQualityDates = data ? lowQualitySessionDates(data) : new Set();
const chartData = data ? buildChartData(data) : [];
diff --git a/src/lib/awards.ts b/src/lib/awards.ts
index aa0fb73..2e076f2 100644
--- a/src/lib/awards.ts
+++ b/src/lib/awards.ts
@@ -1,16 +1,19 @@
import { Session } from '@/types/session';
-import {
- Trophy,
- Timer,
- Flame,
- Medal,
- Award as AwardIcon,
+import {
+ Trophy,
+ Timer,
+ Flame,
+ Medal,
+ Award as AwardIcon,
Zap,
Activity,
TrendingUp,
Target,
- Crown
+ Crown,
+ ShieldCheck
} from 'lucide-react';
+import { cleanCatchQualifies } from './postureAchievements';
+import type { SessionFaultInput } from './mocap/postureTrendAggregation';
export interface Award {
id: string;
@@ -501,5 +504,18 @@ export const AWARDS: Award[] = [
const date = new Date(s.timestamp);
return date.getHours() >= 21;
})
+ },
+
+ // Posture Achievements
+ {
+ id: 'posture-clean-catch',
+ title: 'Clean Catch',
+ description: 'Row a mocap-linked session with ≤10% rounded-back-at-catch faults (min. 20 strokes, quality capture required)',
+ icon: ShieldCheck,
+ color: 'text-cyan-500',
+ condition: (_sessions: Session[], stats?: { postureSessions?: SessionFaultInput[] }): boolean => {
+ const postureSessions = stats?.postureSessions;
+ return postureSessions ? postureSessions.some(cleanCatchQualifies) : false;
+ }
}
];
diff --git a/src/lib/postureAchievements.ts b/src/lib/postureAchievements.ts
new file mode 100644
index 0000000..3169b35
--- /dev/null
+++ b/src/lib/postureAchievements.ts
@@ -0,0 +1,21 @@
+import type { SessionFaultInput } from './mocap/postureTrendAggregation';
+import { isLowQuality } from './mocap/postureTrendAggregation';
+
+export const CLEAN_CATCH_AWARD_ID = 'posture-clean-catch' as const;
+export const CLEAN_CATCH_MIN_STROKES = 20;
+export const CLEAN_CATCH_MAX_FAULT_RATE = 0.10;
+
+export function cleanCatchQualifies(session: SessionFaultInput): boolean {
+ if (isLowQuality(session)) return false;
+ if (session.strokeCount < CLEAN_CATCH_MIN_STROKES) return false;
+ const faultCount = session.faults.filter((f) => f.faultType === 'rounded_back_at_catch').length;
+ return faultCount / session.strokeCount <= CLEAN_CATCH_MAX_FAULT_RATE;
+}
+
+export function firstCleanCatchDate(postureSessions: SessionFaultInput[]): Date | null {
+ const sorted = [...postureSessions].sort(
+ (a, b) => a.sessionDate.getTime() - b.sessionDate.getTime()
+ );
+ const first = sorted.find(cleanCatchQualifies);
+ return first ? first.sessionDate : null;
+}
diff --git a/src/lib/store.ts b/src/lib/store.ts
index bad90f6..042270b 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -4,6 +4,8 @@ import { AWARDS, EarnedAward } from '@/lib/awards';
import { initializeStoreFromDB, saveSessionsToDB, savePRsToDB, saveAwardsToDB, saveChartSettingsToDB, saveSessionAnalysisSettingsToDB } from '@/lib/dataSync';
import { clearSessionsCache } from '@/lib/services/sessionsCache';
import { clearAnalyticsCache } from '@/lib/services/analyticsCache';
+import { firstCleanCatchDate, CLEAN_CATCH_AWARD_ID } from '@/lib/postureAchievements';
+import type { SessionFaultInput } from '@/lib/mocap/postureTrendAggregation';
// Chart configuration types
export type ChartMetric = 'distance' | 'pace' | 'power' | 'strokeRate' | 'energy' | 'duration' | 'splitTime' | 'consistencyScore';
@@ -163,6 +165,7 @@ interface RowingStore {
updateChartSettings: (settings: Partial) => void;
resetChartSettings: () => void;
dismissNewAward: () => void;
+ evaluatePostureAwards: (postureSessions: SessionFaultInput[]) => void;
upsertAIAwardSuggestion: (suggestion: Omit & { suggestedAt?: Date }) => void;
approveAIAwardSuggestion: (id: string) => void;
@@ -884,6 +887,27 @@ export const useRowingStore = create()((set, get) => ({
set({ newlyEarnedAward: null });
},
+ evaluatePostureAwards: (postureSessions: SessionFaultInput[]) => {
+ const state = get();
+ const existingIds = new Set(state.earnedAwards.map((a) => a.awardId));
+ if (existingIds.has(CLEAN_CATCH_AWARD_ID)) return;
+
+ const earnedAt = firstCleanCatchDate(postureSessions);
+ if (!earnedAt) return;
+
+ const newAward: EarnedAward = { awardId: CLEAN_CATCH_AWARD_ID, earnedAt };
+ const updatedAwards = [...state.earnedAwards, newAward];
+
+ saveAwardsToDB([newAward]).catch((err) => {
+ console.error('[STORE] Failed to save posture award:', err);
+ });
+
+ set({
+ earnedAwards: updatedAwards,
+ newlyEarnedAward: state.newlyEarnedAward || newAward,
+ });
+ },
+
upsertAIAwardSuggestion: (suggestion) => {
set((state) => {
const next: AIAwardSuggestion = {
diff --git a/tests/postureAchievements.test.ts b/tests/postureAchievements.test.ts
new file mode 100644
index 0000000..c02f550
--- /dev/null
+++ b/tests/postureAchievements.test.ts
@@ -0,0 +1,144 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import {
+ cleanCatchQualifies,
+ firstCleanCatchDate,
+ CLEAN_CATCH_MIN_STROKES,
+ CLEAN_CATCH_MAX_FAULT_RATE,
+} from "../src/lib/postureAchievements.js";
+import type { SessionFaultInput } from "../src/lib/mocap/postureTrendAggregation.js";
+
+function makeSession(overrides: Partial = {}): SessionFaultInput {
+ return {
+ sessionId: "sess-1",
+ sessionDate: new Date("2025-06-01"),
+ qualityScore: 0.9,
+ qualityFlags: [],
+ faults: [],
+ strokeCount: 30,
+ ...overrides,
+ };
+}
+
+function roundedBackFaults(n: number) {
+ return Array.from({ length: n }, () => ({ faultType: "rounded_back_at_catch", severity: "warning" as const }));
+}
+
+// ── cleanCatchQualifies ────────────────────────────────────────────────────
+
+describe("cleanCatchQualifies — eligibility guards", () => {
+ test("low quality score → false", () => {
+ assert.equal(cleanCatchQualifies(makeSession({ qualityScore: 0.4 })), false);
+ });
+
+ test("quality flags present → false", () => {
+ assert.equal(cleanCatchQualifies(makeSession({ qualityFlags: ["partial_occlusion"] })), false);
+ });
+
+ test("stroke count below minimum → false", () => {
+ assert.equal(
+ cleanCatchQualifies(makeSession({ strokeCount: CLEAN_CATCH_MIN_STROKES - 1 })),
+ false,
+ );
+ });
+
+ test("exactly minimum strokes, zero faults → true", () => {
+ assert.equal(
+ cleanCatchQualifies(makeSession({ strokeCount: CLEAN_CATCH_MIN_STROKES, faults: [] })),
+ true,
+ );
+ });
+});
+
+describe("cleanCatchQualifies — fault rate threshold", () => {
+ test("zero faults → true", () => {
+ assert.equal(cleanCatchQualifies(makeSession({ strokeCount: 20, faults: [] })), true);
+ });
+
+ test("fault rate exactly at threshold → true", () => {
+ // 10% of 20 strokes = 2 faults
+ assert.equal(
+ cleanCatchQualifies(makeSession({ strokeCount: 20, faults: roundedBackFaults(2) })),
+ true,
+ );
+ });
+
+ test("fault rate one above threshold → false", () => {
+ // 3/20 = 15% > 10%
+ assert.equal(
+ cleanCatchQualifies(makeSession({ strokeCount: 20, faults: roundedBackFaults(3) })),
+ false,
+ );
+ });
+
+ test("other fault types not counted toward rounded_back rate", () => {
+ const faults = [
+ { faultType: "excessive_layback", severity: "warning" as const },
+ { faultType: "early_arm_bend", severity: "info" as const },
+ ];
+ // 0 rounded_back faults out of 20 strokes → eligible
+ assert.equal(cleanCatchQualifies(makeSession({ strokeCount: 20, faults })), true);
+ });
+
+ test("high fault rate with poor quality → false (quality check first)", () => {
+ assert.equal(
+ cleanCatchQualifies(makeSession({ qualityScore: 0.3, strokeCount: 30, faults: [] })),
+ false,
+ );
+ });
+});
+
+// ── firstCleanCatchDate ────────────────────────────────────────────────────
+
+describe("firstCleanCatchDate", () => {
+ test("empty sessions → null", () => {
+ assert.equal(firstCleanCatchDate([]), null);
+ });
+
+ test("no qualifying sessions → null", () => {
+ const sessions = [
+ makeSession({ qualityScore: 0.3 }),
+ makeSession({ strokeCount: 5 }),
+ makeSession({ strokeCount: 20, faults: roundedBackFaults(5) }), // 25% > 10%
+ ];
+ assert.equal(firstCleanCatchDate(sessions), null);
+ });
+
+ test("single qualifying session → its date", () => {
+ const date = new Date("2025-08-15");
+ const result = firstCleanCatchDate([makeSession({ sessionDate: date, strokeCount: 25, faults: [] })]);
+ assert.deepEqual(result, date);
+ });
+
+ test("returns earliest qualifying date when multiple sessions qualify", () => {
+ const early = new Date("2025-03-01");
+ const late = new Date("2025-09-01");
+ const sessions = [
+ makeSession({ sessionDate: late, strokeCount: 30, faults: [] }),
+ makeSession({ sessionDate: early, strokeCount: 30, faults: [] }),
+ ];
+ assert.deepEqual(firstCleanCatchDate(sessions), early);
+ });
+
+ test("skips non-qualifying sessions to find first qualifying one", () => {
+ const badDate = new Date("2025-01-01");
+ const goodDate = new Date("2025-06-01");
+ const sessions = [
+ makeSession({ sessionDate: badDate, qualityScore: 0.2 }), // low quality
+ makeSession({ sessionDate: goodDate, strokeCount: 30, faults: [] }),
+ ];
+ assert.deepEqual(firstCleanCatchDate(sessions), goodDate);
+ });
+
+ test("poor quality session between two clean sessions → returns earliest clean", () => {
+ const d1 = new Date("2025-02-01");
+ const d2 = new Date("2025-04-01"); // low quality
+ const d3 = new Date("2025-07-01");
+ const sessions = [
+ makeSession({ sessionDate: d3, strokeCount: 30, faults: [] }),
+ makeSession({ sessionDate: d2, qualityScore: 0.1 }),
+ makeSession({ sessionDate: d1, strokeCount: 30, faults: [] }),
+ ];
+ assert.deepEqual(firstCleanCatchDate(sessions), d1);
+ });
+});
From 25075b27eb967a8af591a77b0ad2e72af9f23198 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 19:34:03 +0200
Subject: [PATCH 26/29] =?UTF-8?q?feat(mocap):=20add=20sidecar=20capture=20?=
=?UTF-8?q?tracer=20=E2=80=94=20v2=20blob,=20sidecar-3d=20pipeline,=20UI?=
=?UTF-8?q?=20toggle=20(closes=20#30)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the minimal tracer bullet for freemocap sidecar capture per ADR-0005.
- poseFrameStream: keypointSchemaVersion v2 (x,y,z,confidence per keypoint),
encodeFrameV2, header gains coordinateSpace/cameraCount; v1 API unchanged
- poseFrameStreamAdapter: branches on v2 header, z-channel via keypointQuadsToPosePoints
- analysis/types: z? on PosePoint, coordinateSpace/cameraCount on PoseFrameStream,
Sidecar3DMetrics, three new fault types, 'pending' severity
- postureMetrics: toProjectedStream coordinate-space adapter (world-mm-3d to
normalized-2d projection for v1 fault rules), computeSidecar3DMetrics
- postureFaultDetector: three fault rule stubs emitting severity=pending
- sidecarClient: WebSocket + health poller for localhost:8765
- API routes: POST/GET /sidecar/connect|status per session
- sessions route: accepts source=sidecar, capturePerspective=sidecar-3d
- Prisma: calibrationId, cameraCount on MocapSession; sidecarPort on UserSettings
- Capture page: Multi-camera sidecar toggle with health poll
- coaching: drill/copy/severity entries for three new fault types
- Tests: 13 new tests (v2 codec, fixture, adapter, fault stubs)
- Docs: sidecar-local-setup.md
Co-Authored-By: Claude Sonnet 4.6
---
docs/sidecar-local-setup.md | 128 ++++++++++
prisma/schema.prisma | 3 +
.../sessions/[id]/sidecar/connect/route.ts | 60 +++++
.../sessions/[id]/sidecar/status/route.ts | 34 +++
src/app/api/mocap/sessions/route.ts | 9 +-
src/app/mocap/page.tsx | 77 ++++++
src/components/PostureFaultTrendCard.tsx | 6 +
.../__tests__/fixtures/generate-v2-blob.mjs | 72 ++++++
.../mocap/__tests__/fixtures/v2-blob-3d.bin | Bin 0 -> 53632 bytes
.../mocap/analysis/poseFrameStreamAdapter.ts | 47 +++-
.../mocap/analysis/postureFaultDetector.ts | 67 +++++-
src/lib/mocap/analysis/postureMetrics.ts | 152 +++++++++++-
src/lib/mocap/analysis/types.ts | 20 +-
src/lib/mocap/coaching/coachingAdvisor.ts | 28 +++
src/lib/mocap/coaching/liveCoachingEngine.ts | 1 +
src/lib/mocap/poseFrameStream.ts | 98 ++++++--
src/lib/mocap/sidecarClient.ts | 64 +++++
tests/sidecarTracer.test.ts | 223 ++++++++++++++++++
18 files changed, 1051 insertions(+), 38 deletions(-)
create mode 100644 docs/sidecar-local-setup.md
create mode 100644 src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts
create mode 100644 src/app/api/mocap/sessions/[id]/sidecar/status/route.ts
create mode 100644 src/lib/mocap/__tests__/fixtures/generate-v2-blob.mjs
create mode 100644 src/lib/mocap/__tests__/fixtures/v2-blob-3d.bin
create mode 100644 src/lib/mocap/sidecarClient.ts
create mode 100644 tests/sidecarTracer.test.ts
diff --git a/docs/sidecar-local-setup.md b/docs/sidecar-local-setup.md
new file mode 100644
index 0000000..6cb1e7e
--- /dev/null
+++ b/docs/sidecar-local-setup.md
@@ -0,0 +1,128 @@
+# Running the sidecar tracer locally
+
+This guide covers how to run the minimal freemocap sidecar integration for local development and testing.
+
+## Prerequisites
+
+- Python 3.10+ with pip
+- `rowing-tracker-sidecar` PyPI package (or the mock server below)
+- The app running locally (`npm run dev`)
+
+## Option A — real freemocap sidecar
+
+```bash
+pip install rowing-tracker-sidecar
+rowing-tracker-sidecar --port 8765
+```
+
+The sidecar exposes:
+- `ws://localhost:8765/pose-stream` — streams `KeypointFrame` JSON
+- `GET http://localhost:8765/health` — returns `{ status, fps, cameras, schemaVersion }`
+- `POST http://localhost:8765/session/start` — arms capture
+- `POST http://localhost:8765/session/stop` — flushes and closes
+
+## Option B — minimal mock server (for UI/API dev without hardware)
+
+```python
+#!/usr/bin/env python3
+"""Minimal sidecar mock — runs without freemocap or cameras."""
+import asyncio, json, math, random, time
+import websockets
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import threading
+
+PORT = 8765
+FPS = 30
+
+def health():
+ return {"status": "ready", "fps": FPS, "cameras": 3, "schemaVersion": 2}
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/health":
+ body = json.dumps(health()).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(body)
+ def do_POST(self):
+ body = json.dumps({"sessionId": "mock-session", "calibrationId": "mock-calib"}).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(body)
+ def log_message(self, *a): pass
+
+async def pose_stream(websocket):
+ frame_index = 0
+ while True:
+ ts = time.time() * 1000
+ keypoints = [
+ {"index": i, "x": 50 + math.sin(i * 0.5) * 200,
+ "y": 500 + math.cos(i * 0.3 + frame_index * 0.05) * 300,
+ "z": 1000 + random.gauss(0, 20),
+ "confidence": 0.85 + random.gauss(0, 0.05)}
+ for i in range(33)
+ ]
+ frame = {"frameIndex": frame_index, "timestampMs": ts,
+ "keypoints": keypoints,
+ "quality": {"trackedCount": 33, "meanConfidence": 0.85,
+ "reprojectionErrorMm": 1.2, "cameraCount": 3}}
+ try:
+ await websocket.send(json.dumps(frame))
+ except websockets.exceptions.ConnectionClosed:
+ break
+ frame_index += 1
+ await asyncio.sleep(1 / FPS)
+
+async def main():
+ http = HTTPServer(("", PORT), Handler)
+ threading.Thread(target=http.serve_forever, daemon=True).start()
+ print(f"Sidecar mock running on port {PORT}")
+ async with websockets.serve(pose_stream, "localhost", PORT, path="/pose-stream"):
+ await asyncio.Future()
+
+asyncio.run(main())
+```
+
+Save as `scripts/sidecar-mock.py` and run:
+
+```bash
+pip install websockets
+python scripts/sidecar-mock.py
+```
+
+## Using the sidecar in the app
+
+1. Start the sidecar (real or mock) on port 8765.
+2. Open the app at `http://localhost:3000/mocap`.
+3. Check **Multi-camera sidecar** — the UI polls health and shows "Sidecar ready — 3 cameras, 30 fps".
+4. Click **Start mocap session** — the app creates a session with `source=sidecar`, `capturePerspective=sidecar-3d`.
+5. The session detail page opens as normal. Posture faults from sidecar-3D will appear with `severity=pending` for the three new fault types until thresholds are defined.
+
+## API endpoints added
+
+| Method | Path | Purpose |
+|--------|------|---------|
+| `POST` | `/api/mocap/sessions/:id/sidecar/connect` | Verify sidecar health and arm capture |
+| `GET` | `/api/mocap/sessions/:id/sidecar/status` | Proxy to `localhost:8765/health` |
+
+Both require an authenticated session and a MocapSession in `capturing` status.
+
+## PoseFrameStream v2 blob format
+
+v2 blobs are written when `source=sidecar`. Key differences from v1:
+
+- `keypointSchemaVersion = 2` in header
+- Each keypoint is `[x, y, z, confidence]` (4 × Float32 per keypoint, vs 3 × Float32 in v1)
+- Header byte 20: `coordinateSpace` (0 = normalized-2d, 1 = world-mm-3d)
+- Header byte 21: `cameraCount`
+- v1 blobs are unchanged and remain readable
+
+## Running the new tests
+
+```bash
+npx tsx --test tests/sidecarTracer.test.ts
+```
+
+Expected: 13 tests pass.
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ef8913b..8b1113c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -194,6 +194,8 @@ model MocapSession {
captureFps Float
calibrationCatchFrame Json?
calibrationFinishFrame Json?
+ calibrationId String?
+ cameraCount Int?
durationSec Float @default(0)
qualityScore Float?
qualityFlags String[] @default([])
@@ -530,6 +532,7 @@ model UserSettings {
userProfileRawInput String? @db.Text
postureThresholds Json?
mocapPreferences Json?
+ sidecarPort Int?
dashboardSettings Json?
sessionsViewSettings Json?
sessionAnalysisSettings Json?
diff --git a/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts b/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts
new file mode 100644
index 0000000..ae645b4
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/sidecar/connect/route.ts
@@ -0,0 +1,60 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { z } from "zod";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { checkSidecarHealth, SIDECAR_DEFAULT_PORT } from "@/lib/mocap/sidecarClient";
+
+const ConnectBody = z.object({
+ port: z.number().int().positive().optional(),
+});
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const mocapSession = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { id: true, status: true },
+ });
+ if (!mocapSession) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (mocapSession.status !== "capturing") {
+ return NextResponse.json(
+ { error: `Session is ${mocapSession.status}; expected capturing` },
+ { status: 409 },
+ );
+ }
+
+ let body: z.infer;
+ try {
+ body = ConnectBody.parse(await req.json().catch(() => ({})));
+ } catch (err) {
+ return NextResponse.json(
+ { error: "Invalid request body", details: err instanceof Error ? err.message : String(err) },
+ { status: 400 },
+ );
+ }
+
+ const port = body.port ?? SIDECAR_DEFAULT_PORT;
+
+ try {
+ const health = await checkSidecarHealth(port);
+ return NextResponse.json({
+ status: "connected",
+ fps: health.fps,
+ cameras: health.cameras,
+ schemaVersion: health.schemaVersion,
+ port,
+ });
+ } catch {
+ return NextResponse.json({ status: "unreachable", port }, { status: 503 });
+ }
+}
diff --git a/src/app/api/mocap/sessions/[id]/sidecar/status/route.ts b/src/app/api/mocap/sessions/[id]/sidecar/status/route.ts
new file mode 100644
index 0000000..a71ca9f
--- /dev/null
+++ b/src/app/api/mocap/sessions/[id]/sidecar/status/route.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/db/prisma";
+import { checkSidecarHealth, SIDECAR_DEFAULT_PORT } from "@/lib/mocap/sidecarClient";
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { id } = await params;
+ const mocapSession = await prisma.mocapSession.findFirst({
+ where: { id, userId: session.user.id },
+ select: { id: true },
+ });
+ if (!mocapSession) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ const url = new URL(req.url);
+ const port = parseInt(url.searchParams.get("port") ?? String(SIDECAR_DEFAULT_PORT), 10);
+
+ try {
+ const health = await checkSidecarHealth(port);
+ return NextResponse.json({ ...health, port });
+ } catch {
+ return NextResponse.json({ status: "unreachable", port }, { status: 503 });
+ }
+}
diff --git a/src/app/api/mocap/sessions/route.ts b/src/app/api/mocap/sessions/route.ts
index a064514..f29f1ad 100644
--- a/src/app/api/mocap/sessions/route.ts
+++ b/src/app/api/mocap/sessions/route.ts
@@ -49,11 +49,13 @@ const CalibrationFrame = z.object({
const CreateBody = z
.object({
- source: z.enum(["browser"]),
+ source: z.enum(["browser", "sidecar"]),
captureModelVersion: z.string().min(1).max(120),
- capturePerspective: z.enum(["side-left", "side-right"]),
+ capturePerspective: z.enum(["side-left", "side-right", "sidecar-3d"]),
captureFps: z.number().positive().max(240),
recordOnly: z.boolean().optional(),
+ calibrationId: z.string().uuid().optional(),
+ cameraCount: z.number().int().positive().max(16).optional(),
calibrationCatchFrame: CalibrationFrame.extend({
pose: z.literal("catch"),
}).optional(),
@@ -62,6 +64,7 @@ const CreateBody = z
}).optional(),
})
.superRefine((body, ctx) => {
+ if (body.source === "sidecar") return; // sidecar sessions have no browser calibration frames
for (const field of ["calibrationCatchFrame", "calibrationFinishFrame"] as const) {
if (body.recordOnly && body[field] === undefined) continue;
if (!body[field]) {
@@ -111,6 +114,8 @@ export async function POST(req: Request) {
captureFps: body.captureFps,
calibrationCatchFrame: body.calibrationCatchFrame,
calibrationFinishFrame: body.calibrationFinishFrame,
+ calibrationId: body.calibrationId,
+ cameraCount: body.cameraCount,
videoStoragePath: "pending",
poseStreamPath: "pending",
status: "capturing",
diff --git a/src/app/mocap/page.tsx b/src/app/mocap/page.tsx
index a2a5450..810b3fe 100644
--- a/src/app/mocap/page.tsx
+++ b/src/app/mocap/page.tsx
@@ -51,6 +51,7 @@ import type {
PostureFault,
} from "@/lib/mocap/analysis/types";
import { settings } from "@/lib/settings";
+import { checkSidecarHealth, SIDECAR_DEFAULT_PORT, type SidecarHealth } from "@/lib/mocap/sidecarClient";
const CAPTURE_FPS = 30;
const CAPTURE_MODEL_VERSION = "mediapipe-pose-landmarker-lite@0.10.35";
@@ -162,6 +163,9 @@ export default function MocapCapturePage() {
const [framingDegraded, setFramingDegraded] = useState(false);
const [sessionQualityFlags, setSessionQualityFlags] = useState([]);
const [recordOnly, setRecordOnly] = useState(false);
+ const [useSidecar, setUseSidecar] = useState(false);
+ const [sidecarHealth, setSidecarHealth] = useState(null);
+ const [sidecarError, setSidecarError] = useState(null);
const [recordOnlyReason, setRecordOnlyReason] =
useState(null);
const [captureSupport, setCaptureSupport] =
@@ -453,6 +457,45 @@ export default function MocapCapturePage() {
);
const start = useCallback(async () => {
+ // Sidecar path: skip browser camera/calibration, use sidecar-3d perspective
+ if (useSidecar) {
+ setState({ kind: "starting" });
+ try {
+ const health = await checkSidecarHealth(SIDECAR_DEFAULT_PORT);
+ setSidecarHealth(health);
+ setSidecarError(null);
+ if (health.status !== "ready") {
+ setState({ kind: "error", message: `Sidecar not ready: ${health.status}` });
+ return;
+ }
+ const createRes = await fetch("/api/mocap/sessions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ source: "sidecar",
+ captureModelVersion: `freemocap-sidecar@schemaV${health.schemaVersion}`,
+ capturePerspective: "sidecar-3d",
+ captureFps: health.fps,
+ cameraCount: health.cameras,
+ }),
+ });
+ if (!createRes.ok) throw new Error(`Create session failed: ${createRes.status}`);
+ const created: { id: string } = await createRes.json();
+ // Arm the sidecar via the connect endpoint
+ await fetch(`/api/mocap/sessions/${created.id}/sidecar/connect`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ setState({ kind: "capturing", sessionId: created.id, startedAt: Date.now() });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Sidecar error";
+ setSidecarError(msg);
+ setState({ kind: "error", message: msg });
+ }
+ return;
+ }
+
const captureRecordOnly =
recordOnly ||
Boolean(
@@ -612,6 +655,7 @@ export default function MocapCapturePage() {
perspective,
recordOnly,
recordOnlyReason,
+ useSidecar,
clearCueDismissTimer,
]);
@@ -831,6 +875,39 @@ export default function MocapCapturePage() {
/>
Audio cues
+
+ {
+ const checked = e.target.checked;
+ setUseSidecar(checked);
+ setSidecarError(null);
+ setSidecarHealth(null);
+ if (checked) {
+ try {
+ const h = await checkSidecarHealth(SIDECAR_DEFAULT_PORT);
+ setSidecarHealth(h);
+ } catch {
+ setSidecarError("Sidecar not reachable on port 8765. Install rowing-tracker-sidecar and run it first.");
+ }
+ }
+ }}
+ disabled={state.kind !== "idle" && state.kind !== "done"}
+ data-testid="mocap-sidecar-toggle"
+ />
+ Multi-camera sidecar
+
+ {useSidecar && sidecarHealth && (
+
+ Sidecar ready — {sidecarHealth.cameras} camera{sidecarHealth.cameras !== 1 ? "s" : ""}, {sidecarHealth.fps} fps
+
+ )}
+ {useSidecar && sidecarError && (
+
+ {sidecarError}
+
+ )}
Cues:
= {
back_opens_before_legs_drive: 'Back Opens Early',
excessive_layback: 'Excessive Layback',
slow_recovery_ratio: 'Slow Recovery',
+ left_right_asymmetry: 'L/R Asymmetry',
+ knee_track_deviation: 'Knee Track',
+ shin_not_vertical_at_catch: 'Shin Angle',
};
const FAULT_COLORS: Record = {
@@ -31,6 +34,9 @@ const FAULT_COLORS: Record = {
back_opens_before_legs_drive: '#eab308',
excessive_layback: '#8b5cf6',
slow_recovery_ratio: '#06b6d4',
+ left_right_asymmetry: '#10b981',
+ knee_track_deviation: '#3b82f6',
+ shin_not_vertical_at_catch: '#f59e0b',
};
interface ChartPoint {
diff --git a/src/lib/mocap/__tests__/fixtures/generate-v2-blob.mjs b/src/lib/mocap/__tests__/fixtures/generate-v2-blob.mjs
new file mode 100644
index 0000000..be13930
--- /dev/null
+++ b/src/lib/mocap/__tests__/fixtures/generate-v2-blob.mjs
@@ -0,0 +1,72 @@
+/**
+ * Generates a synthetic v2 (sidecar-3d) PoseFrameStream blob for tests.
+ * Run: node generate-v2-blob.mjs
+ * Writes: v2-blob-3d.bin
+ */
+import { writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+
+const MAGIC = new Uint8Array([0x4d, 0x4f, 0x50, 0x53]);
+const HEADER_SIZE = 32;
+const FORMAT_VERSION = 1;
+const KEYPOINT_SCHEMA_V2 = 2;
+const KEYPOINTS_PER_FRAME_V2 = 33;
+const BYTES_PER_FRAME_V2 = 4 + KEYPOINTS_PER_FRAME_V2 * 4 * 4 + 4;
+const COORDINATE_SPACE_WORLD_MM_3D = 1;
+const FPS = 30;
+const FRAME_COUNT = 100;
+const CAMERA_COUNT = 3;
+
+// Build header
+const header = new Uint8Array(HEADER_SIZE);
+const hv = new DataView(header.buffer);
+header.set(MAGIC, 0);
+hv.setUint16(4, FORMAT_VERSION, true);
+hv.setUint16(6, KEYPOINT_SCHEMA_V2, true);
+hv.setFloat32(8, FPS, true);
+hv.setUint16(12, KEYPOINTS_PER_FRAME_V2, true);
+hv.setUint16(14, BYTES_PER_FRAME_V2, true);
+hv.setUint32(16, FRAME_COUNT, true);
+hv.setUint8(20, COORDINATE_SPACE_WORLD_MM_3D);
+hv.setUint8(21, CAMERA_COUNT);
+
+// Build 100 frames simulating one rowing stroke in world-mm-3d
+// Rowing motion: catch at frame 0, finish at frame 35, recovery frames 36-99
+const frames = new Uint8Array(FRAME_COUNT * BYTES_PER_FRAME_V2);
+
+for (let f = 0; f < FRAME_COUNT; f++) {
+ const offset = f * BYTES_PER_FRAME_V2;
+ const fv = new DataView(frames.buffer, offset, BYTES_PER_FRAME_V2);
+ const t = f / FPS;
+ const phase = f < 35 ? f / 35 : (f - 35) / 65; // 0→1 in drive, 0→1 in recovery
+
+ // timestampMs
+ fv.setFloat32(0, 1700000000000 + t * 1000, true);
+
+ // Write 33 keypoints as [x, y, z, confidence]
+ for (let k = 0; k < KEYPOINTS_PER_FRAME_V2; k++) {
+ const base = 4 + k * 16;
+ // Approximate world-mm-3d positions (simplified rowing skeleton)
+ const x = 50 + Math.sin(k * 0.5) * 200; // lateral position mm
+ const y = 500 + Math.cos(k * 0.3) * 300 + phase * 100; // vertical mm
+ const z = 1000 + Math.sin(f * 0.1 + k) * 50; // forward/back mm
+ const conf = 0.8 + 0.15 * Math.sin(k + f * 0.05);
+ fv.setFloat32(base, x, true);
+ fv.setFloat32(base + 4, y, true);
+ fv.setFloat32(base + 8, z, true);
+ fv.setFloat32(base + 12, conf, true);
+ }
+
+ // qualityFlags
+ fv.setUint32(BYTES_PER_FRAME_V2 - 4, 0, true);
+}
+
+const blob = new Uint8Array(HEADER_SIZE + FRAME_COUNT * BYTES_PER_FRAME_V2);
+blob.set(header, 0);
+blob.set(frames, HEADER_SIZE);
+
+writeFileSync(join(__dir, "v2-blob-3d.bin"), blob);
+console.log(`Written ${blob.byteLength} bytes → v2-blob-3d.bin`);
diff --git a/src/lib/mocap/__tests__/fixtures/v2-blob-3d.bin b/src/lib/mocap/__tests__/fixtures/v2-blob-3d.bin
new file mode 100644
index 0000000000000000000000000000000000000000..62f62cd9b3b53243e67ccd0d017767a5da153dbc
GIT binary patch
literal 53632
zcmY)1cT`u`f`(zch&8dquGp|(L9oPza5hMjA|Q5SR}=&kY-lL<-V0G-@R)Mv)0U-zwdoG%ICZH?iv~vQLsRv0tE{E;_XtPa-qTY
z|5vcE{eS-ZKfgW5jVMsS-^c!6Q=q^^bNahrujO~%v643#)7vNAd{;fzD=es&mH1OT
zb8B0=S&%*2>+pvYtVe5`nB~VOnRnWD^D2IQnC08FvS~j%-ne|(-0S4dMwV+xA(K!z
z&ba^W>a`1dcJ~TekHID0RTcqxw8+R_6~*>*syEvWK*?-X!g?{%Vn7@-8NN
zty&dpB@DP~C6`MxgL8*?)w?;)dcB~q**i4ZtUT=PHG5aM<<_%`nejT-+`jGVHEvW9
zt7@j3xwJao1ixzTRr+>GpOnn@=A)aFjO+YvUenu*Jmy>2*X-S$Zbn`k?RDkW>tg|L
zyO>Sw(oOQSQC_M4Ry;n=7_;^61oKT&uvgb+e;<3=w4JFuYP6{l;^tMyXUMT0>l&K|
z+u}@SvBq9qetp+x=)aZBgdXF~!R#(x^PGxVFG?0Pl~1IZG8JRIjvwu2Jsfwz^87p9
z^qHRERi)N=E45C(Otq8XK6I0EczXo_g<9{x$?e#a~y(XC&`$FU*
zysxi$z9`*1sTL;}UDvcXb%&&zXE)>IA~(v-l$|!wJP!?%i`w@pn~dDi#(!EfxhPz;
zuo)5?XQCH1lnd9MSF8&A$C)hwcYA9Kg}#j7$z6rotK+I`PTKO
zspj71esYm@cc*o~Q---+dW>8&Jaf%@yeQSU`VW?ii+)8+|M!#3$4|WE!l_(U^Up5a
zIb6I7Yihomlwe+1?sCy1yn}hS%4Aa^F;XsSAMiD+eoi-8E92#&{%-p}&+BQX{Kb)S
z5%JoXzMqUYnX?1s;?%u%X4siHv%gytx$ul`V(OaFrp_IQ3-7(}8Lt%+OwV-za&h`t
zK@(L!-OLCWDHm(+U$pKWPd62w#LI=#tgTk5CMo8V=qS0^?lQ+J@I$=W@W@>*)=eE^
z^?wj+{`PQ{i|H-8TDdinO>|i=xfnJk)N=VP)oh$QD4&b$*Z^~QQi}215GEH*EBG1f
zT)H_>bhKPFjO<`mmQFWQUc}19x>8Mzr|$$ae@KX2yveL$URE7#&V0~RF7gf*HZOjU
zGhcW$l8d~PSFIYa#+!G)_mzuimz`GL%QSQCo8fYi;WgiCay#94mUOtNIVHyO`Z~pY
zR=THLjLr_ThILLb)xPtTixK0)tqsRw&2Jw$T>P+ol=bGd%0Nu#{w%nE7jDG
z`amvX!n3T)9Wu-aH;0Rx9d1}1om0*Ig9GJa*V`hdbLFw-ZtYHTarb0Z^JG@6DOK3v
z;%xC|W>$QnX|u7VTzEKjG^+!rnCju(<>F1ep9!myVaj_Y$VGvX0p@x_nmPYjj9fI^
zgKOsK#vx(+GQiNO#T`5Eq>03Xu
zvuK9daK+)GNXd@IWlNg5`h1jJq#SQ*+7useTqg$0#ij77#&>RR
z?^oMsxwx@7!Wup--uOp)$i=0INULu7I5Y5?!$rqAqpdA_lFdugQ7(E6S!9JZPcsi*
z4v-6%oZZ&N#u;XCONWc)pXFJLUrsTrTn5O+yxm1jhhk&Q?EW3(;_&2ZX21RC)T!(S
z`g3IK;bvyd_%Y`F@}6>0Hm9R$lJKD^-MEKbTypJVVm&jA%l8f!*IWY4t=Fk$!l)5)
z;XJ~2-tQ+DQ@V994WGrDldBua#gh|mX3vp$lXb;SF8-`r#T=@TV#X$Sm5Zq@
z3Y(OibklBJtXz~jdc``>B;BkVJzC$3q`^C^+gm4?hYP~xqQ={K)|t@JrblRVxtP>`
zq%|-%&g^T_P~VFI54u@-^TwO#Jw9^b(>ThCxR7R=O&^-iMclPOQ!j6dIUO4*7x&V-
zn0*a1OwASX`d-{T+R+?tn`S<}JW?*ACpI&_-`oE^%ny`{ET?MbR@FFjBeIEHENfKM
zxJJjDO1B&?f;Zf-UXGn){{1LGE>673vYxL=H#hx8%Ed2L7g>Fx?Z3Y~jhBnRJmW3@
z(23@!-cfQ97}wo;xyg3+)Lkxon@3v>(&Nl~_IHuLUwl3^(R%0SamFg?B^PDSEw(b(
zrI}i@2Fb-Gx4l;B5*g-nEr*NX%D1f7Ur#nc6Z^}>h>^ui*tS#eD9SVbXmQ^}Wcx9B6h=N;Rje
z4VR1iwYr!eizk`Pi$*SbmFQ@0ZizKvcN@yZ$w^JkMfnn?mK{T+9ONTy~tPQnQ&{
zJgO9DRrz4FdHjvT#hGJKmhLN0bW_p;`f8Evj_aJZ=AlVs&KnPB=~?JO6$otIi=
zilv)xN)M5X`I*_)g-hvX=kJcccNU(0+uF4>#W89rqV`XHtl##IHX|B0)c0a~jq%pXkrPeroj!7r
zvTd2QrC+-FC4Hz|_&V*kK3kD)8t-(tIPlvY>xUkb%oC?Ra#8zAapUJ3Z=UvRBNyQt
zYM89Lac1pshYPQ}EzI^u$tL)4Te(<&*UQwi(#+baedQu1IKXsnkYV0?>TnU~7i%f*~SeXUDhr<LPtzo?EKaa29
zYbY14A;r!2HO81*zc!VNgeSMHX=Oh&wflz3#UtN+*1JtJ%W!8~;X(nZH
z+<$*h9dGr^Og61Q50#6h1^QdL&T-~I?PhW@|7d?}^MrUa^lOKU-~XIw_4sU(={euX
zg-7)j){HIbX5)ila`7hVfYmY}-3*TU-|wmStVvZSn$q6AYAl4{nD87UXx)%IE&E{-+Z?gz-lpIJ++Pu9np
zKMFZqwA_+xef@2M$!pO>E-FX$v#zI3HqCtl<>IduA6VP|v48*kVq`uS*Y5NN*)O`T>1>$3J+pLfhK$)Tg=;=8lUt-Q^t=E|B_
zx!AE`l4V9FnO;Y_%0hmG{T7Uu$xg75&
z7msJHv>xA2H~+L7As1P94_cnj(oE*&s#3uWY)B;$C?{^
zUG=@lJLqIqMkJXz16s&M-pPAb+L}~TwrPZ1M7tccLhL=^PPKTs$naWeZE#67ZI+Ib
zi<(oWSa-XOHFwSj$;IgG!PZCfV@=9mP2^(4_#xIS-$YX;xUpP3j83(lyf?*+i1wF@
z^?$6gT9?l-t|>8c5flEUwR&orsnp%!;%0{jmiL-*X7kGKa{ZCYz^~S
z_cSy8R1dk>GNHJ6-^KoO_fLn5sE9k(dyA(S@3+I{BIngU>($mobGnkBTnt^X+&Vio
z*5vhWBo}2qNU=gMj4^XtHkQSrfVVI5$oE=K0Qna?xYRN7^?dJ{u_)E;)y_Z@Op2>wB^MvxgS@X3~L3
zxtO=xS^MT^=#=PduZRRu*pj<%I5fJ-%Kq%SS~KP25a9eQNiKj
znoFqmO^-+Ymt0J3
z;bho1y9`g%_oCF%yV^HbZyF{SNrMk)-*j(fUaQ
zzFD!Sk6fIKS*3k5wyeWNQpq&!o5kiemy0vU?0wkYH?RI2E*CqBjXL4qW+xT(ore)V{gt`}T4%Kc}|#%~78?TqN9b
zHtd@%7kJ7=;c}0(Z+>vRhg?jG$(BY!+^d}bk
zX1QO&#FZXlL_hCH}{`!
zAr~Fjj?%ta<9vi%)Vv*MXbv
z^PM7%d-+Woy;o{Cu8QM3)o3)gS&nJx5zIl6hlw8a?lwj3R7hl`4XKi0lk+GnU-tRFO8`({92
zAGugsV2t+7UF94u<{urSeY554=5jGHWrp_6>5s$Z!lU|n?VDN4N6W>Vq!ZdV&-=y7
zMZ$oeE%wcd)w;^X>jkB?Z}#2nCKql!>uTRTa@66XYG!Ngn-wZ_k&Bc}U+tUkY#AXJ
zzJ)`zZ-(!2?BD@!!?kaI@?*4Ij58tHHwdXVKn~y8>lZ$m(
z$F*-(v%ib{JNtKM*JHWbK;=&$f^Y
z_s27}Z=O37As3HlZP32Cw0FE*WZlixzUkk3lw34C^W0+J+}=D$E-w0&(Z2cMlO}TE
zRIZ-(&1=gV>wEDktc~`~!>|42qDQ!&_RXK~#>hqO1EJbC@4Gl$)Zg7r`{u+yqvRsu
zb%^%OH6OH;i&OWE_RZ|_j{U+jx|R0LDM_8=!h5fa_Raqk8zdK}kCim+n=2YRT&%tS
zSo@|=u@B_JY1UEgn+@Bvmy7K#A8X&dve@Aw?AUbeo4d-ll8fmrlC*ES4DKlx!^VuW
z&LyOqQZ9-4z8BfC5!yGidJLC~rWL}pZ`Nq}zu!~+v~T8Z=_wcMO106x`A^qYa`7g!
zuJ+BU_ID8%c?U}y_RS?P+sQ@V$)B}v);ZZwQt_8H|D>;r*6=`nd>u1F3Nh%
z(!SaFKqt8vojqRr=JzfR7bC_`(7x%`q@`RujGnE1bHk1(xmf?lM(vw>N+ifdO!z77
zn;YI4DHk_8{A#gpcJmF8i(PNaXy4qD-B>Q}o~);R^V)|^j+BdLyF#^ZE-2zI7aKqF)xNo5lEX#WGOe|5
zUh(cM7d2w*Xy0tPaIjo#nNZ5GZ>AP?xQL2)s(tgj?)~H<=hZRon}uh-FBd}>tkb?Z
zBHrPmc#E0ZH`lLeEf>F+9It)TyG3ugI3F_6imje*B4_4zvAABO_RSWXM#zQJw_(~h
zr|os@;6+My(Y|@^VzgYO9B-q2^ZpMWa&alVp7zZs`@4vX1Bqn}`)03g9pqw}=P%kf
zTaO$d7o``R)V^8E{x0&pcz^3g?VA<14v>qJU(D9NIqPKyxwx@-lJ?C3-#5_r;!;G4
z_RY=9Jmliz+&S7ekIjjeiylKh)xNoDox_Do&R5zu*IpSR7t23;VX<$v?%PE!=It)4
zeY4%IMsjg@a((TajX!jgi>-&-YTs;pHdrpo=5*1%=~^pRE-tx-Yv25GkE6@E<`SuW
zGsmNwTsV&i)4ti|Ky$g6(#=o%X54Fsizg>qYu{{F$ww~!tXo(6=1k|Iaxt|bJ@uLP%^Iuw$VJlN6WTZ3@3)bQ8gJKY-<)c9OLRGh*UZwsd8}1ixft+ZqV`R<
z-}=afPvgnf@B7nC@}>MP;;uz%-)wt3MlSB9g=^mot?O`c^Jo|Co7r!on9!@jw%
zV`sT2T<$mRndQSSz%;(*-Z_;x*eoqb1zDdtX
z-&yA2``S0@Iq5q`Bsb8$NzcijBf%@m8Tw6nPWsLRlYiH~NzX~&+4Ax?+BfMranbM9
zX6>8wob;WKU(VOQNzX~&x#Zn+?VI$RxF|O!UHc|IC;i3oG7GhD(sR;xrfl1yeUqM(
zzSGy~wDwJUPWsLRzrC{PH|aU)J8NHgNBbr{Cw*u523PHy^qlmaUU%DR-=ycH?_7U3
zK>H>=Cw*s3aD?_vdQSSzIKS@NH|aU)J6F%^rhSv1lfLtvasJvj={e~;CqHVdeUqM(
zzH_#}{pXmyZ_;zpcUFojYv?!WIr(0=JHODrNzX~&nYr~V?VI$R^qt;*o3wAzbMoiN
zouB4u-=ycH@4Rp*P5UN2Cw-@1Ub;0iB-KRO-O~SeQJ_Gy_Dy+5lb#b7a~o%9-=ycn#oM5%*3@Yqni+Pt^#5I4
z+Z(NYlb#b7;e8{uZ_;z(qU)MK?VI$RxX6uauYHrA6Bo7bxoY2}=fp+fqVF2|O?pmT
zxc2-*`zAdnF4kq8*1k#4iHq;fex`kso)Z^6&MwlvNzaLkfkI9h^qjcJx_d_ZCOs!E8lL&fqTi(F#KlFw^4d4)IdS1su95al
zdQM!t3hSVKlb#b7J;H;uZ_;z(qV|DE?VI$RxTwE7TKgtFCoUphM`+)q=fuUSdjZ-v
z={a%X8Qo6%COs!Ey!STHzB$G2IdO6NSUE$#NzaLkwfBG5zDduC3#VD%YTu;i#Km@(
zE!sEfIdKtoY@zl|dQMzeE2nDTr02v%*8XYM<^EGlOS@b8|1Pp)duZRJ=fp+R3Q^iO
z={a%HFfvH{COs!E)|Kj@eUqLO7jH5fYTu;i#6{l0cMbg}Jtr>mPX4KVlb#b7(Jp7S
zZ_;z(BE#zo?VI$RxG3wjMEfQ^CoZykPj~E_cF&26R)-w>COs!E9!4+KzDduCi}inO
z)xJs3iHn%S#7Al0r02v%fssA5Z_;z(qS>xU?VI$RxY+nnp!Q9APF$2N(_Z@~Jtr<|
z#JXzVr02xNmI?0|`b~OHTtr2@(!NR0iHn?9-)Y~Z=fuU(1)pi(r02v%@fM4-=fp+I
z@ebNI={a$6DZG*PO?pmT97rs0=r`#(ak0$vZ|$4(oVX~x;0NuS^qjbOf9qE5oAjKx
zIQhj=?VI$RxQP3DhW1T*PFy@`G*kN~JtrY4G>jH|aTXQRD6B+BfMradCLf678GxoVeIIW`_1ndQM!p
zESqV)emTjEkIe5P?pjanoAjKxxR=)5&~MUn;^OAfVC|dqoVbXd*iri?Jtr=*oEmH2
zr02xNvPKmQ{U$vpE`m3_*1k#4iHj3&&T8MJ=fuS?Rkv&3r02xNe
ztf+mHo)Z_Do=qJ4rrmSmVy4~s(r?mp;^J{qSM8hhoVdu!>#lv1o)Z_@mwRg8r02xN
z{aW3%Z_;z(qF0Gv?VI$RxHvhfgZ52&PF$S7+EDu@Jtr>uKX}*BZ_;z(;&`*av~SXL
z;==96Guk)lIdO3=W~=s1dQMyjzDduCi_3*(Td%Swn(?>t@8AK8
zduiXK=fp)$+i2~Z^qjajIKHd)O?pmTv~=&JeUqLO7xQx(Yu}{j#6`lb3Wk1@o)Z^^
z%e~dUNzaLkNipZNZ_;z(V)M)$+BfMrap5_0h4xK)PF!67WVZHAdQM#QEILQ~COs!E
zO08e1eUqLO7rC8xY2T#h#Kru~3)(m7IdM^V`o9+aCOs!EOr1*FH|aTXk@TdA_Dy?p*Dg^qjaTH)gK(O?pmT950ipeUqLO7b)Aav~SXL
z;=@h7`Wv-?eYPFy@%>!p2@
zo)Z_oP3`ki_P$Bai3`_|N``)uo)Z@dPyW@uNzaLkN4^)eZ_;z(qT=8z?VI$RxL7|Z
zQ~M@8CoTe0=V{-h=fuUSTaJB`o)Z@nQ$EtZNzaK3kLr80Z_;z(;!V)xJs3iHqE*&e}KWIdM_@o}2bfdQMyv
zF8ZFK-=ycn#nLYd82U|mPF$?Zx}<%Ro)Z_}o!zZ{lb#b7J36Bif#Dr?`Q
z=fs6mxn|lo={a%nD$HB^COs!EdW45*-=ycnMePGUwQtgM;-dcUKH4|wIdKv3x`*~n
zdQM!Nx)-8-lb#b7p3z>~H|aTX;k~zs_Dy+wbK+v{{eQG?(sSa%Y1Rep
zoAjKx*zS_0eUqLO7h%UTwQtgM;=)=vU;8FKCoZz~FR=QYPBH~u^Sj87?W=v0o)Z^M
zEA-O7NzaLkhLK^~H|aTXv96T2_DyuQl2?={a$c-Fu<-O?pmTv^wP2H|aTX@i2O=_DybuW8?;=fuV6wS}~A(sSZs*W1e4H|aTXarb01?VI$RxHwzfNBbr{
zCoVjk!nJSGbK>Gnd@t>r^qjaTFtV@qO?pmTG~3lv`zAdnE;fD?s(q856BlL6bk@E}
z&xwl~v2NNo={a$+WkMyx`E+_tTzuZBfZ=>PJtr=5UR~6_NzaLkp$m3v-=ycnMe!D^
zwQtgM;^Ob33$<_3bK)Y#b&+-U)3Ii3=lp%=;(GnGZ_;z(!s**y+BfMraZ#jXxb{tY
zPF$oM_tw5i&xwmm;mx#f(sSbCKw@RX`E+_tTm)4uWH_Hr&xwoD3$AM4r02xN`&+ZM
zZ_;z(;^Y@=wQtgM;v(+rMd~-{IdSo%(PGE>bi3!o#mBiHYu}{j#6^!G`?YVo(QCNzaLk
zsV&|!oKL6c#KrVm1q|oY={a$cH2AXiO?pmT)Ofo``zAdnE)K6*qkWT}6Bj$jEYiM7
z&xs3{Ws9v>bH>;wEA#i@ao753-=ycn#l5uN+BfMradGo#xb{tYPFzG!^wGXa&xwmH
zr{>x>={a$+tWg!i`E+_tTr@aa$Z$TLo)Z@*-dxkZNzaLkU#jlczDduCi^)~jY2T#h
z#6|r!OSEs&bK+vdiY3}N={a!`<*{D-COs!E%APx*eUqLO7nj`fv~SXL;-W#RBHB0U
zIdL&!WL52(^qjcJ^lYJhlb#b7Gwsfo^Xc@QxOkk@P5UN2CoZz`dTZaL=fp+!<$l^X
z={a$6zgBPUoAjKx=v5+I`zAdnE>2GJ*1k#4iHq}Bn`+;r=fp+-2k#lqr_*!dV!(=m
zhV$w4oValN@rw3MdQM!Ni^>+N-=ycn#qitqK5Xxs^qjc3TxhAaCOy&Q
zdgMPp60o?x_DyU8tO?pmT
zBt2=NeUqLO7lZB2_m+J=ot_gHxo#2KH|aTXF@ISf?VI$RxLCZPzxGXfPFx(h*jxK1
zJtr<&9SPUINzaLk4K01NZ_;z(V*8?I+BfMranXKlWyAS&dQMz)wC|nad^$ZRF7A3=
z)4oa1iHq5n_i5jx=fp*m3G1|P(sSaXX6RDwoAjKxSP;L=8a3Cx-=j?a^Jet}KG42N
z&xwo7=Y6zq(sSZsdGiSEoAjKxDD%+LzDduCixJ5!v~SXL;v#rORm1sodQM#2=v2gT
zKAoNu7cDR6Y2T#h#6`bX2eog~bK>Ih%MIE$={a$+GYhq$lQ8e`zAdnF1-B?Xy2sg
z#KoPT)@$FS=fuT@L(8;p(sSa%FK@YZ_49ah{9^ud%iN>HzDduC3+_?kd^$ZRF1SaD
z^Xc@QxZoZo`b~OHTyT#P`zAdnF1SaD^Xc@QxZoZo&ZpCJ;(~jWIG;|>i3{#g;(R(i
zCoZ^0iSz07oVef~CC;bQbK-(~lsKPG&xwoQ?R%6spH9z-3+_>(-=ycn1@|biZ_;z(
zf_s!WpH9z-3+_?kd^$ZRF1SaDeUqLO7u=)7`E+_tTyT#P=hNvqalt)GoKL6c#0B>#
zaXy`%6Bpd0#QAi3PF!%066e$DIdQ=~O7xrboVef~CH75vPF!%066e$DIdQ=~N}Nxp
z=fnl~C~-cWo)Z_`qr~}idQMz$j}rYRJtr=>M~Quto)Z_`qg3ziX!Gx!;ragEEP0^6
z_DyK?kHq9pH9z-
zi@rN9Yu}{j#Ki^Y?bbK>He{d30obb3x)3|!bw
z`zAdnF3uEcqJ5K|6BpM9yr+GWo)Z_t;|dzir_*!d;(UjT+BfMranbSC7uq-JIdO4s
z{A}%;^qjaT=`mRQCOs!Ee$5NgzDduCiyvJ=tYFX4W@7XF_g_3c;jev@o)Z@XNBU~t
zr02v%zt7ui-=ycn#l=Bx+BfMrapC@=vi41SPFz$dU)XRyot_gH0ZCW1Z_;z(;^>*}
z+BfMraq;ZgTM-q_^qjcxtq`Jplb#b7UKc{NZ_;z(;?q(w+BfMraZzg1
zeC?a`oVd8~?@o*J>GYhq2z+wQ;(R(iCoUdD7IE~OcF&26DmANW-=ycnMe+VkwQtgM
z;$lSq4%#>AIdS3sm9O?qdQM!lI_9r^lb#b7gG=~o-=ycn#pM_69Ou*Ro)Z_oLz-yc
zr02v%w-fJa-=ycnMcxku4d>J8IdO5M;3e&w^qjb8^yyaZoAjKxINNcK_DyFU)9E>JaqyIVzoFf4(sSbCs|J;|
zZ_;z(BGRLf;e0whCoaA?c}e>wJtrdFk9A~o3;Cz3MT$vT1eUqLO7ojcuv~SXL;vz8KzIV+&pH9z-i(?I&
zYTu;i#6^wlD%v;cIdM_oa$)V8^qjb;^Y2ydoAjKx$n3dO`zAdnE>4b{uYHrA6BnQU
z9HV`co)Z_ZT*I|*(sSbCmxtlnH|aTX5nbE9kIO!vPS1&pd(9VW-=ycn#nxF_7U$FH
zIdM@Z>4wGmbb3x)oUc+;`zAdnE{fi*s(q856BobLZKi#bo)Z^Q^*U
zH|aTXQD;Vg_DyxbK=6iv77cydQMz;&8n<@lb#b7-z+O+
zIG;|>iHlWpE^FVU=fp*!=G(Pz(sSZs%c;5AH|aTXvFwN8+BfMraWN?+O#3E1CoX#D
zbhApHi#4J92Ig}S`qa^H(sSY>cd?)LO?pmT9Qm<>_DyYRqdPf
zoVZACS48_JJtr>8oVccalb#b7(`)Y1zDduCi(MTSXy2sg#Kn~HqqJ|*bK>I6&Ti^A
z={a%nbz+3}O?pmTH2*A4`zAdnF3$eGQ2Qo5CoVEWc3a%PNzaLkvhDIL?%$;6#6@6A
zQSF=boVb|eTTS~WJtrpdQMzS3$yRNwC~@f
z=fuU&zirpPNzaLk<#Xq0-=ycnMQpDa?VI$RxQHm$P5UN2CoVh#BCXKOSaZKd{`)&~
zLjtvL(sSaX{YyXXoAjKxXw#~r_Dy)%9b-=ycn
zg_C_x8|TyMIdSpj{zck1={a#Rz5E`F`#0%1aq;?}Jd67`={a$6B(JFUO?pmT{JOcC
z_DyEZLWZ_;z(!tdTl?VI$RxNut-p?#B{6Bnh=MOpr7v1U?o|9szz_B$Q@COs!Ec1Cy6
zzDduCi>)gi
zEY`kB&xwn~%X=*D-=ycn#e?HFE$-i>=fp*8?_%0F={a#R%(=SuO?pmTbPsB-eUqLO
z7ui9bv~SXL;v#i-7wwz$oVb{}El~R=Jtr=9**|C8ze&%D3)h<+v~SXL;$lg-W8a)&
z_nf#`zoUxwO?pmTH2t!$_DySCOs!EzT4Vd`zAdnE}CuYq#=fuU|<$|u6>i9
z6Bmbn>S2}I9%~*I>66ceYqEVmj@@t4bK+u6b${)f^qja@)u)s8O?pmTyi?J>kI(Km
z={a#RYGXC+oAjKx2t83$`zAdnE^;n8_DyJ%%keUqLO7m;VGYu}{j#D!0}7TP!IIdSnxIWO&-^qjaDHNju|COs!E
z62>_CO?pmTd~E-masMVgCoal=(oy>+Jtrc);NzaLkc}e!4%h_@2IdM@Xy@>Wr
zdQM!l9_-jR={a$+u6UOAO?pmTbXm7h`zAdnE`CUh*S<;5iHp5WduZRJ=fuT|_+Hir
zFJsNC4!!cZSbI50`zAdnF3NxCuYHrA6BlK3I%(ge=fuT~am}@F(sSbCPSNVxH|aTX
zk=vk{{cr3ObU!C9zF2)z`zAdnF6#ZUNBbr{CoaM&F44Y8&xwoujmB!D{do)Z^^FIUyRNzaLk1^0>=_Dyb!r02xN9Q)^t`#0%1aZzAI
zC+(Z`oVXZut(o>sdQMzSY*$VDCOs!Eigqb#JB`=G{YPR-GdQMz4`gp1KO?pmT#B>~|eUqLO7YCF3YTu;i#KoRA{j_h=bK;`<=n2|4
z={a%HC3?B`O?pmTl-s#q`zAdnE~c%%YjOW3Jtr=vHFeUyNzaLky{~F$-=ycn#V2jt
zwQtgM;-XmF&e}KWIdO4jU4ZsYdQM!N%M8}ONzaLkf_MD2Z_;z(BKg}++BfMradD?(
zbM2e-oVfU9bv5mq^qjaz*;3SS|0X>rE?)1-)4oa1iHjlK_h{dw=fp*+=Zm#((sSaX
zSFI%NoAjKx==^OT?VI$RxTsyGzjZ!kv}v(7GM@|Xb*0~==fnl~x^n*}Jtr=>*Oh*g
zo)Z_`>q@^#&xs4}b!FeA=fnl~x^n*}Jtr=>*OmJ>={a%1y{_E9NzaK3?set*OmJ>={a%1y{_E9NzaK3?set*OmJ>={a%1y{_E9
zNzaK3?setJ!M(2ZoAjKx;9ghuO?pmTaIb5vHu2`pkKOXQNFLZ#
z`zAdnE}l9CYTu;i#KlAZ&e}KWIdRdypu6@>dQMy%Tu?*%COs!E*6t{7bpNK^bK;`!
zjyu{n={a$6!Fj*-O?pmT6l}Cy`zAdnE~36Bm6O+_$)Ylb#b72``+qZ_;z(V#C3j+BfMr
zak1!)yY@|bPF%crjSE5-=ycn#qhXdhWj_^IdO5m!!7Nb^qjcpcx$irO?pmT+#A1C
z`zAdnE=qcg*S<;5iHl$J`fJ~$=fuU2E(5Iu=LEC=NmxD?Pfv8!zDduCi-98pwQtgM
z;-cT@oz-vBbK>ISAb0JX^qjbGe^EpGCOs!EDwKCJye~x0iHm@wyV^JDIdO6H%zo{g
z^qjbO_H4QKO?pmT{C;(k_Dy36Bk|=25R4==fuUQrKV`#r02v%sZA@j
zZ_;z(;=;cNwQtgM;v(?LeT(;n=s9umAhLw^O?pmTRH<1@`zAdnE{gYWseO~46Bi@;
zJMQ14=fs8kSAp6$={a%H>R4CpoAjKx7+fMy`zAdnE-t_DQol*hi3{H$EwpdabK;`g
ziR#)n={a$c_d_wmzDduCiz5YZYu}{j#6_b|v$b#1bK>G`$7R|#={a$6_s|6GoAjKx
z_-6P3?VI$RxcKbzLDsh46U?)Mq4``4bPdtINzaLk%cldiZ_;z(;(Tdu?VI$RxClP!
zu6>i96BnB!Yii%5=fuVABqziBLiC)tXw~kX_DyH>=CoZn;TA_WDo)Z_}
z4W6uhlb#b7HP#MtoKLrVPFz&(KUn)FJtr=vEdNmZCOs!E{fr_*!dqHlHy?VI$RxLA-{OZz50CoU!*ZK-{eo)Z^0k9uq0r02xN=EnB>
z0rvYs^qjc(qF#vhO?pmTT(y7BxPOzL6Bi+NPtW;udQMy%Jk>(`COs!EzG_fI`zAdn
zE+RdO8{QY9=fuSqCvR)tr02v%rI>x%H|aTX@nxaq+BfMradF6RlJ-q{PF!qwF;M#^
zJtrxTL?VI$RxCm_#q%c(sSZs>n!^oXnWtJ=fp*w
zqz4x73(<4p;(Qfn?VI$RxF~wJmiA40PF(y}*F*azJtr=r>N)P;r02v%&3QrEH|aTX
zQD;Vo_Dy36BoJJoz-vBbK=6ivAgz7dQMz;&8ne&lb#b7-z+O`cwdN~
z6Bn!I+|j;C&xwmd&G&2Hr02xNmQ%~MZ_;z(V%ZNV+BfMraWN@nu=Y)QPF(cP8EQop
z8Ed*V3Ciap^l6CpO?pmTIb#9`Vu={a#xIV#PuZ`wU4F3$d*seO~46Bn5whqZ6gbK;_GyN4F<3(<4pA~3~S
z`zAdnE++Zb*1k#4iHi}_J+yDqbK>IL=|0*w={a$+^tof-r02xN%10sEH|aTXank-d
zIqg7~U76
z=fuU|Gk?;)NzaLkF?SDX-=ycn#m5CRwQtgM;-X2xH0_)8oVeKBez^8cdQM!de=}VB
zCOs!EirDu?b3UD(6Bl3Z|492LJtr=vm(S6@NzaLk*Z(}UcwdN~6BkGFoV9P#bK>IH
z&9${}(sSbC=0gwdoAjKx@V0wQ-WQ_h#D#OWVD+2yoVX|x9IAbjo)Z@xmIP_vr02xN
z&J=I$oAjKxsPWwXb2&Rs_jBT+Nk~oYoAjKx$nNE2cwdN~6Bh{;?`hwp=fuUeeFwB}
z(sSY>J$$A1O?pmT_}%+Z`zAdnF5DIl)4oa1iHp+bMp&i8lg+&zUGlkTzcW<(COs!E
zc18zl-=ycn#nu%*+BfMraZxYKL;EH@CoWR+YH8o3=fuUJe@htpO?pmTJm~sB`zAdn
zF8crHOYNKVoVb{CGE@5|Jtr>SJDH|^lb#b7sktMxZ_;z(V&e1|?VI$RxcJ4s2Z(-?
zo)Z^armWV!NzaLk#LGF_H|aTX@!+_9FRI;d(sSaXwRcJFoAjKx80PGveUqLO7u|zA
zwQtgM;vzf9(!NR0iHp?Tj`QjCoVb{}EmZp^Jtr=9**|CWoAjKxaJ}iReUqLO7fZrh
zYTu;i#Krm@HMMWjbK;`umrjO$lb#b7{jT2AzDduCi&K*iYTu;i#6?`CRoXY{IdM@o
zJ5~E8Jtr=TRvn>zlb#b7_xp^rO3oN(g2(#ibFs60nD$M2PF%Pi2-dzy&xwn=fBR_P
zr02xNN4q?Zgv~SXL;^M}}4DFlroVZwStqCADwTbK>I0C>QOU^qjc(ZmXyEO?pmTG}~rr-=ycn
z#j8qP)o;>s;^OaeVcIw8IdRdjN3ixydQM!d@%GWaNzaK3=iM!}Z_;z(;=S^I0#u)9J
z^qjaj{L?6_&vE;HjipvT7p}=++BfMraj~X)SM8hhoVZxk$I`w@&xwn7Dtc<)r02xN
zsExI?Z_;z(BJ_l_;e8=`PF&<%dZ>Mqo)Z^sTjprrr02xN;IP%&H|aTX@k?li_DyIb;ZwD5(sSY>-D$1%O?pmT1Pwf@eUqLO7e~52u{fX3
z{hYX{Q>>KsO?pmTM4oZczDduC3!ieWv~SXL;^LEXM*Ai`CoV=!aGX!4=fp+Am@w^|
z^qjc(*#0@={!Q-Z#6|f}e6(-UbK)Ykl85$9dQM!-ORA-Plb#b7Rnkis-WQ_h#6|1D
zKWX2j=fuUj;$Ldtr02v%mv#1^%l7$ndQMzC?3}KBlb#b7dz+5ZzDduCixu&4*5yJI
zP0RuBd@j~rw$I_(`zAdnF3Nw{Rr@AACoan7SlTz~IdLoTv6pdQM#2De9tqlb#b7
zxeZDh_Dy0Dz4GKNzaLk>lLPI-=ycnh4Y{|?VI$R
zxF}L@wDwJUPF(D1I!*f~Jtr>WPOsIzNzaLkHm=9CZ_;z(;){2mTI`$joVZvu+`iA;
z-Z$wvaq*3N9qpU+oVeJRXb%VWzDduCi>hOc_Dy|&PoO?pmTyc+MJeUqLO7lkj^(!NR0iHimIN*MM{dQM!_ZS+9HB(G2aI^qjbupB<}xlb#b7qyHXl1^G-e=?}c}xv0=S
zT>B%iwQtgM;-cqp7wwz$oVaK|tEAz5A$m?+
ztQ_!I`zAdnF3Q>e)W!Qk^qjcpaCMFLO?pmTe0^oA_Dyzv~SXL;$r2crxx!E(R1RW?6;+~Z_;z(qVv)^
z+BfMranbBTEA^Z7oVZwc!D!#4=fp+NPLA{G^qjcp+csSLCOs!E=GZ@H+`mcBiHiaw
zEbW{0oVXZu%|rVpJtrv$W={a#Rrb8?3oAjKxIP;B*_DyG)a)S0vdQM#IX_KgZlb#b7^RuUG-=ycn
zMVIJx+BfMraZzsPaqXM*oVb{_`kA$|$Hla!rL}L;bK+v}t2){@={a%nNt@Q{
zH|aTXQLL@6_Dy*Zlb#b7cRG4%
z-=ycn#V@OCYu}{j#6`*$XT$sH^qjbOz3ZX&O?pmT4C$VueUqLO7p0!B*1k#4iHofZ
zrfT1$=fp+lZ{xLZ(sSaXc9}8OfZdbL%HAFFx!|1%-WQ_h#0Bq6aQ`MfCoU3yGTJxk
zIdQ=|6P!<{=fnl?Ot5d#bK=4^q?FnD#0Bq6uy4|H;(~W3
zct4$<6BoQQ!Tag-oVehf3HnXmTgL_OOz?g>Jtr=BXM%l`o)Z_mGr{}m^qjchoeB0$
zdQM#M&IJ1=Jtr=BXM*$T^qjchoe9pT({tj2cP7|3={a%1I}^MwM9+x}-kIQiA$m?+
z@Xo|k`~FROPF(QL1n1M~IdQ=|6ZD((oVehf3HD8TPF(QL#Iajb%(YM2<#Uldu$%Tx
zdQMzCbqdwKNzaLkhyK3WH|aTX(Z678?VI$RxH!0=j`mG@PF$?rQOfXsIz1;Y`tEqD
zeUqLO7Z;q5Yu}{j#6`hI>$GpubK+ut!x`E)={a#RYW7&|oAjKx7#ftMeUqLO7el>g
zYTu;i#6_*!>$PvvbK>G*yIk#?^qjcp+u*sy`E+_tTqL|Gt$mZ86Bio}*44gA&xwmg
zZ(6J0r02xNi#NX7H|aTXad1ee_DyIq02l3>^qjaD9#_)vemXrTF3xv&tbLQ76BiwC9nro?&xwnB~Kb@Wv7XeAnv~SXL;^OF;$THeD={a#xrDi?toAjKxDBiz~_DyuP}1;zIz1;Yjud>NeUqLO7mYqWs(q856BlPYeyn|yo)Z^|dDFFT(sSbC
zo8d{?H|aTX@!99&tjw9Irfcsu`CJTijnKYH&xwo6r|oy}?0%D;6Bp-8`)S{#=fp+u
z$=2F8={a$+IkK+yO?pmT%uXt8ct4$<6Bn)8{j7bHo)Z__mY&eQNzaLktGgWU3(<4p
zV(2dWK1uuibb3x))L3i(-Ozqth@KM{mHUs^zDduC3$JfyY2T#h#KoWXefsp9^qjaj
zdg7$^O?pmTwBGTH#rbr4PF(cOE~9;uo)Z@fQtN5or02xNrO8X}FbK>Hw26eP=
z(sSY>(xa5&{d9UxTzqlziS|u;PFz%qIi`J+o)Z^e7Fwr$lb#b7{Wi|fzDduCiw!T5
zwQtgM;v%D!{j**?&Hnpft9&l5%!<&yNzaLk&=&T)H1_#)dQMyf#`|gCr02xNv4(B5
zZ_;z(qDFRI?VH@siHic4OB>Fo({tjY&c8ov-=ycnMP|=j?VI$RxHvh^@xBl}CoZ~;
zo27k|o)Z_ZTqkJXr02xNFApbZ-=ycnMU#cIwQtgM;^JQOPqc5+bK+v_tW(-I={a#x
zC&~VW(e5{SZygustCZEgNzaLkqIc_Q-=ycn#cy@nYTu;i#6?uSF6uYwIdM^QUYPbx
zdQM!_nGvCVlb#b7Vc&;p-=ycnMQ*mQ_DyS*7j=fuS~%Ssu}
zr_*!dV%40d+BfMraZ#xGaqXM*oVeI>YMu5?dQMyf4xg!glb#b7lTyZO-=ycnMem%6
z*0Gc{v(n!)pNr6^5!yHDIdPF|zmLfMoAjKxIP#;P_Dy36Bl16PSUIfnZIb?r02xNn7b#nZ_;z(;^Ts!Xy2sg#6^>WbF^>LbK+ui`xNb)^qja@
z|0YHICOs!EirDYia6X-$6Bl3Z|5W=XJtr=vm;YM(COs!EUjOsL;{9}bPFx(xE318z
zo)Z_pZmzF=lb#b7Hy^guzDduC3-3o=)Nj&r;=;LGxb{tYPF$1;j?}(M&xwl;OTx5o
z(sSZsXNsTpO?pmT)Og-n`zAdnE}DeY)xJs3iHq!Br48?=({th?q2kZlH|aTXac!Ud
z=d!(T(sSY>J>0%mW__IATgOG+7qhf)(sSa%ZQ&&CoAjKxD1C0SRm(~@nPc7axoE#L
zQu`)7CoXnIhil)Y=fuU<6OIdSoz
z>#y23={a%H|39a+Z_;z(V$R7=v~SXL;^MuNbF^>LbK)X3ce3_PdQMzSoIXYSCOs!E
zezET*rQf9I#Ko2=o3wAzbK)ZL^4Hoo={a%n;P`JA`zAdnE?Rq+)4oa1iHl**4YY64
zbK;_V(EHjq={a$c9ptZllb#b7sk_6qZ_;z(V(PX??VI$RxY%X?oY8O6bK=7Frl0mr
zdQMy{32&o)lb#b7>vz=EzDduCi>6ac(O?pna
zXxZsQ3%^Ov2^aqwJ=@C9OE)PVy_;}R?h+%vNzVxvp1UIDH|aUyqTBr;@|*OWaPdin
zzx*aWCtNgyydb~HeNMPI`?`~9u=msHIpN||yL$OedQP|qd*_h+COs!yOwU>;zet
z7cH`~^%eO|dQQ0TD|=P_COs!yG`K~{
zZ_;zZ#eL@(`AvFGxbTX%cfsuYoAjJ;Q8aLf{3bmoT(qg|C%;M02^Sq)Jmfd&IpL!8
zxu*^L>GYg%5n6m(ev_UPF18OkAiqh^2^WcVrShBfoN)1QW|sUWJttgTD7Cvs+i%iy
z!bNgwnsxA0y2<_@zb0IGPK%M>r00Z-qGzJyH|aUyV&&*pNPPq8yOuhUjJttiFzj#=Flb#bU#zm}`-=yb+i#y?qPPlM8o+iIZ&j}ZYQx?l_(sRN^`V$-FH|aUyB5dqY`AvFGxY#rN4{PgB
z?;Cnfxaiudt^6iECtO5VcbDI!=Y)$v&H?h9^qg?9)_JJ>COs!yOng5|ev_UPE+(hO
z$Zyhf!bP$DopFDYo)a!y)((;1r00Z-G&g_wO?pnaSTxl`ev_UPF5J_fHteUD^`AvFGxNw;rCBI3}2^UV^yehv*&j}avr@tbmjoN&>+)F_M=t;5Bmv#-c+(sRN^LP~e}O?pna2wdoFKFgSD=sDqH
z#p}PzZ_;zZh4WWOJttgDZm@Um?0%D;6E1GW+WThq
z`*eCvxL8x4Bfm+{2^WcZo8&j?IpLz)jbrkg^qg?9;!J}zFJ-!+=Y$KVqiy9k={eyd
zq`*^tlb#bU{AvQ^H|aUyVoA+V`AvFGxJc+fQht-36E2ecW92vLIpHG9{?53+NzVxv
z%_h7mzet7ZdCJgIpN~L+@bQD^qg>!8WbSENzVxv)knI^Z_;zZMfoqzX48&ohMp5HKJNa9{3bmo
zTs%rUD!)n32^ZeQ8|63YIpJc*>Kyq^dQP}V+MO=HNzVxvb)Dxn`c1p%gp0*hOXN4{
zIpJbR+-CVrdQP}-E!;oDArlb#bUzG(22-=yb+i?#g%
zg|YIR^qg?va3xB9lb#bUrX77%ev_UPF0KT>
zBELz`2^V)(y&%6y&j}Z^HaQvg)9E?k;z32d{3bmoT#S!BEWb(52^UY@UN65%&j}Y>
za~8{Q(sRN^$@p~nO?pnaaGpQc8e;!WZP?+{gbQ}>@SF6Ua4~KDNcl~APPkzAj{BSR
zoN#e{b%6XPJttfY_VtwCr00YScJJEWnr`Sh;bQVHf5>mrbHc^-!N=5Z(sRNEyLb3a
zdQP}t_YS{F&j}ao-qCN;bHW9?cf3!h=Y$J(@9>-SoN)2dGoQ(C(sRPa!>K3aH|aUy
zV)E*cYPbgo{~)q4JyboNzI?d9?f{
zJttfYeH1IdNzVxv)9vq!ev_UPE^hb^mEYt(CtSprydu9z&j}ZAK7K)dlb#bU>i@&p
zu%AxP2^ZI<|0cgl&j}ZiosYTPWe_c!S|;UZ~UgZw5vCtTFDIj(+_o)az{yf@2l(sRPaVy|5JO?pna=sWmh`AvFG
zxY)k#WBE;bPPlk;V4nOYJttf|clk5)Xk1
z(sRPar@Ou6H|aUyBG&j}a1$4AR=(sRPaH_37GoAjJ;ant_J
zxW7ry2^V9R43*!c=Y)%D#{l_FdQP}F|9W@%O?pnaNSWkpc%M$s2^Xh>ewW{*=Y)&k
zACJgy(sRPa&oeg4Z_;zZMTYwl`AvFGxR`N!uKXrFCtO^2&ai4+=bHIlnsRYtU!43V
zJttg@eLGrylb#bU#(Wkkzet7eBlaD8EV12^W3pJ>@s)IpLzcOFM(#r00Z-p;Pb5
zZ_;zZ#kbYR)o;>s!o|&-o8>p@IpN}io4JkmH|?GiF1ohKkl&=|gp1<0GUPYuIpJbt
zt9Z_;zZ#rVpPZr)GzYV!ADtY^IZCOs!y)Exy)NzVxv=PEvv-=yb+i#IFspKRzk;UcNZMShc>6E1SnyyZ9PIpN|%`z~YE
zs5C>*2^W{XweMTlzsK~PaIwKBMt+l?6E3!R#LI8ebHc?r`#aQH=a1JttgrogXj1NzVxv5htSMH|aUyVt-Yr{3bmoT=ek?l;5Q1go^@s)IpN~S(zb^8>GYg%u`;Vcev_UPE*yIum*1r4go{lFH_LC*bHYVvN}h$^r00Z-
z;QsUEH|aUyqAYp7_4c@QlRmyF7vVSJ>KRm*1r4go^|AcgFoqdQP|q>=Y)yNzVxv8?poCH|aUyV#hC@@|*OWa1nB^
ztzkc%o)a!IBJRp>(sRPat>2EzZ_;zZ#j@c^Pev_UPF8aMZOn#G|6E4bb2FY*IbHYWNS>E!S
z^qg>UW0k#M95UO`bHc^_1^4AQ={eyd^{12aoAjJ;QS4AAzet7d;%7%5Tzh!o}$>
z3*!M`6`9VfnP
z=sDqH&&BrgoAjJ;@#_X3`AvFGxVUu9zSn8r-=yb+i-FgN$#2qg!bO{?SouwQPPlk_
zSc3c}JttfQ<;Tcx(sRN^`K&PcO?pna=yE$yev_UPE_#gclHa7~go~=gc82|QdQP~Q
z?0Qdrlb#bU>UN%x-=yb+i}c7X@|*OWaM7(kUw)IG6E55i&6nS#=Y)%S*B4lR8|N5*
zyIcC}-g01hg8U{uCtQ@r#maBebHc^e<-_DR={e!TBO+LSlb#bU(k^<-Z_;zZ#T$=Z
z4E-iOCtO?|{y=_{o)a#TH-0C-NzVxvSqIAGH|aUyqQilu@|*OWaIxpg0{KmPPPhoR
z7RqnZbHc?PdnbW@lb#bUHhr{Bev_UPE~eC;lHa7~go~^Hd}PsY(sRN^-+``;e$(zb
z;o=`{ddP3mbHYWeeV37blb#bUs=|iLZ_;zZMOtO7{3bmoTzp)XAiqh^2^SUicSgTS
z&j}Zvm%`*X={eydKQc&ubFJNT!o`|xUh|+&j}YDT)gEs={ez|^f?#9z7RbpT!a??DZfe22^ZT3os{3C=Y)&I
zx~=k?^qg?`jn|99$7vmzf$#2qg!o{8N@^KoN%#nVy65iJttha9nX~Cr00Z-!znA|H|aUy
zBK?U9`AvFGxCk3tBfm+{2^V{YKeoJXzGLV);i79RH~CF^PPmA!?jgTP&j}ZU?7NJ!
zzDYOqoN%$$d4&8XJttgDd_PWplb#bUCZ{IKZ_;zZMX~*zu`fi=2^TJFhskf!bHYWM
zTd@2lJttf&n(8gTNzVxv?&&Uu{d9Uxxad3XzWgRVCtQ@Y{!V_Ao)a#Hl$6PD(sRPa
zwGeyP$L=@jIpLztEmMAzo)a#@{IjfO{nE@AcDM9bE>_nj%5Tzh!iCH1IQdO_PPlOT
zX1M$&JtthupWa`7lb#bUuC%njFWYa@bHc^`?yjb=WR9Wdgo`b!9?5UgbHatk-Ba?L
z^qg=J;aV=gY1us|T%2#eLVlB;6D~6PWXW&RbHasVUY7hOJtti3@LegtNzVxvla5!&
zZ_;zZML*9U{rN45~K5&%$COs!yWL}Pw-=yb+i;U+-$Zyhf!o{Pr
z{pC05IpHE9rHA|`JttfQE_5~Q3(<4J#fsPeEx$?62^Y>^otEFE=Y)%(bLH}z^qg>U
z`0NV#O?pnanB0&hzet7q?=wbH|?Gi
zE{?t3On#G|6E2*Ny2)?SbHYVPK~MQjdQQ0Tv+pvpFGSA?7fWhJ$Zyhf!bL*=c==6w
zPPj<&A0@v@&j}Y<_IJkpO?pnaXf|QE{3bmoTuiJBmfxi3go_UXeB?LjIpLz^koJas
zA$m@@aJuzCev_UPE@tF@FTY982^Y`#Y?I%l=Y)$rN0!TP(sRN^Wl5I&COs!yc1JJttf|m^(s#lb#bUQiBG_Z_;zZMfH&$@|*OW
za8dq?tJyRrT|FmUeBAx9{3bmoTs%svk>8}}gbVND3i(ZXPPo{ydZqj(Jttfw?ar3p
zr00Z-y3UK_H|aUyVsTZW{3bmoTnve;l;5Q1gbU~LGxD4CoNzJdL^JtKdQP~=@a-VK
zNzVxvUo`ZT-=yb+i?#hi@^qg?va3xND
zlb#bUrX984Iot2k={e!zN^pPqO?pnaxUJR?`UrwF4*CIYVUs;dQP}tha101&j}aoa6hvB
zCOs!yu*1#!bb3y>7;Nuwv!71S2^Z{e<2UI!;es7*?r+j_!Ua3ryicd+gbQ}K={M;)
z;es7*{3bmoT(HB9-=yb+3wF5aH|aUyf*o$|Z*rd#F4*DbeL6iST(HAUzet7wmB3
zH|aUyf*tPtduE!ucDM9bE~bqgEx$?62^Tk>NRZ#8=Y)%EuiD>-{XU(Z6E2b+LgY8;
zIpJb=PEYwwdQP}lz0J-1@4wP@pA#QUZ_;zZ
z#W%^LG4Z_;zZ
zMSGVHrmxFfL(d5p>vlCWt3IA%=sDry+v+p&oAjJ;ar0)S{3bmoTzqiz6ZuVgPPpjW
zCP#jgo)a#L?VT#_Z_;zZ#mH7g@|*OWaPidoFXT7rIpLz_@mcvzdQP}l_lJZ0COs!y
zT#fE1zet7xusRmk2M&j}Y7Pr4fR)9E?kVvoaP
z`AvFGxbR+ABfm+{2^Zf5ZN
zD*PrrCtM6TS|qA=Y)%M_IJkp
zO?pna7!eaLzet7rPG*kl&=|go{Jnd&+OpbHYXROKygKlb#bUwj6jYzet7jAF;
zAiqh^2^U{GR?2VEbHc@#(of_!={eyd^8Fn7O?pna__lC~mC|B{S>Lkh|FfT6m?XbR
z&j}ady%Xg(={eyd^t}lAO?pna*y}Y=ev_UPF1l3t%5Tzh!bP*%4u<#X^qg>!JKMqV
zKAoNuE(#OQ%5Tzh!o`8wh?-s`zet7Z386$Zyhf!o>`GrwYGG&j}Yj
zmaLZFr00Z-pL^|;-=yb+i>(Xo-EZ4((sRN^?k-39O?pnaIPLB(zet7cGDCli#H0
zgp1$04U*ra=Y)$Gk4X7VdQP}_c2T1ICOs!ybe*3hzet7ZE2C{i_v!SUaB*yKGsF9IdQP}->~%(dlb#bUHXW>#
z-=yb+i_nx+@|*OWa1q>piTox#CtQ>z=UN#@r*pF2;VJPR|J!{#gU%H|aUyqQKoxev_UPE~W)^H26(=PPk}lZx(Ni+1Ea53-M
zbMl+?oN!SQR3*Pj&j}X;f>z6K(sRPa)%aZbO?pna7-#QPaetGZ6D~T&6w7bYbHc@U
zzweaar00Z-!V%}?H|aUyqIH+%@|*OWa1lDoU4E0E6E0>B?j^rT&j}Y3?7NIVe3WkJ
zIpN~yyh!;?dQP|~xZUVC={ezI#q}ilO?pnaIADKg+~1_g
zIpL!0=0N#PdQP}#Gs{nYlb#bUZmjBPe(;%V=sDryL*r=j{`knybHYXHPv_(}={ez|
z*kPyqCOs!y^l&Ja-=yb+i_=~58vUl-bHYWWy;H^gO?pnaXwj)eev_UPF24TqOZiQD
zPPmxoazTERo)a!UoZVc0lb#bU_FQzA-=yb+i(fbNlHa7~go{hp8u!!bIpJd9^+@?m
zdQP}#6E#YHlb#bUo*p(vev_UPE`stC~pO?pna=sU2J{3bmoT>PU=Z~0AnPPmA*?=rq|bdI6tgo~=MDEUo#PPj;`
z93{U=&j}YFmyMC%r00Z-3i~_b{w6&qTzFoJkl&=|gp2&hf%2R5oN%#bo3H#PJttiF
ze%-;apH9yS7rowbFzgG_bHc^J56{YP(sRPaB)2O0O?pnaaH?7@zet7mkbadQP}lX6=^Wr00Z-F+~?G_S5M(;o>dRLVlB;
z6E3!tc9P$u=Y)$36MM^V(sRPav8|T;COs!y_?1P;Z_;zZMT6UD`AvFGxVZ0}EWb(5
z2^U`RqvSW~IpLycV5IyeJtthWsT?T3NzVxv9bEk6H|aUyqV&0rhW&JUPPkZC?O@m!
zqUVH*?SszAZ_;zZMPgl*{3bmoTs)jvEWb(52^Y5`^W`_`IpHEXwZN*5n_@m++VowK
z=d@(`O?pnaD0*hJ{3bmoT&x@&CBI3}2^Vc$E%{A)PPmv@+Dm?uo)a#@_qm(R%hL@#
zCtUpac5`zwex{-4gbV){FUW7wbHc^Ah+U2MH|?GiF7AY{k>8}}go~9E3*s8G25*SnE7eev_UPE+)P|T7HwB6D}sFCd+TqbHYWj
z{hi@A={e!TWo@MVCOs!yq`3`}-=yb+i$zoYUrDbpVO?pna*x$XA!Ee%Y!bR+f
z<_5n>&j}YEcQ43q(sRN^gzIkkO?pnaIN$zL`AvFGxX9?URDP456D}O>ohtk$Jtti3
z@GX_!r00Z-Nyoox>^JS66E6CB{%GMh={fm(5&L9I`AvFGxLBFeS$>nA6E2SQ=_9{M
z&j}Yhrx^K7dQQ0LlsZy=lb#bUeySVY_&(k4IpN}JO|twZJtthab{Q?dNzVxvnXRJa
zH|aUyqG84$`AvFGxM*H$|1R5plb#bUa(?b;@SF6UaFJW!Xz-i#oN$qStWJKDo)a!!
z8vmvICOs!yoNl>Bev_UPE)Lce$Zyhf!iE2%rPgZ;-ZOrG_G;og+XucTzet7nzqw
z%Wu+i!bQgOBjq>gIpN~bSxbJCo)az-QhLj8(sRN^;KEMwoAjJ;ajbU>!@dwbCtNsx
zby0qko)a#D&h3`pr00Z-!)HI0-=yb+i^&a3oNlb#bUoQ`&u-=yb+i;#jo@|*OWaN%d)Wn^E7
zo)a#X)Qpthr00Z-g#JnLoAjJ;k>vlH{3bmoTx8kb8TU8oIpLz&gedt=;UGkgsoN%${
z$ePA})9yLpqOxSE{3bmoTzKqVW?la0yXMi~o8CFdEP73Tlb#bUnwcc|O?pnacrbUQ
z{3bmoT%-mW`AvFGxTrqTTYi(C6E4bs=_J2N&j}ZU@>>}8)9E?k;!)Zq`AvFGxbQCi
zN`8}`6E1eFE|uS;=Y)%--OJ=R={ez|uJdyFO?pnaSX{MMev_UPE{4SYxBMnOCtNs}
zU$&lod6IfgxEOSzrTiv6CtPIsJ|n+L&j}Y_H1v_*r00Z-wfzRmZ_;zZMJxYk`AvFG
zxTr2klHa7~go{&!ugP!HbHatgmC^E>^qg=p?P!$zCOs!yTnV=1H|aUy;?AmG@|*OW
za4~C>yJ0__o)a!UN@#A_7oz8ci}A4+
- adaptFrame(packedFrames, frameIndex * header.bytesPerFrame),
+ adaptFrame(packedFrames, frameIndex * header.bytesPerFrame, schema),
),
};
}
-function adaptFrame(bytes: Uint8Array, offset: number): PoseAnalysisFrame {
- const frame = decodeFrame(bytes, offset);
+function adaptFrame(
+ bytes: Uint8Array,
+ offset: number,
+ schema: number,
+): PoseAnalysisFrame {
+ const frame = decodeFrame(bytes, offset, schema);
return {
timestampMs: frame.timestampMs,
- keypoints: keypointTripletsToPosePoints(frame.keypoints),
+ keypoints:
+ schema === KEYPOINT_SCHEMA_V2
+ ? keypointQuadsToPosePoints(frame.keypoints)
+ : keypointTripletsToPosePoints(frame.keypoints),
qualityFlags: frame.qualityFlags,
};
}
@@ -80,3 +100,16 @@ export function keypointTripletsToPosePoints(keypoints: Float32Array): PosePoint
}
return points;
}
+
+export function keypointQuadsToPosePoints(keypoints: Float32Array): PosePoint[] {
+ const points: PosePoint[] = [];
+ for (let i = 0; i < keypoints.length; i += 4) {
+ points.push({
+ x: keypoints[i],
+ y: keypoints[i + 1],
+ z: keypoints[i + 2],
+ confidence: keypoints[i + 3],
+ });
+ }
+ return points;
+}
diff --git a/src/lib/mocap/analysis/postureFaultDetector.ts b/src/lib/mocap/analysis/postureFaultDetector.ts
index 405cea9..e448476 100644
--- a/src/lib/mocap/analysis/postureFaultDetector.ts
+++ b/src/lib/mocap/analysis/postureFaultDetector.ts
@@ -1,5 +1,5 @@
import { postureThresholdsV1, type PostureThresholdBands } from "./postureThresholds";
-import type { PostureFault, PostureMetrics } from "./types";
+import type { PostureFault, PostureMetrics, Sidecar3DMetrics } from "./types";
export function PostureFaultDetector(
metrics: PostureMetrics,
@@ -156,5 +156,70 @@ export function PostureFaultDetector(
});
}
+ // Sidecar-3D fault stubs — thresholds defined in follow-up issues
+ if (metrics.sidecar3D) {
+ faults.push(...detectSidecar3DFaults(metrics.strokeIndex, metrics.sidecar3D));
+ }
+
+ return faults;
+}
+
+function detectSidecar3DFaults(
+ strokeIndex: number,
+ sidecar3D: Sidecar3DMetrics,
+): PostureFault[] {
+ const faults: PostureFault[] = [];
+
+ if (sidecar3D.lateralShoulderSymmetryMm !== undefined) {
+ // Threshold TBD — emit pending stub so UI can show "detection coming soon"
+ faults.push({
+ strokeIndex,
+ faultType: "left_right_asymmetry",
+ severity: "pending",
+ phase: "drive",
+ evidence: {
+ metric: "leftRightAsymmetry",
+ value: sidecar3D.lateralShoulderSymmetryMm,
+ threshold: 0,
+ },
+ });
+ }
+
+ if (
+ sidecar3D.leftKneeTrackDeviationMm !== undefined ||
+ sidecar3D.rightKneeTrackDeviationMm !== undefined
+ ) {
+ const value =
+ Math.max(
+ sidecar3D.leftKneeTrackDeviationMm ?? 0,
+ sidecar3D.rightKneeTrackDeviationMm ?? 0,
+ );
+ faults.push({
+ strokeIndex,
+ faultType: "knee_track_deviation",
+ severity: "pending",
+ phase: "drive",
+ evidence: {
+ metric: "kneeTrackDeviation",
+ value,
+ threshold: 0,
+ },
+ });
+ }
+
+ if (sidecar3D.nearShinAngleDeg !== undefined) {
+ faults.push({
+ strokeIndex,
+ faultType: "shin_not_vertical_at_catch",
+ severity: "pending",
+ phase: "catch",
+ evidence: {
+ metric: "shinVerticalAtCatchDeg",
+ value: sidecar3D.nearShinAngleDeg,
+ threshold: 0,
+ },
+ });
+ }
+
return faults;
}
diff --git a/src/lib/mocap/analysis/postureMetrics.ts b/src/lib/mocap/analysis/postureMetrics.ts
index 36e88c7..e5e2b24 100644
--- a/src/lib/mocap/analysis/postureMetrics.ts
+++ b/src/lib/mocap/analysis/postureMetrics.ts
@@ -7,6 +7,7 @@ import {
type PoseLandmarkName,
type PosePoint,
type PostureMetrics,
+ type Sidecar3DMetrics,
type Stroke,
} from "./types";
@@ -18,13 +19,14 @@ export function PostureMetricsCalculator(
calibration?: Calibration,
): PostureMetrics {
void calibration;
- const catchFrame = frameAt(stream, stroke.catchFrameIndex);
- const finishFrame = frameAt(stream, stroke.finishFrameIndex);
- const backAngleAtCatchDeg = torsoBackAngleDeg(stream, catchFrame);
- const backAngleAtFinishDeg = torsoBackAngleDeg(stream, finishFrame);
+ const projectedStream = toProjectedStream(stream);
+ const catchFrame = frameAt(projectedStream, stroke.catchFrameIndex);
+ const finishFrame = frameAt(projectedStream, stroke.finishFrameIndex);
+ const backAngleAtCatchDeg = torsoBackAngleDeg(projectedStream, catchFrame);
+ const backAngleAtFinishDeg = torsoBackAngleDeg(projectedStream, finishFrame);
const laybackAngleDeg = Math.max(0, 90 - backAngleAtFinishDeg);
- const legSignal = legExtensionSignal(stream, stroke);
+ const legSignal = legExtensionSignal(projectedStream, stroke);
const catchLeg = legSignal[0]?.value ?? 0;
const finishLeg = legSignal[stroke.finishFrameIndex - stroke.catchFrameIndex]
?.value ?? catchLeg;
@@ -39,11 +41,11 @@ export function PostureMetricsCalculator(
catchLeg + legRange * 0.8,
);
const torsoOpenFrameIndex = firstTorsoChangeFrame(
- stream,
+ projectedStream,
stroke,
backAngleAtCatchDeg,
);
- const armBendOnsetFrameIndex = firstArmBendFrame(stream, stroke);
+ const armBendOnsetFrameIndex = firstArmBendFrame(projectedStream, stroke);
return {
strokeIndex: stroke.strokeIndex,
@@ -74,6 +76,10 @@ export function PostureMetricsCalculator(
stream.capturePerspective === "sidecar-3d"
? { available: false, reason: "insufficient-tracking" }
: { available: false, reason: "requires-sidecar-3d" },
+ sidecar3D:
+ stream.capturePerspective === "sidecar-3d"
+ ? computeSidecar3DMetrics(stream, stroke)
+ : undefined,
};
}
@@ -231,3 +237,135 @@ function recoveryDriveRatio(stroke: Stroke): number {
function radiansToDegrees(radians: number): number {
return (radians * 180) / Math.PI;
}
+
+// --- Coordinate-space adapter (ADR-0005 §3) ---
+
+interface SessionBounds {
+ yMin: number;
+ yMax: number;
+ zMin: number;
+ zMax: number;
+}
+
+function computeSessionBounds(stream: PoseFrameStream): SessionBounds {
+ let yMin = Infinity, yMax = -Infinity, zMin = Infinity, zMax = -Infinity;
+ for (const frame of stream.frames) {
+ const kps = Array.isArray(frame.keypoints) ? frame.keypoints : Object.values(frame.keypoints);
+ for (const kp of kps) {
+ if (!kp || kp.confidence < MIN_CONFIDENCE) continue;
+ if (kp.y < yMin) yMin = kp.y;
+ if (kp.y > yMax) yMax = kp.y;
+ if (kp.z !== undefined) {
+ if (kp.z < zMin) zMin = kp.z;
+ if (kp.z > zMax) zMax = kp.z;
+ }
+ }
+ }
+ return { yMin, yMax, zMin, zMax };
+}
+
+function projectToNormalized(point: PosePoint, bounds: SessionBounds): PosePoint {
+ if (point.z === undefined) return point;
+ const yRange = bounds.yMax - bounds.yMin;
+ const zRange = bounds.zMax - bounds.zMin;
+ return {
+ x: zRange > 0 ? (point.z - bounds.zMin) / zRange : 0.5,
+ y: yRange > 0 ? (point.y - bounds.yMin) / yRange : 0.5,
+ confidence: point.confidence,
+ };
+}
+
+function toProjectedStream(stream: PoseFrameStream): PoseFrameStream {
+ if (stream.coordinateSpace !== "world-mm-3d") return stream;
+ const bounds = computeSessionBounds(stream);
+ const projectedFrames = stream.frames.map((frame) => {
+ const kps = frame.keypoints;
+ const projected = Array.isArray(kps)
+ ? (kps as PosePoint[]).map((kp) => projectToNormalized(kp, bounds))
+ : Object.fromEntries(
+ Object.entries(kps as Record).map(([k, v]) => [
+ k,
+ projectToNormalized(v, bounds),
+ ]),
+ );
+ return { ...frame, keypoints: projected };
+ });
+ return { ...stream, frames: projectedFrames as PoseFrameStream["frames"] };
+}
+
+// --- Sidecar-3D specific metrics ---
+
+function computeSidecar3DMetrics(
+ stream: PoseFrameStream,
+ stroke: Stroke,
+): Sidecar3DMetrics {
+ const metrics: Sidecar3DMetrics = {};
+ const frames = stream.frames.slice(stroke.catchFrameIndex, stroke.finishFrameIndex + 1);
+ if (frames.length === 0) return metrics;
+
+ // lateralShoulderSymmetryMm: mean absolute x-displacement left vs right shoulder
+ const shoulderDeltas: number[] = [];
+ const hipDeltas: number[] = [];
+ for (const frame of frames) {
+ const ls = getPosePoint(frame, "leftShoulder");
+ const rs = getPosePoint(frame, "rightShoulder");
+ const lh = getPosePoint(frame, "leftHip");
+ const rh = getPosePoint(frame, "rightHip");
+ if (ls && rs && ls.confidence >= MIN_CONFIDENCE && rs.confidence >= MIN_CONFIDENCE) {
+ shoulderDeltas.push(Math.abs(ls.x - rs.x));
+ }
+ if (lh && rh && lh.confidence >= MIN_CONFIDENCE && rh.confidence >= MIN_CONFIDENCE) {
+ hipDeltas.push(Math.abs(lh.x - rh.x));
+ }
+ }
+ if (shoulderDeltas.length > 0) {
+ metrics.lateralShoulderSymmetryMm =
+ shoulderDeltas.reduce((a, b) => a + b, 0) / shoulderDeltas.length;
+ }
+ if (hipDeltas.length > 0) {
+ metrics.lateralHipSymmetryMm =
+ hipDeltas.reduce((a, b) => a + b, 0) / hipDeltas.length;
+ }
+
+ // knee track deviation: peak |knee.x - ankle.x| during drive
+ let leftKneePeak = 0;
+ let rightKneePeak = 0;
+ for (const frame of frames) {
+ const lk = getPosePoint(frame, "leftKnee");
+ const la = getPosePoint(frame, "leftAnkle");
+ const rk = getPosePoint(frame, "rightKnee");
+ const ra = getPosePoint(frame, "rightAnkle");
+ if (lk && la && lk.confidence >= MIN_CONFIDENCE && la.confidence >= MIN_CONFIDENCE) {
+ leftKneePeak = Math.max(leftKneePeak, Math.abs(lk.x - la.x));
+ }
+ if (rk && ra && rk.confidence >= MIN_CONFIDENCE && ra.confidence >= MIN_CONFIDENCE) {
+ rightKneePeak = Math.max(rightKneePeak, Math.abs(rk.x - ra.x));
+ }
+ }
+ if (leftKneePeak > 0) metrics.leftKneeTrackDeviationMm = leftKneePeak;
+ if (rightKneePeak > 0) metrics.rightKneeTrackDeviationMm = rightKneePeak;
+
+ // nearShinAngleDeg: shin angle from nearer (lower |z|) ankle/knee pair at catch
+ const catchFrame = stream.frames[stroke.catchFrameIndex];
+ if (catchFrame) {
+ const lk = getPosePoint(catchFrame, "leftKnee");
+ const la = getPosePoint(catchFrame, "leftAnkle");
+ const rk = getPosePoint(catchFrame, "rightKnee");
+ const ra = getPosePoint(catchFrame, "rightAnkle");
+ const leftZ = lk?.z ?? Infinity;
+ const rightZ = rk?.z ?? Infinity;
+ const [knee, ankle] = Math.abs(leftZ) <= Math.abs(rightZ)
+ ? [lk, la]
+ : [rk, ra];
+ if (
+ knee && ankle &&
+ knee.confidence >= MIN_CONFIDENCE && ankle.confidence >= MIN_CONFIDENCE
+ ) {
+ const dx = knee.x - ankle.x;
+ const dy = knee.y - ankle.y;
+ metrics.nearShinAngleDeg = radiansToDegrees(Math.atan2(Math.abs(dx), Math.abs(dy)));
+ }
+ }
+
+ return metrics;
+}
diff --git a/src/lib/mocap/analysis/types.ts b/src/lib/mocap/analysis/types.ts
index d8ff2ae..c6b79b7 100644
--- a/src/lib/mocap/analysis/types.ts
+++ b/src/lib/mocap/analysis/types.ts
@@ -7,9 +7,12 @@ export type PostureFaultType =
| "early_arm_bend"
| "back_opens_before_legs_drive"
| "excessive_layback"
- | "slow_recovery_ratio";
+ | "slow_recovery_ratio"
+ | "left_right_asymmetry"
+ | "knee_track_deviation"
+ | "shin_not_vertical_at_catch";
-export type FaultSeverity = "info" | "warning" | "critical";
+export type FaultSeverity = "info" | "warning" | "critical" | "pending";
export type PoseLandmarkName =
| "leftShoulder"
@@ -43,6 +46,7 @@ export const POSE_LANDMARK_INDEX: Record = {
export interface PosePoint {
x: number;
y: number;
+ z?: number; // world-mm-3d only (sidecar-3d, keypointSchemaVersion 2)
confidence: number;
}
@@ -60,6 +64,8 @@ export interface PoseFrameStream {
fps: number;
capturePerspective: CapturePerspective;
frames: readonly PoseAnalysisFrame[];
+ coordinateSpace?: "normalized-2d" | "world-mm-3d";
+ cameraCount?: number;
}
export interface Stroke {
@@ -91,6 +97,14 @@ export interface AvailableMetric {
export type MaybeMetric = AvailableMetric | UnavailableMetric;
+export interface Sidecar3DMetrics {
+ lateralShoulderSymmetryMm?: number;
+ lateralHipSymmetryMm?: number;
+ leftKneeTrackDeviationMm?: number;
+ rightKneeTrackDeviationMm?: number;
+ nearShinAngleDeg?: number;
+}
+
export interface PostureMetrics {
strokeIndex: number;
segmentationSource: StrokeSegmentationSource;
@@ -105,6 +119,7 @@ export interface PostureMetrics {
leftRightAsymmetry: MaybeMetric;
shinVerticalAtCatchDeg: MaybeMetric;
kneeTrackDeviation: MaybeMetric;
+ sidecar3D?: Sidecar3DMetrics;
}
export interface PostureFault {
@@ -140,5 +155,6 @@ function isPosePointArray(keypoints: PoseKeypoints): keypoints is readonly PoseP
export function landmarkSide(
perspective: CapturePerspective,
): "left" | "right" {
+ // sidecar-3d uses right-side projection for v1-compatible fault rules
return perspective === "side-left" ? "left" : "right";
}
diff --git a/src/lib/mocap/coaching/coachingAdvisor.ts b/src/lib/mocap/coaching/coachingAdvisor.ts
index f8137a8..eee76d1 100644
--- a/src/lib/mocap/coaching/coachingAdvisor.ts
+++ b/src/lib/mocap/coaching/coachingAdvisor.ts
@@ -37,6 +37,18 @@ const DRILL_CATALOG: Record = {
"Controlled recovery timing drill",
"Pause at the finish drill",
],
+ left_right_asymmetry: [
+ "Eyes-closed sculling drill",
+ "Single-arm rowing alternate sides",
+ ],
+ knee_track_deviation: [
+ "Slow-motion drive with knee-track focus",
+ "Legs-only rowing with band resistance",
+ ],
+ shin_not_vertical_at_catch: [
+ "Pause drill at the catch — check shin angle",
+ "Footstretcher adjustment check",
+ ],
};
// ---------------------------------------------------------------------------
@@ -74,6 +86,21 @@ const CUE_COPY: Record = {
"Your recovery is very slow relative to the drive. Control the slide speed but aim to keep the recovery-to-drive ratio below 2.5 to maintain a good rhythm.",
audioHint: "Control your slide",
},
+ left_right_asymmetry: {
+ message:
+ "Left-right asymmetry detected in your shoulder or hip position. Focus on applying equal pressure through both feet and keeping the handle level.",
+ audioHint: "Even pressure both sides",
+ },
+ knee_track_deviation: {
+ message:
+ "Your knee is tracking away from the straight line during the drive. Keep the knees tracking over your feet throughout the stroke.",
+ audioHint: "Knees over feet",
+ },
+ shin_not_vertical_at_catch: {
+ message:
+ "Your shin is not vertical at the catch. Check your foot stretcher position and ensure you reach the correct compression before taking the stroke.",
+ audioHint: "Vertical shin at catch",
+ },
};
// ---------------------------------------------------------------------------
@@ -84,6 +111,7 @@ const SEVERITY_RANK: Record = {
info: 0,
warning: 1,
critical: 2,
+ pending: -1, // pending faults are never surfaced as coaching cues
};
// ---------------------------------------------------------------------------
diff --git a/src/lib/mocap/coaching/liveCoachingEngine.ts b/src/lib/mocap/coaching/liveCoachingEngine.ts
index 18dc07c..8d0b119 100644
--- a/src/lib/mocap/coaching/liveCoachingEngine.ts
+++ b/src/lib/mocap/coaching/liveCoachingEngine.ts
@@ -59,6 +59,7 @@ const SEVERITY_RANK: Record = {
info: 0,
warning: 1,
critical: 2,
+ pending: -1,
};
export class LiveCoachingEngine {
diff --git a/src/lib/mocap/poseFrameStream.ts b/src/lib/mocap/poseFrameStream.ts
index 3ee2091..159a6ef 100644
--- a/src/lib/mocap/poseFrameStream.ts
+++ b/src/lib/mocap/poseFrameStream.ts
@@ -1,5 +1,5 @@
/**
- * PoseFrameStream binary blob format (ADR-0001).
+ * PoseFrameStream binary blob format (ADR-0001, ADR-0005).
*
* Layout: [header: 32 bytes][frame 0][frame 1]...[frame N-1]
*
@@ -9,19 +9,39 @@
* Header `frameCount` is OPEN_FRAME_COUNT during streaming append and updated
* to the final count on session finalize. Readers must accept either form: when
* they see OPEN_FRAME_COUNT, they derive the count from file size.
+ *
+ * keypointSchemaVersion 1 (v1): 2D browser capture — keypoints are [x, y, confidence]
+ * keypointSchemaVersion 2 (v2): sidecar-3d capture — keypoints are [x, y, z, confidence]
+ * Header bytes 20–21: coordinateSpace (u8), cameraCount (u8)
*/
export const MAGIC = new Uint8Array([0x4d, 0x4f, 0x50, 0x53]); // "MOPS"
export const HEADER_SIZE = 32;
export const FORMAT_VERSION = 1;
export const KEYPOINT_SCHEMA_V1 = 1;
+export const KEYPOINT_SCHEMA_V2 = 2;
export const KEYPOINTS_PER_FRAME_V1 = 33;
+export const KEYPOINTS_PER_FRAME_V2 = 33;
export const BYTES_PER_FRAME_V1 =
4 /* timestampMs Float32 */ +
KEYPOINTS_PER_FRAME_V1 * 3 * 4 /* x, y, confidence Float32 */ +
4; /* qualityFlags Uint32 */
+export const BYTES_PER_FRAME_V2 =
+ 4 /* timestampMs Float32 */ +
+ KEYPOINTS_PER_FRAME_V2 * 4 * 4 /* x, y, z, confidence Float32 */ +
+ 4; /* qualityFlags Uint32 */
export const OPEN_FRAME_COUNT = 0xffffffff;
+export type CoordinateSpace = "normalized-2d" | "world-mm-3d";
+export const COORDINATE_SPACE_BYTE: Record = {
+ "normalized-2d": 0,
+ "world-mm-3d": 1,
+};
+const COORDINATE_SPACE_FROM_BYTE: CoordinateSpace[] = [
+ "normalized-2d",
+ "world-mm-3d",
+];
+
export const QUALITY_FLAG = {
LOW_LIGHT: 1 << 0,
OCCLUDED: 1 << 1,
@@ -37,11 +57,16 @@ export interface PoseStreamHeader {
keypointsPerFrame: number;
bytesPerFrame: number;
frameCount: number; // OPEN_FRAME_COUNT during streaming
+ coordinateSpace: CoordinateSpace;
+ cameraCount: number;
}
export interface PoseFrame {
timestampMs: number;
- /** Length = keypointsPerFrame * 3 (x, y, confidence interleaved). */
+ /**
+ * v1: length = keypointsPerFrame * 3 (x, y, confidence interleaved)
+ * v2: length = keypointsPerFrame * 4 (x, y, z, confidence interleaved)
+ */
keypoints: Float32Array;
qualityFlags: number;
}
@@ -57,23 +82,33 @@ export function encodeHeader(opts: {
fps: number;
keypointSchemaVersion?: number;
frameCount?: number;
+ coordinateSpace?: CoordinateSpace;
+ cameraCount?: number;
}): Uint8Array {
const schema = opts.keypointSchemaVersion ?? KEYPOINT_SCHEMA_V1;
- if (schema !== KEYPOINT_SCHEMA_V1) {
+ if (schema !== KEYPOINT_SCHEMA_V1 && schema !== KEYPOINT_SCHEMA_V2) {
throw new PoseStreamFormatError(
`Unsupported keypointSchemaVersion ${schema}`,
);
}
+ const coordinateSpace = opts.coordinateSpace ?? "normalized-2d";
+ const cameraCount = opts.cameraCount ?? 1;
+ const keypointsPerFrame =
+ schema === KEYPOINT_SCHEMA_V2 ? KEYPOINTS_PER_FRAME_V2 : KEYPOINTS_PER_FRAME_V1;
+ const bytesPerFrame =
+ schema === KEYPOINT_SCHEMA_V2 ? BYTES_PER_FRAME_V2 : BYTES_PER_FRAME_V1;
const buf = new Uint8Array(HEADER_SIZE);
const view = new DataView(buf.buffer);
buf.set(MAGIC, 0);
view.setUint16(4, FORMAT_VERSION, true);
view.setUint16(6, schema, true);
view.setFloat32(8, opts.fps, true);
- view.setUint16(12, KEYPOINTS_PER_FRAME_V1, true);
- view.setUint16(14, BYTES_PER_FRAME_V1, true);
+ view.setUint16(12, keypointsPerFrame, true);
+ view.setUint16(14, bytesPerFrame, true);
view.setUint32(16, opts.frameCount ?? OPEN_FRAME_COUNT, true);
- // bytes 20-31 reserved (zero)
+ view.setUint8(20, COORDINATE_SPACE_BYTE[coordinateSpace]);
+ view.setUint8(21, cameraCount);
+ // bytes 22-31 reserved (zero)
return buf;
}
@@ -96,11 +131,14 @@ export function decodeHeader(bytes: Uint8Array): PoseStreamHeader {
);
}
const keypointSchemaVersion = view.getUint16(6, true);
- if (keypointSchemaVersion !== KEYPOINT_SCHEMA_V1) {
+ if (keypointSchemaVersion !== KEYPOINT_SCHEMA_V1 && keypointSchemaVersion !== KEYPOINT_SCHEMA_V2) {
throw new PoseStreamFormatError(
- `Unsupported keypointSchemaVersion ${keypointSchemaVersion}; reader knows ${KEYPOINT_SCHEMA_V1}`,
+ `Unsupported keypointSchemaVersion ${keypointSchemaVersion}; reader knows ${KEYPOINT_SCHEMA_V1}, ${KEYPOINT_SCHEMA_V2}`,
);
}
+ const coordinateSpaceByte = view.getUint8(20);
+ const coordinateSpace: CoordinateSpace =
+ COORDINATE_SPACE_FROM_BYTE[coordinateSpaceByte] ?? "normalized-2d";
return {
formatVersion,
keypointSchemaVersion,
@@ -108,6 +146,8 @@ export function decodeHeader(bytes: Uint8Array): PoseStreamHeader {
keypointsPerFrame: view.getUint16(12, true),
bytesPerFrame: view.getUint16(14, true),
frameCount: view.getUint32(16, true),
+ coordinateSpace,
+ cameraCount: view.getUint8(21),
};
}
@@ -126,27 +166,47 @@ export function encodeFrame(frame: PoseFrame): Uint8Array {
return buf;
}
-export function decodeFrame(bytes: Uint8Array, offset = 0): PoseFrame {
- if (bytes.byteLength - offset < BYTES_PER_FRAME_V1) {
+export function encodeFrameV2(frame: PoseFrame): Uint8Array {
+ if (frame.keypoints.length !== KEYPOINTS_PER_FRAME_V2 * 4) {
+ throw new PoseStreamFormatError(
+ `Expected ${KEYPOINTS_PER_FRAME_V2 * 4} keypoint floats for v2, got ${frame.keypoints.length}`,
+ );
+ }
+ const buf = new Uint8Array(BYTES_PER_FRAME_V2);
+ const view = new DataView(buf.buffer);
+ view.setFloat32(0, frame.timestampMs, true);
+ const floats = new Float32Array(buf.buffer, 4, KEYPOINTS_PER_FRAME_V2 * 4);
+ floats.set(frame.keypoints);
+ view.setUint32(BYTES_PER_FRAME_V2 - 4, frame.qualityFlags >>> 0, true);
+ return buf;
+}
+
+export function decodeFrame(bytes: Uint8Array, offset = 0, schema = KEYPOINT_SCHEMA_V1): PoseFrame {
+ const bytesPerFrame = schema === KEYPOINT_SCHEMA_V2 ? BYTES_PER_FRAME_V2 : BYTES_PER_FRAME_V1;
+ if (bytes.byteLength - offset < bytesPerFrame) {
throw new PoseStreamFormatError(
- `Frame slice too short: ${bytes.byteLength - offset} < ${BYTES_PER_FRAME_V1}`,
+ `Frame slice too short: ${bytes.byteLength - offset} < ${bytesPerFrame}`,
);
}
- const view = new DataView(bytes.buffer, bytes.byteOffset + offset, BYTES_PER_FRAME_V1);
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, bytesPerFrame);
const timestampMs = view.getFloat32(0, true);
- const keypoints = new Float32Array(KEYPOINTS_PER_FRAME_V1 * 3);
- for (let i = 0; i < keypoints.length; i++) {
+ const floatCount =
+ schema === KEYPOINT_SCHEMA_V2 ? KEYPOINTS_PER_FRAME_V2 * 4 : KEYPOINTS_PER_FRAME_V1 * 3;
+ const keypoints = new Float32Array(floatCount);
+ for (let i = 0; i < floatCount; i++) {
keypoints[i] = view.getFloat32(4 + i * 4, true);
}
- const qualityFlags = view.getUint32(BYTES_PER_FRAME_V1 - 4, true);
+ const qualityFlags = view.getUint32(bytesPerFrame - 4, true);
return { timestampMs, keypoints, qualityFlags };
}
-export function frameByteOffset(frameIndex: number): number {
- return HEADER_SIZE + frameIndex * BYTES_PER_FRAME_V1;
+export function frameByteOffset(frameIndex: number, schema = KEYPOINT_SCHEMA_V1): number {
+ const bytesPerFrame = schema === KEYPOINT_SCHEMA_V2 ? BYTES_PER_FRAME_V2 : BYTES_PER_FRAME_V1;
+ return HEADER_SIZE + frameIndex * bytesPerFrame;
}
-export function framesFromBlobSize(blobSize: number): number {
+export function framesFromBlobSize(blobSize: number, schema = KEYPOINT_SCHEMA_V1): number {
if (blobSize < HEADER_SIZE) return 0;
- return Math.floor((blobSize - HEADER_SIZE) / BYTES_PER_FRAME_V1);
+ const bytesPerFrame = schema === KEYPOINT_SCHEMA_V2 ? BYTES_PER_FRAME_V2 : BYTES_PER_FRAME_V1;
+ return Math.floor((blobSize - HEADER_SIZE) / bytesPerFrame);
}
diff --git a/src/lib/mocap/sidecarClient.ts b/src/lib/mocap/sidecarClient.ts
new file mode 100644
index 0000000..cb048c1
--- /dev/null
+++ b/src/lib/mocap/sidecarClient.ts
@@ -0,0 +1,64 @@
+export const SIDECAR_DEFAULT_PORT = 8765;
+
+export interface SidecarHealth {
+ status: "ready" | "initializing" | "error";
+ fps: number;
+ cameras: number;
+ schemaVersion: number;
+}
+
+export interface SidecarSessionInfo {
+ sessionId: string;
+ calibrationId: string;
+}
+
+export interface SidecarKeypointFrame {
+ frameIndex: number;
+ timestampMs: number;
+ keypoints: Array<{
+ index: number;
+ x: number;
+ y: number;
+ z: number;
+ confidence: number;
+ }>;
+ quality: {
+ trackedCount: number;
+ meanConfidence: number;
+ reprojectionErrorMm?: number;
+ cameraCount?: number;
+ };
+}
+
+export async function checkSidecarHealth(port = SIDECAR_DEFAULT_PORT): Promise {
+ const res = await fetch(`http://localhost:${port}/health`);
+ if (!res.ok) throw new Error(`Sidecar health check failed: ${res.status}`);
+ return res.json() as Promise;
+}
+
+export async function startSidecarSession(port = SIDECAR_DEFAULT_PORT): Promise {
+ const res = await fetch(`http://localhost:${port}/session/start`, { method: "POST" });
+ if (!res.ok) throw new Error(`Sidecar session/start failed: ${res.status}`);
+ return res.json() as Promise;
+}
+
+export async function stopSidecarSession(port = SIDECAR_DEFAULT_PORT): Promise {
+ await fetch(`http://localhost:${port}/session/stop`, { method: "POST" });
+}
+
+export function connectSidecarStream(
+ port: number,
+ onFrame: (frame: SidecarKeypointFrame) => void,
+ onError: (err: Error) => void,
+): () => void {
+ const ws = new WebSocket(`ws://localhost:${port}/pose-stream`);
+ ws.onmessage = (e) => {
+ try {
+ onFrame(JSON.parse(e.data as string) as SidecarKeypointFrame);
+ } catch {
+ onError(new Error("Failed to parse sidecar frame"));
+ }
+ };
+ ws.onerror = () => onError(new Error("Sidecar WebSocket error"));
+ return () => ws.close();
+}
diff --git a/tests/sidecarTracer.test.ts b/tests/sidecarTracer.test.ts
new file mode 100644
index 0000000..5981032
--- /dev/null
+++ b/tests/sidecarTracer.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Tests for issue #30: sidecar tracer — v2 blob format, coordinate adapter, fault stubs.
+ *
+ * Run with: npx tsx --test tests/sidecarTracer.test.ts
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+import {
+ BYTES_PER_FRAME_V2,
+ HEADER_SIZE,
+ KEYPOINT_SCHEMA_V1,
+ KEYPOINT_SCHEMA_V2,
+ KEYPOINTS_PER_FRAME_V2,
+ PoseStreamFormatError,
+ decodeFrame,
+ decodeHeader,
+ encodeFrame,
+ encodeFrameV2,
+ encodeHeader,
+ framesFromBlobSize,
+ type PoseFrame,
+} from "../src/lib/mocap/poseFrameStream";
+import {
+ adaptPoseFrameStreamBlob,
+ keypointQuadsToPosePoints,
+} from "../src/lib/mocap/analysis/poseFrameStreamAdapter";
+import { PostureFaultDetector } from "../src/lib/mocap/analysis/postureFaultDetector";
+import type { PostureMetrics, Sidecar3DMetrics } from "../src/lib/mocap/analysis/types";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeV2Frame(seed: number): PoseFrame {
+ const kp = new Float32Array(KEYPOINTS_PER_FRAME_V2 * 4);
+ for (let i = 0; i < kp.length; i++) {
+ kp[i] = Math.fround(100 + Math.sin((seed + i) * 0.1) * 500);
+ }
+ return { timestampMs: Math.fround(seed * 33.3), keypoints: kp, qualityFlags: 0 };
+}
+
+// ---------------------------------------------------------------------------
+// v2 blob codec
+// ---------------------------------------------------------------------------
+
+test("v2 header encodes coordinateSpace=world-mm-3d and cameraCount", () => {
+ const hdr = encodeHeader({
+ fps: 30,
+ keypointSchemaVersion: KEYPOINT_SCHEMA_V2,
+ coordinateSpace: "world-mm-3d",
+ cameraCount: 3,
+ });
+ assert.equal(hdr.byteLength, HEADER_SIZE);
+ const parsed = decodeHeader(hdr);
+ assert.equal(parsed.keypointSchemaVersion, KEYPOINT_SCHEMA_V2);
+ assert.equal(parsed.coordinateSpace, "world-mm-3d");
+ assert.equal(parsed.cameraCount, 3);
+ assert.equal(parsed.fps, 30);
+});
+
+test("v2 header defaults coordinateSpace=normalized-2d for v1 blobs", () => {
+ const hdr = encodeHeader({ fps: 30 });
+ const parsed = decodeHeader(hdr);
+ assert.equal(parsed.keypointSchemaVersion, KEYPOINT_SCHEMA_V1);
+ assert.equal(parsed.coordinateSpace, "normalized-2d");
+ assert.equal(parsed.cameraCount, 1);
+});
+
+test("v2 frame encode/decode round-trips without data loss", () => {
+ const frame = makeV2Frame(42);
+ const encoded = encodeFrameV2(frame);
+ assert.equal(encoded.byteLength, BYTES_PER_FRAME_V2);
+ const decoded = decodeFrame(encoded, 0, KEYPOINT_SCHEMA_V2);
+ assert.equal(decoded.timestampMs, frame.timestampMs);
+ assert.equal(decoded.keypoints.length, frame.keypoints.length);
+ for (let i = 0; i < frame.keypoints.length; i++) {
+ assert.equal(decoded.keypoints[i], frame.keypoints[i]);
+ }
+});
+
+test("framesFromBlobSize works for v2 frame size", () => {
+ const blobSize = HEADER_SIZE + 5 * BYTES_PER_FRAME_V2;
+ assert.equal(framesFromBlobSize(blobSize, KEYPOINT_SCHEMA_V2), 5);
+});
+
+test("decodeHeader rejects unknown keypointSchemaVersion", () => {
+ const hdr = encodeHeader({ fps: 30 });
+ const view = new DataView(hdr.buffer);
+ view.setUint16(6, 99, true); // unknown schema
+ assert.throws(() => decodeHeader(hdr), PoseStreamFormatError);
+});
+
+// ---------------------------------------------------------------------------
+// v2 fixture blob
+// ---------------------------------------------------------------------------
+
+test("v2 fixture blob round-trips: header + 100 frames", () => {
+ const blobPath = join(__dirname, "../src/lib/mocap/__tests__/fixtures/v2-blob-3d.bin");
+ const buf = readFileSync(blobPath);
+ const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ const header = decodeHeader(bytes.subarray(0, HEADER_SIZE));
+ assert.equal(header.keypointSchemaVersion, KEYPOINT_SCHEMA_V2);
+ assert.equal(header.coordinateSpace, "world-mm-3d");
+ assert.equal(header.cameraCount, 3);
+ assert.equal(header.frameCount, 100);
+ assert.equal(framesFromBlobSize(bytes.byteLength, KEYPOINT_SCHEMA_V2), 100);
+});
+
+test("adaptPoseFrameStreamBlob produces PoseFrameStream with z from v2 fixture", () => {
+ const blobPath = join(__dirname, "../src/lib/mocap/__tests__/fixtures/v2-blob-3d.bin");
+ const buf = readFileSync(blobPath);
+ const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ const stream = adaptPoseFrameStreamBlob(bytes, "sidecar-3d");
+ assert.equal(stream.coordinateSpace, "world-mm-3d");
+ assert.equal(stream.cameraCount, 3);
+ assert.equal(stream.frames.length, 100);
+ const frame0 = stream.frames[0]!;
+ const kps = Array.isArray(frame0.keypoints) ? frame0.keypoints : Object.values(frame0.keypoints);
+ assert.ok(kps.length > 0, "frame should have keypoints");
+ const kp0 = kps[0]!;
+ assert.ok("z" in kp0 && kp0.z !== undefined, "v2 keypoints must carry z");
+});
+
+// ---------------------------------------------------------------------------
+// coordinateSpace adapter: toNormalizedProjection via adapter
+// ---------------------------------------------------------------------------
+
+test("keypointQuadsToPosePoints produces z field", () => {
+ const quads = new Float32Array([10, 20, 30, 0.9]);
+ const pts = keypointQuadsToPosePoints(quads);
+ assert.equal(pts.length, 1);
+ assert.equal(pts[0]!.x, Math.fround(10));
+ assert.equal(pts[0]!.y, Math.fround(20));
+ assert.equal(pts[0]!.z, Math.fround(30));
+ assert.ok(Math.abs(pts[0]!.confidence - 0.9) < 1e-6, "confidence ~0.9");
+});
+
+// ---------------------------------------------------------------------------
+// v1 blobs still readable unchanged (no regression)
+// ---------------------------------------------------------------------------
+
+test("v1 blob still decodes as normalized-2d, z=undefined", () => {
+ const hdr = encodeHeader({ fps: 30, keypointSchemaVersion: KEYPOINT_SCHEMA_V1 });
+ const hdrParsed = decodeHeader(hdr);
+ assert.equal(hdrParsed.keypointSchemaVersion, KEYPOINT_SCHEMA_V1);
+ assert.equal(hdrParsed.coordinateSpace, "normalized-2d");
+
+ const v1Kp = new Float32Array(33 * 3);
+ for (let i = 0; i < v1Kp.length; i++) v1Kp[i] = Math.fround(i * 0.01);
+ const v1Frame = encodeFrame({ timestampMs: 0, keypoints: v1Kp, qualityFlags: 0 });
+ const decoded = decodeFrame(v1Frame, 0, KEYPOINT_SCHEMA_V1);
+ assert.equal(decoded.keypoints.length, 33 * 3);
+});
+
+// ---------------------------------------------------------------------------
+// Sidecar-3D fault stubs — return "pending" severity, never null
+// ---------------------------------------------------------------------------
+
+function makeMockMetrics(sidecar3D: Sidecar3DMetrics): PostureMetrics {
+ return {
+ strokeIndex: 0,
+ segmentationSource: "pose-segmented",
+ backAngleAtCatchDeg: 60,
+ backAngleAtFinishDeg: 50,
+ laybackAngleDeg: 15,
+ hipKneeOpeningOffsetFrames: 2,
+ armBendOnsetFrameIndex: 20,
+ legExtensionCompleteFrameIndex: 18,
+ armBendBeforeLegsCompleteFrames: -2,
+ recoveryDriveRatio: 1.5,
+ leftRightAsymmetry: { available: false, reason: "insufficient-tracking" },
+ shinVerticalAtCatchDeg: { available: false, reason: "insufficient-tracking" },
+ kneeTrackDeviation: { available: false, reason: "insufficient-tracking" },
+ sidecar3D,
+ };
+}
+
+test("fault detector emits pending left_right_asymmetry when lateralShoulderSymmetryMm present", () => {
+ const metrics = makeMockMetrics({ lateralShoulderSymmetryMm: 45 });
+ const faults = PostureFaultDetector(metrics);
+ const asymmetry = faults.find((f) => f.faultType === "left_right_asymmetry");
+ assert.ok(asymmetry, "should emit left_right_asymmetry fault");
+ assert.equal(asymmetry.severity, "pending");
+ assert.equal(asymmetry.evidence.value, 45);
+});
+
+test("fault detector emits pending knee_track_deviation when knee metrics present", () => {
+ const metrics = makeMockMetrics({
+ leftKneeTrackDeviationMm: 30,
+ rightKneeTrackDeviationMm: 20,
+ });
+ const faults = PostureFaultDetector(metrics);
+ const knee = faults.find((f) => f.faultType === "knee_track_deviation");
+ assert.ok(knee, "should emit knee_track_deviation fault");
+ assert.equal(knee.severity, "pending");
+ assert.equal(knee.evidence.value, 30); // max of left/right
+});
+
+test("fault detector emits pending shin_not_vertical_at_catch when nearShinAngleDeg present", () => {
+ const metrics = makeMockMetrics({ nearShinAngleDeg: 12 });
+ const faults = PostureFaultDetector(metrics);
+ const shin = faults.find((f) => f.faultType === "shin_not_vertical_at_catch");
+ assert.ok(shin, "should emit shin_not_vertical_at_catch fault");
+ assert.equal(shin.severity, "pending");
+});
+
+test("fault detector emits no sidecar faults when sidecar3D is undefined", () => {
+ const metrics = makeMockMetrics({});
+ metrics.sidecar3D = undefined;
+ const faults = PostureFaultDetector(metrics);
+ const sidecarFaults = faults.filter((f) =>
+ ["left_right_asymmetry", "knee_track_deviation", "shin_not_vertical_at_catch"].includes(
+ f.faultType,
+ ),
+ );
+ assert.equal(sidecarFaults.length, 0);
+});
From 462b6716753da8578261ddbade84347744ea6621 Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sat, 9 May 2026 19:37:33 +0200
Subject: [PATCH 27/29] chore(claude): add WebSearch, node, and package.json
parsing to allowed commands
---
.claude/settings.local.json | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index f63c1c9..b9cff7c 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -33,7 +33,10 @@
"Bash(git commit *)",
"Bash(xargs -I{} echo {})",
"Bash(npm test *)",
- "Bash(git worktree *)"
+ "Bash(git worktree *)",
+ "WebSearch",
+ "Bash(node *)",
+ "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k:v for k,v in d.get\\('scripts',{}\\).items\\(\\)}, indent=2\\)\\); print\\('devdeps:', list\\(d.get\\('devDependencies',{}\\).keys\\(\\)\\)[:20]\\)\")"
]
}
}
From a951c79a1be03ad87d250f0fe73de411bd9f8e9e Mon Sep 17 00:00:00 2001
From: Rupert Germann
Date: Sun, 10 May 2026 20:03:18 +0200
Subject: [PATCH 28/29] docs(sidecar): add venv setup, PyPI availability note,
install troubleshooting
---
docs/sidecar-local-setup.md | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/docs/sidecar-local-setup.md b/docs/sidecar-local-setup.md
index 6cb1e7e..a69bf80 100644
--- a/docs/sidecar-local-setup.md
+++ b/docs/sidecar-local-setup.md
@@ -4,17 +4,23 @@ This guide covers how to run the minimal freemocap sidecar integration for local
## Prerequisites
-- Python 3.10+ with pip
-- `rowing-tracker-sidecar` PyPI package (or the mock server below)
+- Python 3.10+ with `venv`
- The app running locally (`npm run dev`)
## Option A — real freemocap sidecar
+`rowing-tracker-sidecar` is not currently available on PyPI as of May 10, 2026, so the commands below will only work once that package is published or if you have access to an internal distribution.
+
```bash
-pip install rowing-tracker-sidecar
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install --upgrade pip
+python -m pip install rowing-tracker-sidecar
rowing-tracker-sidecar --port 8765
```
+If `python -m pip install rowing-tracker-sidecar` fails with `No matching distribution found`, use Option B for local app development.
+
The sidecar exposes:
- `ws://localhost:8765/pose-stream` — streams `KeypointFrame` JSON
- `GET http://localhost:8765/health` — returns `{ status, fps, cameras, schemaVersion }`
@@ -88,7 +94,10 @@ asyncio.run(main())
Save as `scripts/sidecar-mock.py` and run:
```bash
-pip install websockets
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install --upgrade pip
+python -m pip install websockets
python scripts/sidecar-mock.py
```
From 6c5ebb7fd7acbfc6351579cb3445642786d0e867 Mon Sep 17 00:00:00 2001
From: Rupert Germann