tiena2cva commited on
Commit
8c7116a
·
1 Parent(s): 01ba599

feat(report): add annotated issue clips

Browse files
app.py CHANGED
@@ -76,6 +76,9 @@ def _artifact_url(run_id: str, path: str | None) -> str | None:
76
  if not path:
77
  return None
78
 
 
 
 
79
  artifact_path = Path(path).resolve()
80
  run_root = (RUNS_ROOT / run_id).resolve()
81
  if run_root not in artifact_path.parents or not artifact_path.is_file():
@@ -83,6 +86,69 @@ def _artifact_url(run_id: str, path: str | None) -> str | None:
83
  return f"/api/artifacts/{run_id}/{artifact_path.name}"
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @server.post("/api/analyze")
87
  async def analyze_api(
88
  video: UploadFile | None = File(default=None),
@@ -130,12 +196,37 @@ async def analyze_api(
130
  if video_path is not None:
131
  Path(video_path).unlink(missing_ok=True)
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  return {
134
  "run_id": result["run_id"],
135
  "run_dir": result["run_dir"],
136
  "annotated_video_url": _artifact_url(
137
  result["run_id"], result["annotated_video_path"]
138
  ),
 
 
 
139
  "final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
140
  "report": result["final_report"],
141
  }
 
76
  if not path:
77
  return None
78
 
79
+ if not isinstance(path, str):
80
+ return None
81
+
82
  artifact_path = Path(path).resolve()
83
  run_root = (RUNS_ROOT / run_id).resolve()
84
  if run_root not in artifact_path.parents or not artifact_path.is_file():
 
86
  return f"/api/artifacts/{run_id}/{artifact_path.name}"
87
 
88
 
89
+ def _artifact_link(run_id: str, name: str, path: str | None) -> dict[str, str] | None:
90
+ url = _artifact_url(run_id, path)
91
+ if url is None:
92
+ return None
93
+ return {"name": name, "url": url}
94
+
95
+
96
+ def _artifact_urls(result: dict[str, Any]) -> list[dict[str, str]]:
97
+ run_id = result["run_id"]
98
+ run_dir = Path(str(result["run_dir"]))
99
+ links: list[dict[str, str]] = []
100
+ artifact_files = [
101
+ "final_report.json",
102
+ "video_manifest.json",
103
+ "pose_sequence.json",
104
+ "reps.json",
105
+ "rep_analysis.json",
106
+ "variation.json",
107
+ "issue_markers.json",
108
+ "coach_summary.json",
109
+ "verification.json",
110
+ "manifest.json",
111
+ ]
112
+ for filename in artifact_files:
113
+ link = _artifact_link(run_id, filename, str(run_dir / filename))
114
+ if link is not None:
115
+ links.append(link)
116
+
117
+ video_link = _artifact_link(
118
+ run_id,
119
+ "annotated_video.mp4",
120
+ result.get("annotated_video_path"),
121
+ )
122
+ if video_link is not None:
123
+ links.append(video_link)
124
+
125
+ for thumbnail in result.get("issue_thumbnail_paths", []):
126
+ if not isinstance(thumbnail, dict):
127
+ continue
128
+ path = thumbnail.get("path")
129
+ issue = thumbnail.get("issue", "issue")
130
+ rep_id = thumbnail.get("rep_id", "?")
131
+ if not isinstance(path, str):
132
+ continue
133
+ link = _artifact_link(run_id, f"thumbnail_rep_{rep_id}_{issue}.jpg", path)
134
+ if link is not None:
135
+ links.append(link)
136
+
137
+ for clip in result.get("issue_clip_paths", []):
138
+ if not isinstance(clip, dict):
139
+ continue
140
+ path = clip.get("path")
141
+ issue = clip.get("issue", "issue")
142
+ rep_id = clip.get("rep_id", "?")
143
+ if not isinstance(path, str):
144
+ continue
145
+ link = _artifact_link(run_id, f"clip_rep_{rep_id}_{issue}.mp4", path)
146
+ if link is not None:
147
+ links.append(link)
148
+
149
+ return links
150
+
151
+
152
  @server.post("/api/analyze")
153
  async def analyze_api(
154
  video: UploadFile | None = File(default=None),
 
196
  if video_path is not None:
197
  Path(video_path).unlink(missing_ok=True)
198
 
199
+ issue_thumbnail_urls = []
200
+ for thumbnail in result.get("issue_thumbnail_paths", []):
201
+ if not isinstance(thumbnail, dict):
202
+ continue
203
+ path = thumbnail.get("path")
204
+ if not isinstance(path, str):
205
+ continue
206
+ url = _artifact_url(result["run_id"], path)
207
+ if url is not None:
208
+ issue_thumbnail_urls.append({**thumbnail, "url": url})
209
+
210
+ issue_clip_urls = []
211
+ for clip in result.get("issue_clip_paths", []):
212
+ if not isinstance(clip, dict):
213
+ continue
214
+ path = clip.get("path")
215
+ if not isinstance(path, str):
216
+ continue
217
+ url = _artifact_url(result["run_id"], path)
218
+ if url is not None:
219
+ issue_clip_urls.append({**clip, "url": url})
220
+
221
  return {
222
  "run_id": result["run_id"],
223
  "run_dir": result["run_dir"],
224
  "annotated_video_url": _artifact_url(
225
  result["run_id"], result["annotated_video_path"]
226
  ),
227
+ "issue_thumbnail_urls": issue_thumbnail_urls,
228
+ "issue_clip_urls": issue_clip_urls,
229
+ "artifact_urls": _artifact_urls(result),
230
  "final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
231
  "report": result["final_report"],
232
  }
src/pozify/contracts.py CHANGED
@@ -557,6 +557,37 @@ def _validate_final_report(value: Any, path: str) -> None:
557
  _require_type(artifacts["run_dir"], str, f"{path}.artifacts.run_dir")
558
  if artifacts["annotated_video_path"] is not None:
559
  _require_type(artifacts["annotated_video_path"], str, f"{path}.artifacts.annotated_video_path")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
561
 
562
  def _validate_run_manifest(value: Any, path: str) -> None:
 
557
  _require_type(artifacts["run_dir"], str, f"{path}.artifacts.run_dir")
558
  if artifacts["annotated_video_path"] is not None:
559
  _require_type(artifacts["annotated_video_path"], str, f"{path}.artifacts.annotated_video_path")
560
+ if "issue_thumbnail_paths" in artifacts:
561
+ _require_type(artifacts["issue_thumbnail_paths"], list, f"{path}.artifacts.issue_thumbnail_paths")
562
+ for index, thumbnail_value in enumerate(artifacts["issue_thumbnail_paths"]):
563
+ thumbnail_path = f"{path}.artifacts.issue_thumbnail_paths[{index}]"
564
+ thumbnail = _require_mapping(thumbnail_value, thumbnail_path)
565
+ _require_fields(thumbnail, {"issue", "rep_id", "frame", "path"}, thumbnail_path)
566
+ _require_type(thumbnail["issue"], str, f"{thumbnail_path}.issue")
567
+ _require_int(thumbnail["rep_id"], f"{thumbnail_path}.rep_id", minimum=1)
568
+ _require_int(thumbnail["frame"], f"{thumbnail_path}.frame", minimum=0)
569
+ _require_type(thumbnail["path"], str, f"{thumbnail_path}.path")
570
+ if "issue_clip_paths" in artifacts:
571
+ _require_type(artifacts["issue_clip_paths"], list, f"{path}.artifacts.issue_clip_paths")
572
+ for index, clip_value in enumerate(artifacts["issue_clip_paths"]):
573
+ clip_path = f"{path}.artifacts.issue_clip_paths[{index}]"
574
+ clip = _require_mapping(clip_value, clip_path)
575
+ _require_fields(
576
+ clip,
577
+ {"issue", "rep_id", "start_sec", "end_sec", "clip_start_sec", "clip_end_sec", "path"},
578
+ clip_path,
579
+ )
580
+ _require_type(clip["issue"], str, f"{clip_path}.issue")
581
+ _require_int(clip["rep_id"], f"{clip_path}.rep_id", minimum=1)
582
+ _require_number(clip["start_sec"], f"{clip_path}.start_sec", minimum=0)
583
+ _require_number(clip["end_sec"], f"{clip_path}.end_sec", minimum=0)
584
+ _require_number(clip["clip_start_sec"], f"{clip_path}.clip_start_sec", minimum=0)
585
+ _require_number(clip["clip_end_sec"], f"{clip_path}.clip_end_sec", minimum=0)
586
+ if clip["start_sec"] > clip["end_sec"]:
587
+ raise ContractValidationError(f"{clip_path} timestamps must be ordered")
588
+ if clip["clip_start_sec"] > clip["clip_end_sec"]:
589
+ raise ContractValidationError(f"{clip_path} clip timestamps must be ordered")
590
+ _require_type(clip["path"], str, f"{clip_path}.path")
591
 
592
 
593
  def _validate_run_manifest(value: Any, path: str) -> None:
src/pozify/exercises/shared/issue_marker.py CHANGED
@@ -149,6 +149,7 @@ def marker_from_group(
149
  rule.metric_name: round(peak_score.value, 4),
150
  "threshold": rule.threshold,
151
  "confidence": confidence(group, rep_item, variation),
 
152
  "variation_context": {
153
  "detected_variation": variation.detected_variation,
154
  "variation_confidence": variation.variation_confidence,
 
149
  rule.metric_name: round(peak_score.value, 4),
150
  "threshold": rule.threshold,
151
  "confidence": confidence(group, rep_item, variation),
152
+ "peak_frame": peak_score.frame.frame_index,
153
  "variation_context": {
154
  "detected_variation": variation.detected_variation,
155
  "variation_confidence": variation.variation_confidence,
src/pozify/pipeline.py CHANGED
@@ -94,7 +94,13 @@ def run_pipeline(
94
  issues = exercise.mark_issues(reps, analysis, variation)
95
  write_artifact("issue_markers.json", issues)
96
 
97
- annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
 
 
 
 
 
 
98
 
99
  pose_source = (
100
  cleaned_pose_sequence.frames[0].pose_quality.get("source")
@@ -127,7 +133,9 @@ def run_pipeline(
127
  "verification": to_dict(verification),
128
  "artifacts": {
129
  "run_dir": str(run_dir),
130
- "annotated_video_path": annotated_video_path,
 
 
131
  "rep_debug_path": str(run_dir / "rep_debug.json"),
132
  "analysis_mode": analysis_mode,
133
  "pose_source": pose_source,
@@ -146,7 +154,9 @@ def run_pipeline(
146
  return {
147
  "run_id": run_id,
148
  "run_dir": str(run_dir),
149
- "annotated_video_path": annotated_video_path,
 
 
150
  "manifest_path": str(run_dir / "manifest.json"),
151
  "final_report": final_report,
152
  }
 
94
  issues = exercise.mark_issues(reps, analysis, variation)
95
  write_artifact("issue_markers.json", issues)
96
 
97
+ render_artifacts = annotated_renderer.run(
98
+ manifest,
99
+ cleaned_pose_sequence,
100
+ reps,
101
+ issues,
102
+ run_dir,
103
+ )
104
 
105
  pose_source = (
106
  cleaned_pose_sequence.frames[0].pose_quality.get("source")
 
133
  "verification": to_dict(verification),
134
  "artifacts": {
135
  "run_dir": str(run_dir),
136
+ "annotated_video_path": render_artifacts.annotated_video_path,
137
+ "issue_thumbnail_paths": render_artifacts.issue_thumbnail_paths,
138
+ "issue_clip_paths": render_artifacts.issue_clip_paths,
139
  "rep_debug_path": str(run_dir / "rep_debug.json"),
140
  "analysis_mode": analysis_mode,
141
  "pose_source": pose_source,
 
154
  return {
155
  "run_id": run_id,
156
  "run_dir": str(run_dir),
157
+ "annotated_video_path": render_artifacts.annotated_video_path,
158
+ "issue_thumbnail_paths": render_artifacts.issue_thumbnail_paths,
159
+ "issue_clip_paths": render_artifacts.issue_clip_paths,
160
  "manifest_path": str(run_dir / "manifest.json"),
161
  "final_report": final_report,
162
  }
src/pozify/steps/annotated_renderer.py CHANGED
@@ -1,14 +1,16 @@
1
  from __future__ import annotations
2
 
 
3
  import json
4
  from pathlib import Path
 
5
  import shutil
6
  import subprocess
7
  from typing import Any
8
 
9
  import cv2
10
 
11
- from pozify.contracts import IssueMarkers, PoseSequence, Reps, VideoManifest
12
 
13
 
14
  SKELETON_EDGES = [
@@ -38,6 +40,18 @@ BT709_COLOR_ARGS = (
38
  "bt709",
39
  )
40
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  def _tool_path(name: str) -> str | None:
43
  return shutil.which(name)
@@ -224,16 +238,78 @@ def _frame_landmark_points(
224
  return points
225
 
226
 
227
- def _draw_pose(frame: Any, points: dict[str, tuple[int, int]]) -> None:
 
 
 
 
 
228
  for start_name, end_name in SKELETON_EDGES:
229
  start = points.get(start_name)
230
  end = points.get(end_name)
231
  if start is None or end is None:
232
  continue
233
- cv2.line(frame, start, end, (90, 220, 90), 2)
234
 
235
  for point in points.values():
236
- cv2.circle(frame, point, 3, (255, 240, 40), -1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
 
239
  def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
@@ -245,12 +321,225 @@ def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
245
  return boundaries
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  def _draw_overlays(
249
  frame: Any,
250
  frame_index: int,
251
  rep_count: int,
252
  issue_count: int,
253
  boundary_labels: dict[int, list[str]],
 
 
 
254
  ) -> None:
255
  cv2.putText(
256
  frame,
@@ -272,15 +561,31 @@ def _draw_overlays(
272
  2,
273
  cv2.LINE_AA,
274
  )
275
- labels = boundary_labels.get(frame_index, [])
 
 
 
 
 
 
 
 
 
 
 
276
  for offset, label in enumerate(labels):
 
 
 
 
 
277
  cv2.putText(
278
  frame,
279
  label,
280
  (16, 96 + offset * 28),
281
  cv2.FONT_HERSHEY_SIMPLEX,
282
  0.75,
283
- (80, 180, 255),
284
  2,
285
  cv2.LINE_AA,
286
  )
@@ -311,10 +616,15 @@ def run(
311
  reps: Reps,
312
  issues: IssueMarkers,
313
  run_dir: Path,
314
- ) -> str | None:
315
  if not manifest.analysis_allowed or not manifest.video_path:
316
- return manifest.video_path
 
 
 
 
317
 
 
318
  source_path = Path(manifest.video_path)
319
  color_metadata = _video_color_metadata(manifest.video_path)
320
  render_input_path = source_path
@@ -330,14 +640,22 @@ def run(
330
  capture.release()
331
  for temporary_path in temporary_paths:
332
  temporary_path.unlink(missing_ok=True)
333
- return manifest.video_path
 
 
 
 
334
 
335
  fps = manifest.fps if manifest.fps > 0 else 30.0
336
  width = manifest.width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
337
  height = manifest.height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
338
  if width <= 0 or height <= 0:
339
  capture.release()
340
- return manifest.video_path
 
 
 
 
341
 
342
  output_path = run_dir / "annotated_video.mp4"
343
  raw_output_path = run_dir / "annotated_video_raw.mp4" if _tool_path("ffmpeg") else output_path
@@ -346,13 +664,19 @@ def run(
346
  capture.release()
347
  for temporary_path in temporary_paths:
348
  temporary_path.unlink(missing_ok=True)
349
- return manifest.video_path
 
 
 
 
350
 
351
  pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
352
  ordered_pose_frames = sorted(pose_sequence.frames, key=lambda frame: frame.frame_index)
353
  pose_cursor = 0
354
  last_pose_frame = None
355
  boundary_labels = _rep_boundaries(reps)
 
 
356
 
357
  try:
358
  frame_index = 0
@@ -373,11 +697,25 @@ def run(
373
 
374
  exact_pose = pose_by_frame.get(frame_index)
375
  active_pose = exact_pose or last_pose_frame
 
376
  if active_pose is not None and active_pose.landmarks:
377
  points = _frame_landmark_points(active_pose.landmarks, width, height)
378
- _draw_pose(frame, points)
379
-
380
- _draw_overlays(frame, frame_index, len(reps.reps), len(issues.issues), boundary_labels)
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  writer.write(frame)
382
  frame_index += 1
383
  finally:
@@ -392,4 +730,15 @@ def run(
392
  raw_output_path.replace(output_path)
393
  raw_output_path.unlink(missing_ok=True)
394
 
395
- return str(output_path)
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from dataclasses import dataclass
4
  import json
5
  from pathlib import Path
6
+ import re
7
  import shutil
8
  import subprocess
9
  from typing import Any
10
 
11
  import cv2
12
 
13
+ from pozify.contracts import IssueMarker, IssueMarkers, PoseSequence, Reps, VideoManifest
14
 
15
 
16
  SKELETON_EDGES = [
 
40
  "bt709",
41
  )
42
 
43
+ NORMAL_EDGE_COLOR = (90, 220, 90)
44
+ NORMAL_JOINT_COLOR = (255, 240, 40)
45
+ ISSUE_EDGE_COLOR = (0, 130, 255)
46
+ ISSUE_JOINT_COLOR = (0, 40, 255)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RenderArtifacts:
51
+ annotated_video_path: str | None
52
+ issue_thumbnail_paths: list[dict[str, Any]]
53
+ issue_clip_paths: list[dict[str, Any]]
54
+
55
 
56
  def _tool_path(name: str) -> str | None:
57
  return shutil.which(name)
 
238
  return points
239
 
240
 
241
+ def _draw_pose(
242
+ frame: Any,
243
+ points: dict[str, tuple[int, int]],
244
+ highlighted_joints: set[str] | None = None,
245
+ ) -> None:
246
+ highlighted_joints = highlighted_joints or set()
247
  for start_name, end_name in SKELETON_EDGES:
248
  start = points.get(start_name)
249
  end = points.get(end_name)
250
  if start is None or end is None:
251
  continue
252
+ cv2.line(frame, start, end, NORMAL_EDGE_COLOR, 2)
253
 
254
  for point in points.values():
255
+ cv2.circle(frame, point, 3, NORMAL_JOINT_COLOR, -1)
256
+
257
+ if not highlighted_joints:
258
+ return
259
+
260
+ for start_name, end_name in SKELETON_EDGES:
261
+ if start_name not in highlighted_joints and end_name not in highlighted_joints:
262
+ continue
263
+ start = points.get(start_name)
264
+ end = points.get(end_name)
265
+ if start is None or end is None:
266
+ continue
267
+ cv2.line(frame, start, end, ISSUE_EDGE_COLOR, 4)
268
+
269
+ for name in highlighted_joints:
270
+ point = points.get(name)
271
+ if point is not None:
272
+ cv2.circle(frame, point, 6, ISSUE_JOINT_COLOR, -1)
273
+
274
+
275
+ def _issue_angle_label(issue: IssueMarker) -> str | None:
276
+ for key, value in issue.evidence.items():
277
+ if not key.endswith("_deg") or isinstance(value, bool) or not isinstance(value, int | float):
278
+ continue
279
+ return f"{key.removesuffix('_deg').replace('_', ' ')} {round(float(value))} deg"
280
+ return None
281
+
282
+
283
+ def _issue_label_anchor(issue: IssueMarker, points: dict[str, tuple[int, int]]) -> tuple[int, int]:
284
+ anchors = [points[name] for name in issue.affected_joints if name in points]
285
+ if not anchors:
286
+ return 16, 132
287
+ x = round(sum(point[0] for point in anchors) / len(anchors))
288
+ y = round(sum(point[1] for point in anchors) / len(anchors))
289
+ return x + 10, max(24, y - 10)
290
+
291
+
292
+ def _draw_angle_labels(
293
+ frame: Any,
294
+ points: dict[str, tuple[int, int]],
295
+ active_issues: list[IssueMarker],
296
+ ) -> None:
297
+ for offset, issue in enumerate(active_issues):
298
+ label = _issue_angle_label(issue)
299
+ if label is None:
300
+ continue
301
+ anchor = _issue_label_anchor(issue, points)
302
+ text_anchor = (anchor[0], anchor[1] + offset * 28)
303
+ cv2.putText(
304
+ frame,
305
+ label,
306
+ text_anchor,
307
+ cv2.FONT_HERSHEY_SIMPLEX,
308
+ 0.72,
309
+ ISSUE_EDGE_COLOR,
310
+ 2,
311
+ cv2.LINE_AA,
312
+ )
313
 
314
 
315
  def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
 
321
  return boundaries
322
 
323
 
324
+ def _rep_phase(frame_index: int, reps: Reps) -> str | None:
325
+ for rep in reps.reps:
326
+ if not rep.start_frame <= frame_index <= rep.end_frame:
327
+ continue
328
+ if frame_index == rep.start_frame:
329
+ phase = "start"
330
+ elif frame_index == rep.mid_frame:
331
+ phase = "mid"
332
+ elif frame_index == rep.end_frame:
333
+ phase = "end"
334
+ elif frame_index < rep.mid_frame:
335
+ phase = "lowering"
336
+ else:
337
+ phase = "rising"
338
+ return f"rep {rep.rep_id} {phase}"
339
+ return None
340
+
341
+
342
+ def _active_issues(frame_index: int, issues: IssueMarkers) -> list[IssueMarker]:
343
+ return [
344
+ issue
345
+ for issue in issues.issues
346
+ if issue.start_frame <= frame_index <= issue.end_frame
347
+ ]
348
+
349
+
350
+ def _highlighted_joints(active_issues: list[IssueMarker]) -> set[str]:
351
+ joints: set[str] = set()
352
+ for issue in active_issues:
353
+ joints.update(issue.affected_joints)
354
+ return joints
355
+
356
+
357
+ def _primary_issue(active_issues: list[IssueMarker]) -> IssueMarker | None:
358
+ if not active_issues:
359
+ return None
360
+ return max(active_issues, key=lambda issue: issue.severity)
361
+
362
+
363
+ def _confidence_warning(active_issues: list[IssueMarker], warnings: list[str]) -> str | None:
364
+ confidences = [
365
+ float(issue.evidence["confidence"])
366
+ for issue in active_issues
367
+ if isinstance(issue.evidence.get("confidence"), int | float)
368
+ ]
369
+ if confidences and min(confidences) < 0.55:
370
+ return "Low confidence issue evidence"
371
+ if warnings:
372
+ return "Camera warning: " + ", ".join(warnings[:2])
373
+ return None
374
+
375
+
376
+ def _thumbnail_frame(issue: IssueMarker) -> int:
377
+ peak_frame = issue.evidence.get("peak_frame")
378
+ if isinstance(peak_frame, int) and peak_frame >= 0:
379
+ return peak_frame
380
+ return round((issue.start_frame + issue.end_frame) / 2)
381
+
382
+
383
+ def _slug(value: str) -> str:
384
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower()
385
+ return slug or "issue"
386
+
387
+
388
+ def _thumbnail_targets(issues: IssueMarkers, run_dir: Path) -> dict[int, list[dict[str, Any]]]:
389
+ targets: dict[int, list[dict[str, Any]]] = {}
390
+ for index, issue in enumerate(issues.issues, start=1):
391
+ frame = _thumbnail_frame(issue)
392
+ filename = f"issue_thumbnail_{index}_{_slug(issue.issue)}.jpg"
393
+ targets.setdefault(frame, []).append(
394
+ {
395
+ "issue": issue.issue,
396
+ "rep_id": issue.rep_id,
397
+ "frame": frame,
398
+ "path": str(run_dir / filename),
399
+ }
400
+ )
401
+ return targets
402
+
403
+
404
+ def _clip_metadata(issue: IssueMarker, index: int, run_dir: Path) -> dict[str, Any]:
405
+ filename = f"issue_clip_{index}_{_slug(issue.issue)}.mp4"
406
+ clip_start_sec = max(0.0, float(issue.start_sec) - 1.0)
407
+ clip_end_sec = max(clip_start_sec + 0.1, float(issue.end_sec) + 1.0)
408
+ return {
409
+ "issue": issue.issue,
410
+ "rep_id": issue.rep_id,
411
+ "start_sec": issue.start_sec,
412
+ "end_sec": issue.end_sec,
413
+ "clip_start_sec": round(clip_start_sec, 3),
414
+ "clip_end_sec": round(clip_end_sec, 3),
415
+ "path": str(run_dir / filename),
416
+ }
417
+
418
+
419
+ def _issue_clip_paths(
420
+ source_path: Path,
421
+ issues: IssueMarkers,
422
+ run_dir: Path,
423
+ fps: float,
424
+ width: int,
425
+ height: int,
426
+ ) -> list[dict[str, Any]]:
427
+ clips: list[dict[str, Any]] = []
428
+ for index, issue in enumerate(issues.issues, start=1):
429
+ clip = _clip_metadata(issue, index, run_dir)
430
+ output_path = Path(clip["path"])
431
+ start_sec = float(clip["clip_start_sec"])
432
+ end_sec = float(clip["clip_end_sec"])
433
+ written = _write_issue_clip_ffmpeg(source_path, output_path, start_sec, end_sec)
434
+ if not written:
435
+ written = _write_issue_clip_cv2(
436
+ source_path,
437
+ output_path,
438
+ start_sec,
439
+ end_sec,
440
+ fps,
441
+ width,
442
+ height,
443
+ )
444
+ if written:
445
+ clips.append(clip)
446
+ return clips
447
+
448
+
449
+ def _write_issue_clip_ffmpeg(
450
+ source_path: Path,
451
+ output_path: Path,
452
+ start_sec: float,
453
+ end_sec: float,
454
+ ) -> bool:
455
+ ffmpeg = _tool_path("ffmpeg")
456
+ if ffmpeg is None:
457
+ return False
458
+
459
+ duration = max(0.1, end_sec - start_sec)
460
+ command = [
461
+ ffmpeg,
462
+ "-y",
463
+ "-v",
464
+ "error",
465
+ "-ss",
466
+ f"{start_sec:.3f}",
467
+ "-i",
468
+ str(source_path),
469
+ "-t",
470
+ f"{duration:.3f}",
471
+ "-an",
472
+ "-c:v",
473
+ "libx264",
474
+ "-preset",
475
+ "veryfast",
476
+ "-crf",
477
+ "22",
478
+ "-pix_fmt",
479
+ "yuv420p",
480
+ *BT709_COLOR_ARGS,
481
+ str(output_path),
482
+ ]
483
+ try:
484
+ subprocess.run(command, check=True, capture_output=True, timeout=60)
485
+ except (subprocess.SubprocessError, OSError):
486
+ return False
487
+ return output_path.exists() and output_path.stat().st_size > 0
488
+
489
+
490
+ def _write_issue_clip_cv2(
491
+ source_path: Path,
492
+ output_path: Path,
493
+ start_sec: float,
494
+ end_sec: float,
495
+ fps: float,
496
+ width: int,
497
+ height: int,
498
+ ) -> bool:
499
+ capture = cv2.VideoCapture(str(source_path))
500
+ if not capture.isOpened():
501
+ capture.release()
502
+ return False
503
+
504
+ source_fps = fps if fps > 0 else capture.get(cv2.CAP_PROP_FPS) or 30.0
505
+ source_width = width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
506
+ source_height = height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
507
+ if source_width <= 0 or source_height <= 0:
508
+ capture.release()
509
+ return False
510
+
511
+ start_frame = max(0, round(start_sec * source_fps))
512
+ end_frame = max(start_frame + 1, round(end_sec * source_fps))
513
+ capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
514
+ writer, _codec = _open_video_writer(output_path, source_fps, source_width, source_height)
515
+ if writer is None:
516
+ capture.release()
517
+ return False
518
+
519
+ try:
520
+ frame_index = start_frame
521
+ while frame_index <= end_frame:
522
+ ok, frame = capture.read()
523
+ if not ok or frame is None:
524
+ break
525
+ writer.write(frame)
526
+ frame_index += 1
527
+ finally:
528
+ writer.release()
529
+ capture.release()
530
+
531
+ return output_path.exists() and output_path.stat().st_size > 0
532
+
533
+
534
  def _draw_overlays(
535
  frame: Any,
536
  frame_index: int,
537
  rep_count: int,
538
  issue_count: int,
539
  boundary_labels: dict[int, list[str]],
540
+ phase_label: str | None,
541
+ active_issues: list[IssueMarker],
542
+ quality_warnings: list[str],
543
  ) -> None:
544
  cv2.putText(
545
  frame,
 
561
  2,
562
  cv2.LINE_AA,
563
  )
564
+ labels = list(boundary_labels.get(frame_index, []))
565
+ if phase_label:
566
+ labels.append(phase_label)
567
+ primary_issue = _primary_issue(active_issues)
568
+ if primary_issue is not None:
569
+ labels.append(
570
+ f"{primary_issue.issue} severity {round(primary_issue.severity * 100)}%"
571
+ )
572
+ warning = _confidence_warning(active_issues, quality_warnings)
573
+ if warning is not None:
574
+ labels.append(warning)
575
+
576
  for offset, label in enumerate(labels):
577
+ color = (80, 180, 255)
578
+ if primary_issue is not None and label.startswith(primary_issue.issue):
579
+ color = ISSUE_EDGE_COLOR
580
+ elif label.startswith("Low confidence") or label.startswith("Camera warning"):
581
+ color = (0, 210, 255)
582
  cv2.putText(
583
  frame,
584
  label,
585
  (16, 96 + offset * 28),
586
  cv2.FONT_HERSHEY_SIMPLEX,
587
  0.75,
588
+ color,
589
  2,
590
  cv2.LINE_AA,
591
  )
 
616
  reps: Reps,
617
  issues: IssueMarkers,
618
  run_dir: Path,
619
+ ) -> RenderArtifacts:
620
  if not manifest.analysis_allowed or not manifest.video_path:
621
+ return RenderArtifacts(
622
+ annotated_video_path=manifest.video_path,
623
+ issue_thumbnail_paths=[],
624
+ issue_clip_paths=[],
625
+ )
626
 
627
+ run_dir.mkdir(parents=True, exist_ok=True)
628
  source_path = Path(manifest.video_path)
629
  color_metadata = _video_color_metadata(manifest.video_path)
630
  render_input_path = source_path
 
640
  capture.release()
641
  for temporary_path in temporary_paths:
642
  temporary_path.unlink(missing_ok=True)
643
+ return RenderArtifacts(
644
+ annotated_video_path=manifest.video_path,
645
+ issue_thumbnail_paths=[],
646
+ issue_clip_paths=[],
647
+ )
648
 
649
  fps = manifest.fps if manifest.fps > 0 else 30.0
650
  width = manifest.width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
651
  height = manifest.height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
652
  if width <= 0 or height <= 0:
653
  capture.release()
654
+ return RenderArtifacts(
655
+ annotated_video_path=manifest.video_path,
656
+ issue_thumbnail_paths=[],
657
+ issue_clip_paths=[],
658
+ )
659
 
660
  output_path = run_dir / "annotated_video.mp4"
661
  raw_output_path = run_dir / "annotated_video_raw.mp4" if _tool_path("ffmpeg") else output_path
 
664
  capture.release()
665
  for temporary_path in temporary_paths:
666
  temporary_path.unlink(missing_ok=True)
667
+ return RenderArtifacts(
668
+ annotated_video_path=manifest.video_path,
669
+ issue_thumbnail_paths=[],
670
+ issue_clip_paths=[],
671
+ )
672
 
673
  pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
674
  ordered_pose_frames = sorted(pose_sequence.frames, key=lambda frame: frame.frame_index)
675
  pose_cursor = 0
676
  last_pose_frame = None
677
  boundary_labels = _rep_boundaries(reps)
678
+ thumbnail_targets = _thumbnail_targets(issues, run_dir)
679
+ issue_thumbnail_paths: list[dict[str, Any]] = []
680
 
681
  try:
682
  frame_index = 0
 
697
 
698
  exact_pose = pose_by_frame.get(frame_index)
699
  active_pose = exact_pose or last_pose_frame
700
+ active_issues = _active_issues(frame_index, issues)
701
  if active_pose is not None and active_pose.landmarks:
702
  points = _frame_landmark_points(active_pose.landmarks, width, height)
703
+ _draw_pose(frame, points, _highlighted_joints(active_issues))
704
+ _draw_angle_labels(frame, points, active_issues)
705
+
706
+ _draw_overlays(
707
+ frame,
708
+ frame_index,
709
+ len(reps.reps),
710
+ len(issues.issues),
711
+ boundary_labels,
712
+ _rep_phase(frame_index, reps),
713
+ active_issues,
714
+ manifest.quality_warnings,
715
+ )
716
+ for thumbnail in thumbnail_targets.get(frame_index, []):
717
+ if cv2.imwrite(thumbnail["path"], frame):
718
+ issue_thumbnail_paths.append(thumbnail)
719
  writer.write(frame)
720
  frame_index += 1
721
  finally:
 
730
  raw_output_path.replace(output_path)
731
  raw_output_path.unlink(missing_ok=True)
732
 
733
+ return RenderArtifacts(
734
+ annotated_video_path=str(output_path),
735
+ issue_thumbnail_paths=issue_thumbnail_paths,
736
+ issue_clip_paths=_issue_clip_paths(
737
+ output_path,
738
+ issues,
739
+ run_dir,
740
+ fps,
741
+ width,
742
+ height,
743
+ ),
744
+ )
tests/test_annotated_renderer.py CHANGED
@@ -10,7 +10,15 @@ import numpy as np
10
 
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
 
13
- from pozify.contracts import IssueMarkers, PoseFrame, PoseSequence, Rep, Reps, VideoManifest
 
 
 
 
 
 
 
 
14
  from pozify.steps import annotated_renderer
15
 
16
 
@@ -84,7 +92,7 @@ class AnnotatedRendererTests(unittest.TestCase):
84
  partial_reps=[],
85
  )
86
 
87
- output_path = annotated_renderer.run(
88
  manifest,
89
  pose_sequence,
90
  reps,
@@ -92,8 +100,116 @@ class AnnotatedRendererTests(unittest.TestCase):
92
  Path(self.temp_dir.name),
93
  )
94
 
95
- self.assertIsNotNone(output_path)
96
- self.assertTrue(Path(str(output_path)).exists())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  def test_hdr_metadata_requires_sdr_conversion(self) -> None:
99
  self.assertTrue(
 
10
 
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
 
13
+ from pozify.contracts import (
14
+ IssueMarker,
15
+ IssueMarkers,
16
+ PoseFrame,
17
+ PoseSequence,
18
+ Rep,
19
+ Reps,
20
+ VideoManifest,
21
+ )
22
  from pozify.steps import annotated_renderer
23
 
24
 
 
92
  partial_reps=[],
93
  )
94
 
95
+ result = annotated_renderer.run(
96
  manifest,
97
  pose_sequence,
98
  reps,
 
100
  Path(self.temp_dir.name),
101
  )
102
 
103
+ self.assertIsNotNone(result.annotated_video_path)
104
+ self.assertTrue(Path(str(result.annotated_video_path)).exists())
105
+ self.assertEqual(result.issue_thumbnail_paths, [])
106
+ self.assertEqual(result.issue_clip_paths, [])
107
+
108
+ def test_renderer_highlights_active_issue_and_writes_thumbnail(self) -> None:
109
+ video_path = self._write_video()
110
+ manifest = VideoManifest(
111
+ video_path=str(video_path),
112
+ fps=30.0,
113
+ duration_sec=0.2,
114
+ total_frames=6,
115
+ sampled_frames=6,
116
+ width=320,
117
+ height=240,
118
+ codec="mp4v",
119
+ container="mp4",
120
+ brightness_mean=100.0,
121
+ blur_laplacian_var=100.0,
122
+ quality_warnings=[],
123
+ analysis_allowed=True,
124
+ )
125
+ pose_sequence = PoseSequence(
126
+ frames=[_frame(index) for index in range(6)],
127
+ normalized=True,
128
+ smoothing_method="exponential_smoothing",
129
+ pose_valid_ratio=1.0,
130
+ )
131
+ reps = Reps(
132
+ exercise="push_up",
133
+ reps=[Rep(1, 0, 2, 5, 0.0, 0.067, 0.167)],
134
+ partial_reps=[],
135
+ )
136
+ issues = IssueMarkers(
137
+ issues=[
138
+ IssueMarker(
139
+ rep_id=1,
140
+ issue="hip_sag",
141
+ severity=0.82,
142
+ start_frame=2,
143
+ end_frame=4,
144
+ start_sec=0.067,
145
+ end_sec=0.133,
146
+ affected_joints=["left_hip"],
147
+ evidence={
148
+ "body_line_score": 0.42,
149
+ "threshold": 0.6,
150
+ "confidence": 0.54,
151
+ "peak_frame": 3,
152
+ },
153
+ )
154
+ ]
155
+ )
156
+
157
+ result = annotated_renderer.run(
158
+ manifest,
159
+ pose_sequence,
160
+ reps,
161
+ issues,
162
+ Path(self.temp_dir.name),
163
+ )
164
+
165
+ self.assertIsNotNone(result.annotated_video_path)
166
+ self.assertEqual(len(result.issue_thumbnail_paths), 1)
167
+ self.assertTrue(Path(result.issue_thumbnail_paths[0]["path"]).exists())
168
+ self.assertEqual(len(result.issue_clip_paths), 1)
169
+ self.assertTrue(Path(result.issue_clip_paths[0]["path"]).exists())
170
+ self.assertEqual(result.issue_clip_paths[0]["clip_start_sec"], 0.0)
171
+ self.assertGreater(result.issue_clip_paths[0]["clip_end_sec"], 1.0)
172
+
173
+ capture = cv2.VideoCapture(str(result.annotated_video_path))
174
+ frames = []
175
+ for _ in range(4):
176
+ ok, frame = capture.read()
177
+ self.assertTrue(ok)
178
+ frames.append(frame)
179
+ capture.release()
180
+
181
+ left_hip_x, left_hip_y = 134, 132
182
+ active_roi = frames[3][left_hip_y - 8 : left_hip_y + 9, left_hip_x - 8 : left_hip_x + 9]
183
+ inactive_roi = frames[0][left_hip_y - 8 : left_hip_y + 9, left_hip_x - 8 : left_hip_x + 9]
184
+ active_red_pixels = np.count_nonzero(
185
+ (active_roi[:, :, 2] > 180) & (active_roi[:, :, 1] < 180) & (active_roi[:, :, 0] < 120)
186
+ )
187
+ inactive_red_pixels = np.count_nonzero(
188
+ (inactive_roi[:, :, 2] > 180) & (inactive_roi[:, :, 1] < 180) & (inactive_roi[:, :, 0] < 120)
189
+ )
190
+ self.assertGreater(active_red_pixels, inactive_red_pixels * 4)
191
+
192
+ def test_angle_label_uses_degree_evidence(self) -> None:
193
+ issue = IssueMarker(
194
+ rep_id=1,
195
+ issue="incomplete_depth",
196
+ severity=0.7,
197
+ start_frame=2,
198
+ end_frame=4,
199
+ start_sec=0.067,
200
+ end_sec=0.133,
201
+ affected_joints=["left_elbow", "right_elbow"],
202
+ evidence={
203
+ "elbow_angle_deg": 123.4,
204
+ "threshold": 115.0,
205
+ "confidence": 0.74,
206
+ },
207
+ )
208
+
209
+ self.assertEqual(
210
+ annotated_renderer._issue_angle_label(issue),
211
+ "elbow angle 123 deg",
212
+ )
213
 
214
  def test_hdr_metadata_requires_sdr_conversion(self) -> None:
215
  self.assertTrue(
tests/test_issue_marker.py CHANGED
@@ -162,6 +162,9 @@ class IssueMarkerTests(unittest.TestCase):
162
  self.assertGreaterEqual(hip_sag.end_frame - hip_sag.start_frame, 2)
163
  self.assertLess(hip_sag.evidence["body_line_score"], hip_sag.evidence["threshold"])
164
  self.assertIn("confidence", hip_sag.evidence)
 
 
 
165
  self.assertEqual(
166
  hip_sag.evidence["variation_context"]["detected_variation"],
167
  "wide_grip_push_up",
 
162
  self.assertGreaterEqual(hip_sag.end_frame - hip_sag.start_frame, 2)
163
  self.assertLess(hip_sag.evidence["body_line_score"], hip_sag.evidence["threshold"])
164
  self.assertIn("confidence", hip_sag.evidence)
165
+ self.assertIn("peak_frame", hip_sag.evidence)
166
+ self.assertGreaterEqual(hip_sag.evidence["peak_frame"], hip_sag.start_frame)
167
+ self.assertLessEqual(hip_sag.evidence["peak_frame"], hip_sag.end_frame)
168
  self.assertEqual(
169
  hip_sag.evidence["variation_context"]["detected_variation"],
170
  "wide_grip_push_up",
tests/test_pipeline_contracts.py CHANGED
@@ -136,6 +136,11 @@ class PipelineContractTests(unittest.TestCase):
136
  self.assertTrue(artifact_path.exists(), artifact_name)
137
  payload = json.loads(artifact_path.read_text(encoding="utf-8"))
138
  self.assertEqual(sorted(payload.keys()), keys, artifact_name)
 
 
 
 
 
139
 
140
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
141
  self.assertTrue(manifest["mock_mode"])
 
136
  self.assertTrue(artifact_path.exists(), artifact_name)
137
  payload = json.loads(artifact_path.read_text(encoding="utf-8"))
138
  self.assertEqual(sorted(payload.keys()), keys, artifact_name)
139
+ if artifact_name == "final_report.json":
140
+ self.assertIn("issue_thumbnail_paths", payload["artifacts"])
141
+ self.assertIsInstance(payload["artifacts"]["issue_thumbnail_paths"], list)
142
+ self.assertIn("issue_clip_paths", payload["artifacts"])
143
+ self.assertIsInstance(payload["artifacts"]["issue_clip_paths"], list)
144
 
145
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
146
  self.assertTrue(manifest["mock_mode"])
web/app.js CHANGED
@@ -1,4 +1,8 @@
1
- import React, { useEffect, useMemo, useState } from "https://esm.sh/react@18.2.0";
 
 
 
 
2
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
3
 
4
  const h = React.createElement;
@@ -6,7 +10,13 @@ const h = React.createElement;
6
  const defaults = {
7
  description:
8
  "Upload a short workout clip, tune the athlete context, and generate an annotated form-review report with structured artifacts.",
9
- goals: ["strength", "hypertrophy", "endurance", "mobility", "beginner_practice"],
 
 
 
 
 
 
10
  experience_levels: ["beginner", "intermediate"],
11
  exercises: ["auto"],
12
  limitations: ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
@@ -33,12 +43,26 @@ function SelectField({ labelText, value, onChange, options }) {
33
  h(
34
  "select",
35
  { value, onChange: (event) => onChange(event.target.value) },
36
- options.map((option) => h("option", { key: option, value: option }, label(option))),
 
 
37
  ),
38
  );
39
  }
40
 
41
- function Summary({ result }) {
 
 
 
 
 
 
 
 
 
 
 
 
42
  if (!result) {
43
  return h(
44
  "section",
@@ -54,10 +78,10 @@ function Summary({ result }) {
54
  const issues = report.issue_markers?.issues || [];
55
  const stats = [
56
  ["Exercise", report.exercise.exercise],
 
57
  ["Variation", report.variation.detected_variation],
58
  ["Reps", String(report.reps.reps.length)],
59
  ["Issues", String(issues.length)],
60
- ["Mode", report.artifacts.analysis_mode],
61
  ];
62
 
63
  return h(
@@ -69,7 +93,12 @@ function Summary({ result }) {
69
  "div",
70
  { className: "stat-grid" },
71
  stats.map(([name, value]) =>
72
- h("div", { className: "stat", key: name }, h("span", null, name), h("strong", null, value)),
 
 
 
 
 
73
  ),
74
  ),
75
  h(
@@ -79,55 +108,250 @@ function Summary({ result }) {
79
  h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
80
  h(NoteList, { title: "Next session", items: summary.next_session_plan }),
81
  ),
82
- h(IssueTimeline, { issues }),
83
  warnings.length
84
  ? h(
85
  "div",
86
  { className: "quality-list" },
87
- warnings.map((warning) => h("span", { key: warning }, label(warning))),
 
 
88
  )
89
- : h("div", { className: "quality-list" }, h("span", null, "No quality warnings")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  );
91
  }
92
 
93
  function issueEvidence(issue) {
94
  const entries = Object.entries(issue.evidence || {}).filter(
95
  ([key, value]) =>
96
- !["threshold", "confidence", "variation_context", "supporting_frames", "fallback"].includes(key) &&
97
- typeof value !== "object",
 
 
 
 
 
98
  );
99
  const [metric, value] = entries[0] || ["metric", "n/a"];
100
  return `${label(metric)} ${value} vs ${issue.evidence?.threshold ?? "n/a"}`;
101
  }
102
 
103
- function IssueTimeline({ issues }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  return h(
105
  "section",
106
- { className: "issue-timeline", "aria-label": "Issue timeline" },
107
  h(
108
  "div",
109
  { className: "timeline-head" },
110
  h("h3", null, "Issue timeline"),
111
- h("span", null, issues.length ? `${issues.length} interval${issues.length === 1 ? "" : "s"}` : "clear"),
 
 
 
 
 
 
112
  ),
113
  issues.length
114
  ? h(
115
  "div",
116
- { className: "timeline-list" },
117
  issues.map((issue, index) =>
118
  h(
119
  "article",
120
- { className: "timeline-item", key: `${issue.rep_id}-${issue.issue}-${index}` },
 
 
 
 
121
  h(
122
  "div",
123
  { className: "timeline-main" },
124
  h("strong", null, label(issue.issue)),
125
- h("span", null, `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`),
 
 
 
 
 
126
  ),
127
  h(
128
  "div",
129
  { className: "timeline-meta" },
130
- h("span", null, `severity ${Math.round(issue.severity * 100)}%`),
 
 
 
 
 
 
 
 
 
131
  h("span", null, issueEvidence(issue)),
132
  h("span", null, issue.affected_joints.map(label).join(", ")),
133
  ),
@@ -138,12 +362,128 @@ function IssueTimeline({ issues }) {
138
  );
139
  }
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  function NoteList({ title, items }) {
142
  return h(
143
  "article",
144
  { className: "note" },
145
  h("h3", null, title),
146
- h("ul", null, items.map((item) => h("li", { key: item }, item))),
 
 
 
 
147
  );
148
  }
149
 
@@ -157,11 +497,14 @@ function App() {
157
  const [equipment, setEquipment] = useState("bodyweight");
158
  const [limitations, setLimitations] = useState([]);
159
  const [result, setResult] = useState(null);
160
- const [activeTab, setActiveTab] = useState("json");
161
  const [status, setStatus] = useState("idle");
162
  const [error, setError] = useState("");
163
 
164
- const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
 
 
 
165
 
166
  useEffect(() => {
167
  fetch("/api/config")
@@ -178,7 +521,9 @@ function App() {
178
 
179
  function toggleLimitation(value) {
180
  setLimitations((current) =>
181
- current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
 
 
182
  );
183
  }
184
 
@@ -198,7 +543,10 @@ function App() {
198
  payload.append("equipment", equipment);
199
 
200
  try {
201
- const response = await fetch("/api/analyze", { method: "POST", body: payload });
 
 
 
202
  const body = await response.json();
203
  if (!response.ok) throw new Error(body.detail || "Analysis failed.");
204
  setResult(body);
@@ -218,16 +566,35 @@ function App() {
218
  h(
219
  "div",
220
  { className: "hero-content" },
221
- h("p", { className: "eyebrow" }, "Pose intelligence for coached training"),
 
 
 
 
222
  h("h1", null, "Pozify"),
223
  h("p", null, config.description),
224
  ),
225
  h(
226
  "div",
227
  { className: "hero-metrics", "aria-label": "Pipeline highlights" },
228
- h("div", { className: "metric" }, h("strong", null, "33"), h("span", null, "pose landmarks")),
229
- h("div", { className: "metric" }, h("strong", null, "60s"), h("span", null, "clip ceiling")),
230
- h("div", { className: "metric" }, h("strong", null, "JSON"), h("span", null, "audit trail")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  ),
232
  ),
233
  h(
@@ -239,8 +606,17 @@ function App() {
239
  h(
240
  "div",
241
  { className: "panel-head" },
242
- h("div", null, h("h2", null, "Session setup"), h("p", null, "Movement context")),
243
- h("span", { className: "status-pill" }, status === "running" ? "Analyzing" : "Ready"),
 
 
 
 
 
 
 
 
 
244
  ),
245
  h(
246
  "label",
@@ -251,7 +627,11 @@ function App() {
251
  onChange: (event) => setFile(event.target.files?.[0] || null),
252
  }),
253
  previewUrl
254
- ? h("video", { className: "dropzone-preview", src: previewUrl, controls: true })
 
 
 
 
255
  : h(
256
  "span",
257
  { className: "dropzone-empty" },
@@ -263,7 +643,12 @@ function App() {
263
  h(
264
  "div",
265
  { className: "form-grid" },
266
- h(SelectField, { labelText: "Goal", value: goal, onChange: setGoal, options: config.goals }),
 
 
 
 
 
267
  h(SelectField, {
268
  labelText: "Experience",
269
  value: experience,
@@ -314,7 +699,11 @@ function App() {
314
  ),
315
  ),
316
  ),
317
- h("button", { className: "primary", disabled: status === "running" }, status === "running" ? "Analyzing..." : "Analyze Form"),
 
 
 
 
318
  error ? h("div", { className: "error" }, error) : null,
319
  ),
320
  h(
@@ -323,14 +712,25 @@ function App() {
323
  h(
324
  "div",
325
  { className: "panel-head" },
326
- h("div", null, h("h2", null, "Review output"), h("p", null, "Annotated movement")),
327
- result ? h("span", { className: "status-pill" }, result.run_id) : null,
 
 
 
 
 
 
 
328
  ),
329
  h(
330
  "div",
331
  { className: "result-stage" },
332
  result?.annotated_video_url
333
- ? h("video", { className: "result-video", src: result.annotated_video_url, controls: true })
 
 
 
 
334
  : h(
335
  "div",
336
  { className: "result-empty" },
@@ -340,41 +740,7 @@ function App() {
340
  ),
341
  ),
342
  ),
343
- h(Summary, { result }),
344
- h(
345
- "div",
346
- { className: "tabs", role: "tablist", "aria-label": "Artifacts" },
347
- ["json", "artifacts"].map((tab) =>
348
- h(
349
- "button",
350
- {
351
- className: `tab${activeTab === tab ? " active" : ""}`,
352
- key: tab,
353
- onClick: () => setActiveTab(tab),
354
- type: "button",
355
- },
356
- tab === "json" ? "Final report JSON" : "Artifact links",
357
- ),
358
- ),
359
- ),
360
- h(
361
- "section",
362
- { className: "artifact-panel" },
363
- activeTab === "json"
364
- ? h("pre", { className: "json-block" }, result ? JSON.stringify(result.report, null, 2) : "{}")
365
- : h(
366
- "div",
367
- { className: "artifact-links" },
368
- result
369
- ? [
370
- h("a", { className: "artifact-link", key: "report", href: result.final_report_url }, "final_report.json", h("span", null, "open")),
371
- result.annotated_video_url
372
- ? h("a", { className: "artifact-link", key: "video", href: result.annotated_video_url }, "annotated_video.mp4", h("span", null, "open"))
373
- : null,
374
- ]
375
- : h("p", null, "Artifacts appear after analysis."),
376
- ),
377
- ),
378
  );
379
  }
380
 
 
1
+ import React, {
2
+ useEffect,
3
+ useMemo,
4
+ useState,
5
+ } from "https://esm.sh/react@18.2.0";
6
  import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
7
 
8
  const h = React.createElement;
 
10
  const defaults = {
11
  description:
12
  "Upload a short workout clip, tune the athlete context, and generate an annotated form-review report with structured artifacts.",
13
+ goals: [
14
+ "strength",
15
+ "hypertrophy",
16
+ "endurance",
17
+ "mobility",
18
+ "beginner_practice",
19
+ ],
20
  experience_levels: ["beginner", "intermediate"],
21
  exercises: ["auto"],
22
  limitations: ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
 
43
  h(
44
  "select",
45
  { value, onChange: (event) => onChange(event.target.value) },
46
+ options.map((option) =>
47
+ h("option", { key: option, value: option }, label(option)),
48
+ ),
49
  ),
50
  );
51
  }
52
 
53
+ function formatValue(value) {
54
+ if (typeof value === "number")
55
+ return Number.isInteger(value) ? String(value) : value.toFixed(3);
56
+ if (Array.isArray(value)) return value.map(formatValue).join(", ");
57
+ if (value && typeof value === "object") return JSON.stringify(value);
58
+ return value ?? "n/a";
59
+ }
60
+
61
+ function percent(value) {
62
+ return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
63
+ }
64
+
65
+ function SummaryTab({ result }) {
66
  if (!result) {
67
  return h(
68
  "section",
 
78
  const issues = report.issue_markers?.issues || [];
79
  const stats = [
80
  ["Exercise", report.exercise.exercise],
81
+ ["Confidence", percent(report.exercise.confidence)],
82
  ["Variation", report.variation.detected_variation],
83
  ["Reps", String(report.reps.reps.length)],
84
  ["Issues", String(issues.length)],
 
85
  ];
86
 
87
  return h(
 
93
  "div",
94
  { className: "stat-grid" },
95
  stats.map(([name, value]) =>
96
+ h(
97
+ "div",
98
+ { className: "stat", key: name },
99
+ h("span", null, name),
100
+ h("strong", null, value),
101
+ ),
102
  ),
103
  ),
104
  h(
 
108
  h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
109
  h(NoteList, { title: "Next session", items: summary.next_session_plan }),
110
  ),
 
111
  warnings.length
112
  ? h(
113
  "div",
114
  { className: "quality-list" },
115
+ warnings.map((warning) =>
116
+ h("span", { key: warning }, label(warning)),
117
+ ),
118
  )
119
+ : h(
120
+ "div",
121
+ { className: "quality-list" },
122
+ h("span", null, "No quality warnings"),
123
+ ),
124
+ );
125
+ }
126
+
127
+ function MetricsTab({ result }) {
128
+ if (!result)
129
+ return h(
130
+ "section",
131
+ { className: "summary" },
132
+ h("h2", null, "Movement metrics"),
133
+ h("p", null, "Metrics appear after analysis."),
134
+ );
135
+ const report = result.report;
136
+ const aggregate = Object.entries(
137
+ report.rep_analysis?.aggregate_metrics || {},
138
+ );
139
+ const reps = report.rep_analysis?.items || [];
140
+ return h(
141
+ "section",
142
+ { className: "summary" },
143
+ h("h2", null, "Movement metrics"),
144
+ h(
145
+ "div",
146
+ { className: "metric-grid" },
147
+ aggregate.length
148
+ ? aggregate.map(([name, value]) =>
149
+ h(
150
+ "div",
151
+ { className: "stat", key: name },
152
+ h("span", null, label(name)),
153
+ h("strong", null, formatValue(value)),
154
+ ),
155
+ )
156
+ : h("p", null, "No aggregate metrics available."),
157
+ ),
158
+ h(
159
+ "div",
160
+ { className: "table-wrap" },
161
+ h(
162
+ "table",
163
+ null,
164
+ h(
165
+ "thead",
166
+ null,
167
+ h(
168
+ "tr",
169
+ null,
170
+ ["Rep", "Duration", "ROM", "Stability", "Symmetry"].map((heading) =>
171
+ h("th", { key: heading }, heading),
172
+ ),
173
+ ),
174
+ ),
175
+ h(
176
+ "tbody",
177
+ null,
178
+ reps.map((rep) =>
179
+ h(
180
+ "tr",
181
+ { key: rep.rep_id },
182
+ h("td", null, rep.rep_id),
183
+ h("td", null, `${formatValue(rep.duration_sec)}s`),
184
+ h("td", null, percent(rep.range_of_motion_score)),
185
+ h("td", null, percent(rep.stability_score)),
186
+ h("td", null, percent(rep.symmetry_score)),
187
+ ),
188
+ ),
189
+ ),
190
+ ),
191
+ ),
192
+ );
193
+ }
194
+
195
+ function RepsTab({ result }) {
196
+ if (!result)
197
+ return h(
198
+ "section",
199
+ { className: "summary" },
200
+ h("h2", null, "Rep review"),
201
+ h("p", null, "Rep segments appear after analysis."),
202
+ );
203
+ const reps = result.report.reps?.reps || [];
204
+ return h(
205
+ "section",
206
+ { className: "summary" },
207
+ h("h2", null, "Rep review"),
208
+ reps.length
209
+ ? h(
210
+ "div",
211
+ { className: "rep-grid" },
212
+ reps.map((rep) =>
213
+ h(
214
+ "article",
215
+ { className: "rep-card", key: rep.rep_id },
216
+ h("strong", null, `Rep ${rep.rep_id}`),
217
+ h(
218
+ "span",
219
+ null,
220
+ `${rep.start_sec.toFixed(2)}s-${rep.end_sec.toFixed(2)}s`,
221
+ ),
222
+ h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
223
+ h("span", null, `midpoint ${rep.mid_sec.toFixed(2)}s`),
224
+ ),
225
+ ),
226
+ )
227
+ : h("p", null, "No complete reps were detected."),
228
  );
229
  }
230
 
231
  function issueEvidence(issue) {
232
  const entries = Object.entries(issue.evidence || {}).filter(
233
  ([key, value]) =>
234
+ ![
235
+ "threshold",
236
+ "confidence",
237
+ "variation_context",
238
+ "supporting_frames",
239
+ "fallback",
240
+ ].includes(key) && typeof value !== "object",
241
  );
242
  const [metric, value] = entries[0] || ["metric", "n/a"];
243
  return `${label(metric)} ${value} vs ${issue.evidence?.threshold ?? "n/a"}`;
244
  }
245
 
246
+ function thumbnailForIssue(result, issue, index) {
247
+ const thumbnails = result?.issue_thumbnail_urls || [];
248
+ return (
249
+ thumbnails.find(
250
+ (thumbnail) =>
251
+ thumbnail.rep_id === issue.rep_id && thumbnail.issue === issue.issue,
252
+ ) || thumbnails[index]
253
+ );
254
+ }
255
+
256
+ function clipForIssue(result, issue, index) {
257
+ const clips = result?.issue_clip_urls || [];
258
+ return (
259
+ clips.find(
260
+ (clip) => clip.rep_id === issue.rep_id && clip.issue === issue.issue,
261
+ ) || clips[index]
262
+ );
263
+ }
264
+
265
+ function IssueMedia({ result, issue, index }) {
266
+ const clip = clipForIssue(result, issue, index);
267
+ if (clip?.url) {
268
+ return h("video", {
269
+ className: "issue-clip",
270
+ src: clip.url,
271
+ controls: true,
272
+ muted: true,
273
+ playsInline: true,
274
+ preload: "metadata",
275
+ });
276
+ }
277
+
278
+ const thumbnail = thumbnailForIssue(result, issue, index);
279
+ if (thumbnail?.url) {
280
+ return h("img", {
281
+ className: "issue-thumb",
282
+ src: thumbnail.url,
283
+ alt: `${label(issue.issue)} thumbnail`,
284
+ });
285
+ }
286
+
287
+ return h("div", { className: "issue-thumb empty" }, "No clip");
288
+ }
289
+
290
+ function issueClipText(result, issue, index) {
291
+ const clip = clipForIssue(result, issue, index);
292
+ if (
293
+ !clip ||
294
+ typeof clip.clip_start_sec !== "number" ||
295
+ typeof clip.clip_end_sec !== "number"
296
+ ) {
297
+ return `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`;
298
+ }
299
+ return `Rep ${issue.rep_id} · clip ${clip.clip_start_sec.toFixed(2)}s-${clip.clip_end_sec.toFixed(2)}s`;
300
+ }
301
+
302
+ function IssuesTab({ result }) {
303
+ const issues = result?.report?.issue_markers?.issues || [];
304
  return h(
305
  "section",
306
+ { className: "summary", "aria-label": "Issue timeline" },
307
  h(
308
  "div",
309
  { className: "timeline-head" },
310
  h("h3", null, "Issue timeline"),
311
+ h(
312
+ "span",
313
+ null,
314
+ issues.length
315
+ ? `${issues.length} interval${issues.length === 1 ? "" : "s"}`
316
+ : "clear",
317
+ ),
318
  ),
319
  issues.length
320
  ? h(
321
  "div",
322
+ { className: "issue-card-grid" },
323
  issues.map((issue, index) =>
324
  h(
325
  "article",
326
+ {
327
+ className: "issue-card",
328
+ key: `${issue.rep_id}-${issue.issue}-${index}`,
329
+ },
330
+ h(IssueMedia, { result, issue, index }),
331
  h(
332
  "div",
333
  { className: "timeline-main" },
334
  h("strong", null, label(issue.issue)),
335
+ h("span", null, issueClipText(result, issue, index)),
336
+ h(
337
+ "span",
338
+ null,
339
+ `issue ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`,
340
+ ),
341
  ),
342
  h(
343
  "div",
344
  { className: "timeline-meta" },
345
+ h(
346
+ "span",
347
+ null,
348
+ `severity ${Math.round(issue.severity * 100)}%`,
349
+ ),
350
+ h(
351
+ "span",
352
+ null,
353
+ `confidence ${percent(issue.evidence?.confidence)}`,
354
+ ),
355
  h("span", null, issueEvidence(issue)),
356
  h("span", null, issue.affected_joints.map(label).join(", ")),
357
  ),
 
362
  );
363
  }
364
 
365
+ function CoachTab({ result }) {
366
+ if (!result)
367
+ return h(
368
+ "section",
369
+ { className: "summary" },
370
+ h("h2", null, "Coach summary"),
371
+ h("p", null, "Coach notes appear after analysis."),
372
+ );
373
+ const summary = result.report.coach_summary;
374
+ return h(
375
+ "section",
376
+ { className: "summary" },
377
+ h("h2", null, "Coach summary"),
378
+ h("p", null, summary.summary),
379
+ h(
380
+ "div",
381
+ { className: "note-grid" },
382
+ h(NoteList, { title: "Main findings", items: summary.main_findings }),
383
+ h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
384
+ h(NoteList, {
385
+ title: "Confidence notes",
386
+ items: summary.confidence_notes,
387
+ }),
388
+ ),
389
+ h(
390
+ "article",
391
+ { className: "note full-note" },
392
+ h("h3", null, "Variation context"),
393
+ h("p", null, summary.variation_explanation),
394
+ ),
395
+ );
396
+ }
397
+
398
+ function JsonTab({ result }) {
399
+ return h(
400
+ "pre",
401
+ { className: "json-block" },
402
+ result ? JSON.stringify(result.report, null, 2) : "{}",
403
+ );
404
+ }
405
+
406
+ function ArtifactsTab({ result }) {
407
+ const links = result?.artifact_urls || [];
408
+ return h(
409
+ "section",
410
+ { className: "artifact-panel" },
411
+ h(
412
+ "div",
413
+ { className: "artifact-links" },
414
+ links.length
415
+ ? links.map((artifact) =>
416
+ h(
417
+ "a",
418
+ {
419
+ className: "artifact-link",
420
+ key: artifact.url,
421
+ href: artifact.url,
422
+ download: artifact.name,
423
+ },
424
+ artifact.name,
425
+ h("span", null, "download"),
426
+ ),
427
+ )
428
+ : h("p", null, "Artifacts appear after analysis."),
429
+ ),
430
+ );
431
+ }
432
+
433
+ const reportTabs = [
434
+ ["summary", "Summary"],
435
+ ["metrics", "Metrics"],
436
+ ["reps", "Reps"],
437
+ ["issues", "Issues"],
438
+ ["coach", "Coach"],
439
+ ["json", "JSON"],
440
+ ["artifacts", "Artifacts"],
441
+ ];
442
+
443
+ function ReportPanel({ result, activeTab, onTabChange }) {
444
+ const content = {
445
+ summary: h(SummaryTab, { result }),
446
+ metrics: h(MetricsTab, { result }),
447
+ reps: h(RepsTab, { result }),
448
+ issues: h(IssuesTab, { result }),
449
+ coach: h(CoachTab, { result }),
450
+ json: h(JsonTab, { result }),
451
+ artifacts: h(ArtifactsTab, { result }),
452
+ }[activeTab];
453
+
454
+ return h(
455
+ React.Fragment,
456
+ null,
457
+ h(
458
+ "div",
459
+ { className: "tabs", role: "tablist", "aria-label": "Report sections" },
460
+ reportTabs.map(([key, name]) =>
461
+ h(
462
+ "button",
463
+ {
464
+ className: `tab${activeTab === key ? " active" : ""}`,
465
+ key,
466
+ onClick: () => onTabChange(key),
467
+ type: "button",
468
+ },
469
+ name,
470
+ ),
471
+ ),
472
+ ),
473
+ content,
474
+ );
475
+ }
476
+
477
  function NoteList({ title, items }) {
478
  return h(
479
  "article",
480
  { className: "note" },
481
  h("h3", null, title),
482
+ h(
483
+ "ul",
484
+ null,
485
+ items.map((item) => h("li", { key: item }, item)),
486
+ ),
487
  );
488
  }
489
 
 
497
  const [equipment, setEquipment] = useState("bodyweight");
498
  const [limitations, setLimitations] = useState([]);
499
  const [result, setResult] = useState(null);
500
+ const [activeTab, setActiveTab] = useState("summary");
501
  const [status, setStatus] = useState("idle");
502
  const [error, setError] = useState("");
503
 
504
+ const previewUrl = useMemo(
505
+ () => (file ? URL.createObjectURL(file) : ""),
506
+ [file],
507
+ );
508
 
509
  useEffect(() => {
510
  fetch("/api/config")
 
521
 
522
  function toggleLimitation(value) {
523
  setLimitations((current) =>
524
+ current.includes(value)
525
+ ? current.filter((item) => item !== value)
526
+ : [...current, value],
527
  );
528
  }
529
 
 
543
  payload.append("equipment", equipment);
544
 
545
  try {
546
+ const response = await fetch("/api/analyze", {
547
+ method: "POST",
548
+ body: payload,
549
+ });
550
  const body = await response.json();
551
  if (!response.ok) throw new Error(body.detail || "Analysis failed.");
552
  setResult(body);
 
566
  h(
567
  "div",
568
  { className: "hero-content" },
569
+ h(
570
+ "p",
571
+ { className: "eyebrow" },
572
+ "Pose intelligence for coached training",
573
+ ),
574
  h("h1", null, "Pozify"),
575
  h("p", null, config.description),
576
  ),
577
  h(
578
  "div",
579
  { className: "hero-metrics", "aria-label": "Pipeline highlights" },
580
+ h(
581
+ "div",
582
+ { className: "metric" },
583
+ h("strong", null, "33"),
584
+ h("span", null, "pose landmarks"),
585
+ ),
586
+ h(
587
+ "div",
588
+ { className: "metric" },
589
+ h("strong", null, "60s"),
590
+ h("span", null, "clip ceiling"),
591
+ ),
592
+ h(
593
+ "div",
594
+ { className: "metric" },
595
+ h("strong", null, "JSON"),
596
+ h("span", null, "audit trail"),
597
+ ),
598
  ),
599
  ),
600
  h(
 
606
  h(
607
  "div",
608
  { className: "panel-head" },
609
+ h(
610
+ "div",
611
+ null,
612
+ h("h2", null, "Session setup"),
613
+ h("p", null, "Movement context"),
614
+ ),
615
+ h(
616
+ "span",
617
+ { className: "status-pill" },
618
+ status === "running" ? "Analyzing" : "Ready",
619
+ ),
620
  ),
621
  h(
622
  "label",
 
627
  onChange: (event) => setFile(event.target.files?.[0] || null),
628
  }),
629
  previewUrl
630
+ ? h("video", {
631
+ className: "dropzone-preview",
632
+ src: previewUrl,
633
+ controls: true,
634
+ })
635
  : h(
636
  "span",
637
  { className: "dropzone-empty" },
 
643
  h(
644
  "div",
645
  { className: "form-grid" },
646
+ h(SelectField, {
647
+ labelText: "Goal",
648
+ value: goal,
649
+ onChange: setGoal,
650
+ options: config.goals,
651
+ }),
652
  h(SelectField, {
653
  labelText: "Experience",
654
  value: experience,
 
699
  ),
700
  ),
701
  ),
702
+ h(
703
+ "button",
704
+ { className: "primary", disabled: status === "running" },
705
+ status === "running" ? "Analyzing..." : "Analyze Form",
706
+ ),
707
  error ? h("div", { className: "error" }, error) : null,
708
  ),
709
  h(
 
712
  h(
713
  "div",
714
  { className: "panel-head" },
715
+ h(
716
+ "div",
717
+ null,
718
+ h("h2", null, "Review output"),
719
+ h("p", null, "Annotated movement"),
720
+ ),
721
+ result
722
+ ? h("span", { className: "status-pill" }, result.run_id)
723
+ : null,
724
  ),
725
  h(
726
  "div",
727
  { className: "result-stage" },
728
  result?.annotated_video_url
729
+ ? h("video", {
730
+ className: "result-video",
731
+ src: result.annotated_video_url,
732
+ controls: true,
733
+ })
734
  : h(
735
  "div",
736
  { className: "result-empty" },
 
740
  ),
741
  ),
742
  ),
743
+ h(ReportPanel, { result, activeTab, onTabChange: setActiveTab }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  );
745
  }
746
 
web/styles.css CHANGED
@@ -378,6 +378,13 @@ input[type="text"]:focus,
378
  margin: 16px 0;
379
  }
380
 
 
 
 
 
 
 
 
381
  .stat {
382
  border: 1px solid rgba(37, 44, 31, 0.12);
383
  border-radius: 8px;
@@ -425,6 +432,106 @@ input[type="text"]:focus,
425
  color: #3e4438;
426
  }
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  .quality-list {
429
  display: flex;
430
  flex-wrap: wrap;
@@ -581,7 +688,10 @@ input[type="text"]:focus,
581
  @media (max-width: 980px) {
582
  .workspace,
583
  .note-grid,
584
- .stat-grid {
 
 
 
585
  grid-template-columns: 1fr;
586
  }
587
  }
 
378
  margin: 16px 0;
379
  }
380
 
381
+ .metric-grid {
382
+ display: grid;
383
+ grid-template-columns: repeat(4, minmax(0, 1fr));
384
+ gap: 10px;
385
+ margin: 16px 0;
386
+ }
387
+
388
  .stat {
389
  border: 1px solid rgba(37, 44, 31, 0.12);
390
  border-radius: 8px;
 
432
  color: #3e4438;
433
  }
434
 
435
+ .full-note {
436
+ margin-top: 12px;
437
+ }
438
+
439
+ .full-note p {
440
+ margin: 0;
441
+ }
442
+
443
+ .table-wrap {
444
+ overflow: auto;
445
+ border: 1px solid rgba(37, 44, 31, 0.12);
446
+ border-radius: 8px;
447
+ background: rgba(255, 253, 247, 0.68);
448
+ }
449
+
450
+ table {
451
+ width: 100%;
452
+ border-collapse: collapse;
453
+ min-width: 620px;
454
+ }
455
+
456
+ th,
457
+ td {
458
+ border-bottom: 1px solid rgba(37, 44, 31, 0.1);
459
+ padding: 11px 12px;
460
+ text-align: left;
461
+ }
462
+
463
+ th {
464
+ color: var(--forest);
465
+ font-size: 0.78rem;
466
+ text-transform: uppercase;
467
+ }
468
+
469
+ td {
470
+ color: #3e4438;
471
+ }
472
+
473
+ .rep-grid,
474
+ .issue-card-grid {
475
+ display: grid;
476
+ grid-template-columns: repeat(3, minmax(0, 1fr));
477
+ gap: 12px;
478
+ margin-top: 14px;
479
+ }
480
+
481
+ .rep-card,
482
+ .issue-card {
483
+ border: 1px solid rgba(37, 44, 31, 0.12);
484
+ border-radius: 8px;
485
+ background: rgba(255, 253, 247, 0.66);
486
+ }
487
+
488
+ .rep-card {
489
+ display: grid;
490
+ gap: 6px;
491
+ padding: 14px;
492
+ }
493
+
494
+ .rep-card strong {
495
+ color: var(--forest-deep);
496
+ }
497
+
498
+ .rep-card span {
499
+ color: var(--muted);
500
+ font-size: 0.88rem;
501
+ }
502
+
503
+ .issue-card {
504
+ overflow: hidden;
505
+ }
506
+
507
+ .issue-card .timeline-main,
508
+ .issue-card .timeline-meta {
509
+ padding: 10px 12px;
510
+ }
511
+
512
+ .issue-card .timeline-meta {
513
+ padding-top: 0;
514
+ }
515
+
516
+ .issue-thumb,
517
+ .issue-clip {
518
+ display: block;
519
+ width: 100%;
520
+ aspect-ratio: 16 / 10;
521
+ object-fit: cover;
522
+ background: #171815;
523
+ }
524
+
525
+ .issue-clip {
526
+ color: var(--paper);
527
+ }
528
+
529
+ .issue-thumb.empty {
530
+ display: grid;
531
+ color: rgba(255, 249, 236, 0.62);
532
+ place-items: center;
533
+ }
534
+
535
  .quality-list {
536
  display: flex;
537
  flex-wrap: wrap;
 
688
  @media (max-width: 980px) {
689
  .workspace,
690
  .note-grid,
691
+ .stat-grid,
692
+ .metric-grid,
693
+ .rep-grid,
694
+ .issue-card-grid {
695
  grid-template-columns: 1fr;
696
  }
697
  }