splitsmith 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. {splitsmith-0.2.1 → splitsmith-0.3.0}/CHANGELOG.md +8 -0
  2. {splitsmith-0.2.1 → splitsmith-0.3.0}/PKG-INFO +2 -2
  3. {splitsmith-0.2.1 → splitsmith-0.3.0}/README.md +1 -1
  4. {splitsmith-0.2.1 → splitsmith-0.3.0}/pyproject.toml +1 -1
  5. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/__init__.py +1 -1
  6. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/server.py +13 -5
  7. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/AuditControls.tsx +8 -6
  8. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/BeepSection.tsx +226 -57
  9. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/HelpOverlay.tsx +7 -5
  10. splitsmith-0.3.0/src/splitsmith/ui_static/src/components/audit/BeepAnomalyBanner.tsx +71 -0
  11. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/BeepStatusChip.tsx +17 -38
  12. splitsmith-0.3.0/src/splitsmith/ui_static/src/components/audit/ShortcutHints.tsx +148 -0
  13. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/StageActionBar.tsx +8 -5
  14. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/api.ts +11 -3
  15. splitsmith-0.3.0/src/splitsmith/ui_static/src/lib/keyboard.ts +45 -0
  16. splitsmith-0.3.0/src/splitsmith/ui_static/src/lib/platform.ts +50 -0
  17. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Audit.tsx +132 -368
  18. splitsmith-0.3.0/src/splitsmith/ui_static/src/pages/BeepReview.tsx +1049 -0
  19. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Coach.tsx +5 -0
  20. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Design.tsx +3 -2
  21. splitsmith-0.3.0/src/splitsmith/ui_static/tsconfig.app.tsbuildinfo +1 -0
  22. {splitsmith-0.2.1 → splitsmith-0.3.0}/uv.lock +1 -1
  23. splitsmith-0.2.1/src/splitsmith/ui_static/src/components/audit/SyncBanner.tsx +0 -157
  24. splitsmith-0.2.1/src/splitsmith/ui_static/src/pages/BeepReview.tsx +0 -759
  25. splitsmith-0.2.1/src/splitsmith/ui_static/tsconfig.app.tsbuildinfo +0 -1
  26. {splitsmith-0.2.1 → splitsmith-0.3.0}/.gitignore +0 -0
  27. {splitsmith-0.2.1 → splitsmith-0.3.0}/LICENSE +0 -0
  28. {splitsmith-0.2.1 → splitsmith-0.3.0}/docs/local-slim/README.md +0 -0
  29. {splitsmith-0.2.1 → splitsmith-0.3.0}/docs/saas-readiness/README.md +0 -0
  30. {splitsmith-0.2.1 → splitsmith-0.3.0}/site/README.md +0 -0
  31. {splitsmith-0.2.1 → splitsmith-0.3.0}/skills/splitsmith-match/README.md +0 -0
  32. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/audit.py +0 -0
  33. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/automation.py +0 -0
  34. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/backup.py +0 -0
  35. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/beep_calibration.py +0 -0
  36. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/beep_detect.py +0 -0
  37. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/cleanup.py +0 -0
  38. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/cli.py +0 -0
  39. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/coach.py +0 -0
  40. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/coach_distributions.py +0 -0
  41. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/__init__.py +0 -0
  42. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/cli.py +0 -0
  43. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/emitter.py +0 -0
  44. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/filler.py +0 -0
  45. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/layout.py +0 -0
  46. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/manifest.py +0 -0
  47. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/compare/project_loader.py +0 -0
  48. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/composition.py +0 -0
  49. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/config.py +0 -0
  50. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/cross_align.py +0 -0
  51. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/csv_gen.py +0 -0
  52. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/ensemble_calibration.json +0 -0
  53. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/fonts/Antonio-OFL.txt +0 -0
  54. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/fonts/Antonio-VariableFont.ttf +0 -0
  55. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/fonts/JetBrainsMono-Bold.ttf +0 -0
  56. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/fonts/JetBrainsMono-OFL.txt +0 -0
  57. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/overlay_theme.json +0 -0
  58. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/templates/action-cut.yaml +0 -0
  59. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/templates/match-recap.yaml +0 -0
  60. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/voter_c_gbdt.joblib +0 -0
  61. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/data/voter_e_visual_probe.joblib +0 -0
  62. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/__init__.py +0 -0
  63. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/agc_state.py +0 -0
  64. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/api.py +0 -0
  65. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/backend.py +0 -0
  66. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/calibration.py +0 -0
  67. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/clap_mel.py +0 -0
  68. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/features.py +0 -0
  69. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/fixtures.py +0 -0
  70. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/tta.py +0 -0
  71. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/visual.py +0 -0
  72. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ensemble/voters.py +0 -0
  73. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/fcp7xml_render.py +0 -0
  74. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/fcpxml_gen.py +0 -0
  75. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/fixture_schema.py +0 -0
  76. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab/__init__.py +0 -0
  77. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab/core.py +0 -0
  78. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab/promote.py +0 -0
  79. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab/snap_window.py +0 -0
  80. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab/sweeps.py +0 -0
  81. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/lab_cli.py +0 -0
  82. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/match_cli.py +0 -0
  83. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/match_model.py +0 -0
  84. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/match_registry.py +0 -0
  85. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/__init__.py +0 -0
  86. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/__main__.py +0 -0
  87. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/detect_tools.py +0 -0
  88. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/export_tools.py +0 -0
  89. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/sandbox.py +0 -0
  90. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/server.py +0 -0
  91. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/tools.py +0 -0
  92. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mcp/write_tools.py +0 -0
  93. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/model_cli.py +0 -0
  94. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/__init__.py +0 -0
  95. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/cache.py +0 -0
  96. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/download.py +0 -0
  97. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/errors.py +0 -0
  98. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/manifest.py +0 -0
  99. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/models/registry.py +0 -0
  100. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/mp4_render.py +0 -0
  101. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/overlay_render.py +0 -0
  102. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/overlay_theme.py +0 -0
  103. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/relink.py +0 -0
  104. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/report.py +0 -0
  105. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/runtime.py +0 -0
  106. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/shot_detect.py +0 -0
  107. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/shot_refine.py +0 -0
  108. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/system_check.py +0 -0
  109. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/templates.py +0 -0
  110. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/thumbnail.py +0 -0
  111. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/trim.py +0 -0
  112. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/__init__.py +0 -0
  113. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/audio.py +0 -0
  114. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/embedded.py +0 -0
  115. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/exports.py +0 -0
  116. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/jobs.py +0 -0
  117. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/logging_setup.py +0 -0
  118. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/match_exports.py +0 -0
  119. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/project.py +0 -0
  120. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/__init__.py +0 -0
  121. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/cache.py +0 -0
  122. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/http.py +0 -0
  123. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/local.py +0 -0
  124. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/models.py +0 -0
  125. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui/scoreboard/protocol.py +0 -0
  126. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/.gitignore +0 -0
  127. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/index.html +0 -0
  128. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/package-lock.json +0 -0
  129. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/package.json +0 -0
  130. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/pnpm-lock.yaml +0 -0
  131. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/App.tsx +0 -0
  132. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/AddFootageModal.tsx +0 -0
  133. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/AppShell.tsx +0 -0
  134. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/CameraModelSelect.tsx +0 -0
  135. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/CleanupDialog.tsx +0 -0
  136. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/DirectoryPickerModal.tsx +0 -0
  137. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/FolderPicker.tsx +0 -0
  138. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/HitlQueuePanel.tsx +0 -0
  139. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/Jobs.tsx +0 -0
  140. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ListDrawer.tsx +0 -0
  141. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/MarkerGlyph.tsx +0 -0
  142. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/MarkerLayer.tsx +0 -0
  143. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/MountSelect.tsx +0 -0
  144. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/RelinkDialog.tsx +0 -0
  145. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/SettingProvenance.tsx +0 -0
  146. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ShooterScopedRoute.tsx +0 -0
  147. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ShotStepper.tsx +0 -0
  148. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/StageTimeSection.tsx +0 -0
  149. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/SweepsCard.tsx +0 -0
  150. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/VideoPanel.tsx +0 -0
  151. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/Waveform.tsx +0 -0
  152. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/AnomalyChips.tsx +0 -0
  153. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/AnomalyPins.tsx +0 -0
  154. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/CamGridModal.tsx +0 -0
  155. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/CamSyncPill.tsx +0 -0
  156. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/MultiCamColumn.tsx +0 -0
  157. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/PrereqGate.tsx +0 -0
  158. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/SessionSummary.tsx +0 -0
  159. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/audit/StageChipRail.tsx +0 -0
  160. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/developer/DeveloperShell.tsx +0 -0
  161. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/match/MatchShell.tsx +0 -0
  162. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/match/MatchSidebar.tsx +0 -0
  163. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/match/ShooterChipStrip.tsx +0 -0
  164. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/AvatarStack.tsx +0 -0
  165. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/Brand.tsx +0 -0
  166. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/ContextBar.tsx +0 -0
  167. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/DisplayHeading.tsx +0 -0
  168. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/IconButton.tsx +0 -0
  169. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/Kbd.tsx +0 -0
  170. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/Kicker.tsx +0 -0
  171. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/ModeSwitch.tsx +0 -0
  172. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/Readout.tsx +0 -0
  173. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/ShotTimerShell.tsx +0 -0
  174. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/StageDot.tsx +0 -0
  175. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/StatusPill.tsx +0 -0
  176. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/Tick.tsx +0 -0
  177. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/badge.tsx +0 -0
  178. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/button.tsx +0 -0
  179. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/card.tsx +0 -0
  180. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/index.ts +0 -0
  181. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/components/ui/skeleton.tsx +0 -0
  182. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/anomalies.ts +0 -0
  183. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/audit-input.ts +0 -0
  184. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/audit-next-step.ts +0 -0
  185. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/features.ts +0 -0
  186. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/matchHref.ts +0 -0
  187. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/mode.tsx +0 -0
  188. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/slugify.ts +0 -0
  189. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/stageStatus.ts +0 -0
  190. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/lib/utils.ts +0 -0
  191. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/main.tsx +0 -0
  192. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Compare.tsx +0 -0
  193. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/CreateMatch.tsx +0 -0
  194. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Export.tsx +0 -0
  195. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Home.tsx +0 -0
  196. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Ingest.tsx +0 -0
  197. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Lab.tsx +0 -0
  198. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/MergeMatches.tsx +0 -0
  199. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Pick.tsx +0 -0
  200. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/PromoteReview.tsx +0 -0
  201. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Review.tsx +0 -0
  202. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/Shooters.tsx +0 -0
  203. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/dev/DevCorpus.tsx +0 -0
  204. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/dev/DevRetrain.tsx +0 -0
  205. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/dev/DevReviewQueue.tsx +0 -0
  206. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/pages/dev/DevValidate.tsx +0 -0
  207. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/src/styles/index.css +0 -0
  208. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/tsconfig.app.json +0 -0
  209. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/tsconfig.json +0 -0
  210. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/tsconfig.node.json +0 -0
  211. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/tsconfig.node.tsbuildinfo +0 -0
  212. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/ui_static/vite.config.ts +0 -0
  213. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/user_config.py +0 -0
  214. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/video_match.py +0 -0
  215. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/video_probe.py +0 -0
  216. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/waveform.py +0 -0
  217. {splitsmith-0.2.1 → splitsmith-0.3.0}/src/splitsmith/youtube_sidecar.py +0 -0
  218. {splitsmith-0.2.1 → splitsmith-0.3.0}/tests/fixtures/beep_calibration/README.md +0 -0
  219. {splitsmith-0.2.1 → splitsmith-0.3.0}/tests/fixtures/schemas/README.md +0 -0
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/mandakan/splitsmith/compare/v0.2.1...v0.3.0) (2026-05-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * **beep-review:** single home for beep work; trim audit page ([#399](https://github.com/mandakan/splitsmith/issues/399)) ([9ecf999](https://github.com/mandakan/splitsmith/commit/9ecf9998f94edeb5420e3a982f97ae4edb2114f0))
9
+ * **brand:** hero + og:image, new tagline, audit shortcut hints ([#401](https://github.com/mandakan/splitsmith/issues/401)) ([40aa55a](https://github.com/mandakan/splitsmith/commit/40aa55ac56da0e9c81ae373554b9dc8816f4b2e0))
10
+
3
11
  ## [0.2.1](https://github.com/mandakan/splitsmith/compare/v0.2.0...v0.2.1) (2026-05-24)
4
12
 
5
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splitsmith
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Extract IPSC shot splits from head-mounted camera footage
5
5
  Author: Mathias Axell
6
6
  License: MIT
@@ -36,7 +36,7 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  Extract per-shot split times from head-mounted camera footage of IPSC matches and generate Final Cut Pro timelines with per-shot markers.
38
38
 
39
- ![audit view](https://raw.githubusercontent.com/mandakan/splitsmith/main/docs/screenshots/audit.png)
39
+ ![Splitsmith -- Detect. Coach. Cut.](https://raw.githubusercontent.com/mandakan/splitsmith/main/docs/screenshots/hero.png)
40
40
 
41
41
  Built to do two things from a single stage video: get per-shot splits for analysis and coaching, and prepare frame-marked clips for match-footage review. Your head-mounted cam (Insta360 Go 3S in this case) already captures audio of every shot; the RO's timer only records your total stage time, so the splits live in the video and nowhere else. Splitsmith extracts them and turns them into a CSV plus an FCPXML timeline with per-shot markers you can step through in Final Cut Pro.
42
42
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  Extract per-shot split times from head-mounted camera footage of IPSC matches and generate Final Cut Pro timelines with per-shot markers.
9
9
 
10
- ![audit view](https://raw.githubusercontent.com/mandakan/splitsmith/main/docs/screenshots/audit.png)
10
+ ![Splitsmith -- Detect. Coach. Cut.](https://raw.githubusercontent.com/mandakan/splitsmith/main/docs/screenshots/hero.png)
11
11
 
12
12
  Built to do two things from a single stage video: get per-shot splits for analysis and coaching, and prepare frame-marked clips for match-footage review. Your head-mounted cam (Insta360 Go 3S in this case) already captures audio of every shot; the RO's timer only records your total stage time, so the splits live in the video and nowhere else. Splitsmith extracts them and turns them into a CSV plus an FCPXML timeline with per-shot markers you can step through in Final Cut Pro.
13
13
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "splitsmith"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Extract IPSC shot splits from head-mounted camera footage"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """splitsmith: extract IPSC shot splits from head-mounted camera footage."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -1059,7 +1059,7 @@ class BeepQueueItem(BaseModel):
1059
1059
  beep_time: float | None
1060
1060
  beep_confidence: float | None
1061
1061
  beep_reviewed: bool
1062
- status: Literal["missing", "low_confidence", "unreviewed"]
1062
+ status: Literal["missing", "low_confidence", "unreviewed", "confirmed"]
1063
1063
  alt_candidates: list[BeepQueueAltCandidate]
1064
1064
  # Auto-computed cross-align suggestion for secondaries lives on the
1065
1065
  # shooter's other videos; the SPA fetches them lazily if needed.
@@ -7257,16 +7257,21 @@ def create_app(
7257
7257
  # ----------------------------------------------------------------------
7258
7258
 
7259
7259
  @app.get("/api/match/beep-queue", response_model=BeepQueueResponse)
7260
- def get_beep_queue() -> BeepQueueResponse:
7260
+ def get_beep_queue(include_confirmed: bool = Query(default=False)) -> BeepQueueResponse:
7261
7261
  """Pending beep items across every shooter in the bound match.
7262
7262
 
7263
- Surfaces three states per primary video:
7263
+ Surfaces three pending states per primary video:
7264
7264
  - ``missing``: detector hasn't run or didn't find a beep
7265
7265
  - ``low_confidence``: detector found one but below the project's
7266
7266
  auto-trust threshold
7267
7267
  - ``unreviewed``: detector found one above threshold but the
7268
7268
  user hasn't yet listened + approved
7269
7269
 
7270
+ When ``include_confirmed`` is true the response also includes
7271
+ items whose beep has been reviewed, with ``status="confirmed"``.
7272
+ The SPA uses this for the "Show confirmed" toggle so the user
7273
+ can revisit a settled beep without having to clear-and-redo it.
7274
+
7270
7275
  Items are grouped by stage; per-stage shot detection is gated on
7271
7276
  every shooter's primary in that stage being ``beep_reviewed``.
7272
7277
  """
@@ -7320,12 +7325,15 @@ def create_app(
7320
7325
  elif primary.beep_reviewed:
7321
7326
  grp.confirmed += 1
7322
7327
  total_confirmed += 1
7323
- continue
7328
+ if not include_confirmed:
7329
+ continue
7330
+ status = "confirmed"
7324
7331
  elif primary.beep_confidence is not None and primary.beep_confidence < threshold:
7325
7332
  status = "low_confidence"
7326
7333
  else:
7327
7334
  status = "unreviewed"
7328
- total_pending += 1
7335
+ if status != "confirmed":
7336
+ total_pending += 1
7329
7337
  alts = [
7330
7338
  BeepQueueAltCandidate(
7331
7339
  time=cand.time,
@@ -12,6 +12,7 @@ import { Maximize2, Minus, Plus } from "lucide-react";
12
12
 
13
13
  import { MarkerGlyph, type MarkerKind } from "@/components/MarkerGlyph";
14
14
  import { Button } from "@/components/ui/button";
15
+ import { modKeyLabel } from "@/lib/platform";
15
16
  import { cn } from "@/lib/utils";
16
17
 
17
18
  export interface MarkerFilters {
@@ -152,6 +153,7 @@ export function ZoomControls({ zoom, onZoomChange, className }: ZoomControlsProp
152
153
  const next = base / ZOOM_STEP;
153
154
  onZoomChange(next <= MIN_ZOOM ? null : next);
154
155
  };
156
+ const mod = modKeyLabel();
155
157
 
156
158
  return (
157
159
  <div className={cn("inline-flex items-center gap-1", className)} role="group" aria-label="Zoom">
@@ -159,8 +161,8 @@ export function ZoomControls({ zoom, onZoomChange, className }: ZoomControlsProp
159
161
  size="sm"
160
162
  variant="ghost"
161
163
  onClick={zoomOut}
162
- aria-label="Zoom out (Cmd+3)"
163
- title="Zoom out (Cmd+3)"
164
+ aria-label={`Zoom out (${mod}+3)`}
165
+ title={`Zoom out (${mod}+3)`}
164
166
  >
165
167
  <Minus className="size-3" />
166
168
  </Button>
@@ -168,8 +170,8 @@ export function ZoomControls({ zoom, onZoomChange, className }: ZoomControlsProp
168
170
  size="sm"
169
171
  variant="ghost"
170
172
  onClick={() => onZoomChange(null)}
171
- aria-label="Fit waveform (Cmd+2)"
172
- title="Fit waveform (Cmd+2)"
173
+ aria-label={`Fit waveform (${mod}+2)`}
174
+ title={`Fit waveform (${mod}+2)`}
173
175
  aria-pressed={zoom == null}
174
176
  >
175
177
  <Maximize2 className="size-3" />
@@ -178,8 +180,8 @@ export function ZoomControls({ zoom, onZoomChange, className }: ZoomControlsProp
178
180
  size="sm"
179
181
  variant="ghost"
180
182
  onClick={zoomIn}
181
- aria-label="Zoom in (Cmd+1)"
182
- title="Zoom in (Cmd+1)"
183
+ aria-label={`Zoom in (${mod}+1)`}
184
+ title={`Zoom in (${mod}+1)`}
183
185
  >
184
186
  <Plus className="size-3" />
185
187
  </Button>
@@ -50,6 +50,8 @@ import {
50
50
  import { Badge } from "@/components/ui/badge";
51
51
  import { Button } from "@/components/ui/button";
52
52
  import { Waveform } from "@/components/Waveform";
53
+ import { useSpacePlayPause } from "@/lib/keyboard";
54
+ import { modKeyGlyph } from "@/lib/platform";
53
55
  import { cn, useReleaseMediaOnUnmount } from "@/lib/utils";
54
56
  import {
55
57
  ApiError,
@@ -695,6 +697,8 @@ export function BeepWaveformPicker({
695
697
  showFallbackBeepMarker = true,
696
698
  instructions,
697
699
  ariaLabel,
700
+ externalMediaRef,
701
+ fillHeight = false,
698
702
  }: {
699
703
  slug: string;
700
704
  stageNumber: number;
@@ -715,17 +719,56 @@ export function BeepWaveformPicker({
715
719
  instructions?: string;
716
720
  /** Override the canvas aria-label for screen readers. */
717
721
  ariaLabel?: string;
722
+ /** Optional external media element (audio OR video) for the picker
723
+ * to drive. When provided, the picker skips rendering its own
724
+ * <audio> element -- the parent owns playback (same pattern as
725
+ * the audit page's MultiCamColumn <video>). Space, click-to-scrub,
726
+ * zoom, and the initial seek all act on this element. When not
727
+ * provided, the picker creates its own internal <audio src=
728
+ * videoAudioUrl(...)> as before -- StageTimeSection relies on
729
+ * that path. */
730
+ externalMediaRef?: { current: HTMLAudioElement | HTMLVideoElement | null };
731
+ /** Grow the waveform vertically (200 px instead of 80 px) so the
732
+ * picker box fills the row when paired with a taller sibling like
733
+ * BeepReview's <video aspect-video>. */
734
+ fillHeight?: boolean;
718
735
  }) {
719
736
  const [peaks, setPeaks] = useState<PeaksResult | null>(null);
720
737
  const [loading, setLoading] = useState(true);
721
738
  const [unavailable, setUnavailable] = useState(false);
722
739
  const [localTime, setLocalTime] = useState<number>(0);
723
740
  const [playing, setPlaying] = useState(false);
724
- const [pxPerSec, setPxPerSec] = useState<number | null>(null);
741
+ // Zoom is a multiplier relative to the viewport-fit baseline (null =
742
+ // fit-to-width). The audit canvas uses the same model -- it
743
+ // matters because absolute px/s blows up on long clips: a 104 s
744
+ // primary at 240 px/s renders 25k px wide, so even "first zoom"
745
+ // shoves the whole clip off-screen.
746
+ const [zoom, setZoom] = useState<number | null>(null);
747
+ const [viewportWidth, setViewportWidth] = useState(0);
748
+ const viewportRef = useRef<HTMLDivElement | null>(null);
749
+ const wrapperCallbackRef = useCallback((el: HTMLDivElement | null) => {
750
+ viewportRef.current = el;
751
+ if (!el) return;
752
+ setViewportWidth(Math.floor(el.getBoundingClientRect().width));
753
+ const observer = new ResizeObserver((entries) => {
754
+ for (const entry of entries) {
755
+ const w = Math.floor(entry.contentRect.width);
756
+ if (w > 0) setViewportWidth(w);
757
+ }
758
+ });
759
+ observer.observe(el);
760
+ return () => observer.disconnect();
761
+ }, []);
725
762
  const [snapping, setSnapping] = useState(false);
726
763
  const [proposal, setProposal] = useState<BeepSnapResult | null>(null);
727
- const audioRef = useRef<HTMLAudioElement | null>(null);
728
- useReleaseMediaOnUnmount(audioRef);
764
+ // The picker's playback handle. When the parent owns the media
765
+ // element (BeepReview passes its <video> ref), we just alias the
766
+ // external ref; otherwise we create our own <audio> below. Either
767
+ // way, the rest of the picker (togglePlay, scrub, init seek,
768
+ // Space hook) reads / writes through ``mediaRef``.
769
+ const internalAudioRef = useRef<HTMLAudioElement | null>(null);
770
+ const mediaRef = externalMediaRef ?? internalAudioRef;
771
+ useReleaseMediaOnUnmount(internalAudioRef);
729
772
  const rafRef = useRef<number | null>(null);
730
773
 
731
774
  useEffect(() => {
@@ -737,7 +780,23 @@ export function BeepWaveformPicker({
737
780
  .then((p) => {
738
781
  if (cancelled) return;
739
782
  setPeaks(p);
740
- setLocalTime(p.beep_time ?? 0);
783
+ // Park the playhead at the detected beep so pressing Play
784
+ // immediately auditions what the detector picked, instead of
785
+ // making the user scrub from t=0 first. Also seek the
786
+ // underlying <audio> element when it's already loaded --
787
+ // otherwise localTime gets out of sync with the audio
788
+ // element's currentTime, and the first Play snaps the
789
+ // playhead back to whatever it was last at.
790
+ const t = p.beep_time ?? 0;
791
+ setLocalTime(t);
792
+ const el = mediaRef.current;
793
+ if (el) {
794
+ try {
795
+ el.currentTime = t;
796
+ } catch {
797
+ /* metadata not loaded yet -- onLoadedMetadata will retry */
798
+ }
799
+ }
741
800
  })
742
801
  .catch(() => {
743
802
  if (!cancelled) setUnavailable(true);
@@ -776,7 +835,7 @@ export function BeepWaveformPicker({
776
835
  useEffect(() => {
777
836
  if (!playing) return;
778
837
  const tick = () => {
779
- const el = audioRef.current;
838
+ const el = mediaRef.current;
780
839
  if (el) setLocalTime(el.currentTime);
781
840
  rafRef.current = requestAnimationFrame(tick);
782
841
  };
@@ -788,7 +847,7 @@ export function BeepWaveformPicker({
788
847
  }, [playing]);
789
848
 
790
849
  const togglePlay = useCallback(() => {
791
- const el = audioRef.current;
850
+ const el = mediaRef.current;
792
851
  if (!el) return;
793
852
  if (el.paused) {
794
853
  el.play().catch(() => {
@@ -797,7 +856,36 @@ export function BeepWaveformPicker({
797
856
  } else {
798
857
  el.pause();
799
858
  }
800
- }, []);
859
+ }, [mediaRef]);
860
+
861
+ // Window-level Space → toggle so the user can audition the beep
862
+ // even when focus is parked on a sidebar link or a queue row
863
+ // (the common path: click an item, hit Space). Gated by the picker
864
+ // actually having peaks loaded so Space falls through to the
865
+ // browser default while the picker is in its empty / loading state.
866
+ useSpacePlayPause(togglePlay, peaks != null);
867
+
868
+ // When the parent owns the media element (BeepReview), the
869
+ // ``playing`` / ``localTime`` state need a separate event bridge --
870
+ // the inline ``onPlay``/``onPause`` handlers below only fire on the
871
+ // internal <audio>. Listen on the external element so the picker's
872
+ // playhead + Play button label still reflect the truth.
873
+ useEffect(() => {
874
+ if (!externalMediaRef) return;
875
+ const el = externalMediaRef.current;
876
+ if (!el) return;
877
+ const onPlay = () => setPlaying(true);
878
+ const onPause = () => setPlaying(false);
879
+ const onEnded = () => setPlaying(false);
880
+ el.addEventListener("play", onPlay);
881
+ el.addEventListener("pause", onPause);
882
+ el.addEventListener("ended", onEnded);
883
+ return () => {
884
+ el.removeEventListener("play", onPlay);
885
+ el.removeEventListener("pause", onPause);
886
+ el.removeEventListener("ended", onEnded);
887
+ };
888
+ }, [externalMediaRef, peaks]);
801
889
 
802
890
  // Click / drag = seek audio AND set the marker. Marker and the
803
891
  // numeric input both read from the draft state in the parent, so they
@@ -806,7 +894,7 @@ export function BeepWaveformPicker({
806
894
  const handleScrub = useCallback(
807
895
  (t: number) => {
808
896
  setLocalTime(t);
809
- const el = audioRef.current;
897
+ const el = mediaRef.current;
810
898
  if (el) {
811
899
  try {
812
900
  el.currentTime = t;
@@ -853,19 +941,49 @@ export function BeepWaveformPicker({
853
941
 
854
942
  const dismissProposal = useCallback(() => setProposal(null), []);
855
943
 
944
+ // Zoom math mirrors the audit canvas: 1.5x per click, capped at
945
+ // 16x in, 0.25x out (anything below = back to fit).
856
946
  const zoomIn = useCallback(() => {
857
- setPxPerSec((cur) => {
858
- if (cur == null) return 240;
859
- return Math.min(2000, cur * 2);
860
- });
947
+ setZoom((z) => Math.min(16, (z ?? 1) * 1.5));
861
948
  }, []);
862
949
  const zoomOut = useCallback(() => {
863
- setPxPerSec((cur) => {
864
- if (cur == null) return null;
865
- const next = cur / 2;
866
- return next < 60 ? null : next;
950
+ setZoom((z) => {
951
+ const next = (z ?? 1) / 1.5;
952
+ return next <= 0.25 ? null : next;
867
953
  });
868
954
  }, []);
955
+ const zoomFit = useCallback(() => setZoom(null), []);
956
+ const pxPerSec = useMemo(() => {
957
+ if (zoom == null || !peaks) return null;
958
+ if (peaks.duration <= 0 || viewportWidth <= 0) return null;
959
+ const fitPps = viewportWidth / peaks.duration;
960
+ return Math.max(1, fitPps * zoom);
961
+ }, [zoom, viewportWidth, peaks]);
962
+
963
+ // Cmd/Ctrl + 1/2/3 -- same bindings as the audit canvas waveform so
964
+ // muscle memory carries across surfaces. Cmd+1 = zoom in, Cmd+2 =
965
+ // fit, Cmd+3 = zoom out. Ignored when typing in an input/textarea so
966
+ // we don't steal the browser's tab-cycling shortcut from form fields.
967
+ useEffect(() => {
968
+ function onKey(e: KeyboardEvent) {
969
+ if (!(e.metaKey || e.ctrlKey)) return;
970
+ if (e.key !== "1" && e.key !== "2" && e.key !== "3") return;
971
+ if (
972
+ e.target instanceof HTMLElement &&
973
+ (e.target.tagName === "INPUT" ||
974
+ e.target.tagName === "TEXTAREA" ||
975
+ e.target.isContentEditable)
976
+ ) {
977
+ return;
978
+ }
979
+ e.preventDefault();
980
+ if (e.key === "1") zoomIn();
981
+ else if (e.key === "2") zoomFit();
982
+ else zoomOut();
983
+ }
984
+ window.addEventListener("keydown", onKey);
985
+ return () => window.removeEventListener("keydown", onKey);
986
+ }, [zoomIn, zoomOut, zoomFit]);
869
987
 
870
988
  if (loading) {
871
989
  return (
@@ -898,7 +1016,7 @@ export function BeepWaveformPicker({
898
1016
  size="icon"
899
1017
  variant="ghost"
900
1018
  onClick={zoomOut}
901
- disabled={pxPerSec == null}
1019
+ disabled={zoom == null}
902
1020
  aria-label="Zoom out"
903
1021
  title="Zoom out"
904
1022
  className="size-7"
@@ -917,50 +1035,101 @@ export function BeepWaveformPicker({
917
1035
  </Button>
918
1036
  </div>
919
1037
  </div>
920
- <Waveform
921
- peaks={peaks.peaks}
922
- duration={peaks.duration}
923
- currentTime={localTime}
924
- onScrub={handleScrub}
925
- onScrubEnd={handleScrubEnd}
926
- beepTime={markerLocal}
927
- pixelsPerSecond={pxPerSec}
928
- height={80}
929
- ariaLabel={ariaLabel ?? `Beep editor waveform for stage ${stageNumber}`}
930
- />
931
- <audio
932
- ref={audioRef}
933
- src={api.videoAudioUrl(slug, stageNumber, videoId)}
934
- preload="metadata"
935
- onPlay={() => setPlaying(true)}
936
- onPause={() => setPlaying(false)}
937
- onEnded={() => setPlaying(false)}
938
- controls
939
- className="w-full"
940
- />
941
- <div className="flex flex-wrap items-center gap-2 pt-1">
942
- <Button
943
- size="sm"
944
- variant="outline"
945
- onClick={togglePlay}
946
- title="Play / pause the audio"
947
- >
948
- {playing ? <Pause /> : <Play />}
949
- {playing ? "Pause" : "Play"}
950
- </Button>
951
- {snapEnabled ? (
1038
+ <div ref={wrapperCallbackRef}>
1039
+ <Waveform
1040
+ peaks={peaks.peaks}
1041
+ duration={peaks.duration}
1042
+ currentTime={localTime}
1043
+ onScrub={handleScrub}
1044
+ onScrubEnd={handleScrubEnd}
1045
+ beepTime={markerLocal}
1046
+ pixelsPerSecond={pxPerSec}
1047
+ height={fillHeight ? 200 : 80}
1048
+ ariaLabel={ariaLabel ?? `Beep editor waveform for stage ${stageNumber}`}
1049
+ />
1050
+ </div>
1051
+ {/* Zoom-shortcut chrome -- mirrors the time-ruler footer that
1052
+ lives under the audit canvas waveform. Same bindings
1053
+ (modKey+1/2/3) so muscle memory carries between surfaces.
1054
+ Right-aligned; the eyebrow on the left labels the row so the
1055
+ chord doesn't read as orphan keys. */}
1056
+ <div className="-mx-3 mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t border-rule bg-surface-3/60 px-4 py-2 font-mono text-[0.625rem] uppercase tracking-[0.08em] text-subtle">
1057
+ <span className="tracking-[0.14em] text-muted">Zoom</span>
1058
+ <span className="ml-auto inline-flex items-center gap-1.5">
1059
+ <kbd className="rounded border border-rule-strong bg-surface-2 px-1.5 py-px font-mono text-[0.625rem] font-semibold text-ink-2">
1060
+ {modKeyGlyph()}1
1061
+ </kbd>
1062
+ <span>in</span>
1063
+ </span>
1064
+ <span className="inline-flex items-center gap-1.5">
1065
+ <kbd className="rounded border border-rule-strong bg-surface-2 px-1.5 py-px font-mono text-[0.625rem] font-semibold text-ink-2">
1066
+ {modKeyGlyph()}2
1067
+ </kbd>
1068
+ <span>fit</span>
1069
+ </span>
1070
+ <span className="inline-flex items-center gap-1.5">
1071
+ <kbd className="rounded border border-rule-strong bg-surface-2 px-1.5 py-px font-mono text-[0.625rem] font-semibold text-ink-2">
1072
+ {modKeyGlyph()}3
1073
+ </kbd>
1074
+ <span>out</span>
1075
+ </span>
1076
+ </div>
1077
+ {externalMediaRef ? null : (
1078
+ <audio
1079
+ ref={internalAudioRef}
1080
+ src={api.videoAudioUrl(slug, stageNumber, videoId)}
1081
+ preload="metadata"
1082
+ onPlay={() => setPlaying(true)}
1083
+ onPause={() => setPlaying(false)}
1084
+ onEnded={() => setPlaying(false)}
1085
+ onLoadedMetadata={() => {
1086
+ // Retry the initial seek when audio metadata lands
1087
+ // after peaks did. Without this, the picker shows the
1088
+ // beep marker but Play starts at 0.
1089
+ const el = internalAudioRef.current;
1090
+ if (!el) return;
1091
+ if (Math.abs(el.currentTime - localTime) > 0.05) {
1092
+ try {
1093
+ el.currentTime = localTime;
1094
+ } catch {
1095
+ /* unreachable -- metadata loaded by definition */
1096
+ }
1097
+ }
1098
+ }}
1099
+ controls
1100
+ className="w-full"
1101
+ />
1102
+ )}
1103
+ {/* Play / Snap buttons live in this row only when the picker
1104
+ owns its own <audio>. When the parent passes an external
1105
+ media element (e.g. BeepReview's <video controls>), the
1106
+ play affordance + Space toggle already exist on that
1107
+ element -- a redundant button row just eats vertical space. */}
1108
+ {externalMediaRef ? null : (
1109
+ <div className="flex flex-wrap items-center gap-2 pt-1">
952
1110
  <Button
953
1111
  size="sm"
954
- variant="default"
955
- onClick={() => void requestSnap()}
956
- disabled={draftSourceTime == null || snapping}
957
- title="Snap the marker to the rise-foot of the nearest beep tone (±1.5s)"
1112
+ variant="outline"
1113
+ onClick={togglePlay}
1114
+ title="Play / pause the audio"
958
1115
  >
959
- {snapping ? <Loader2 className="animate-spin" /> : <Crosshair />}
960
- Snap to beep
1116
+ {playing ? <Pause /> : <Play />}
1117
+ {playing ? "Pause" : "Play"}
961
1118
  </Button>
962
- ) : null}
963
- </div>
1119
+ {snapEnabled ? (
1120
+ <Button
1121
+ size="sm"
1122
+ variant="default"
1123
+ onClick={() => void requestSnap()}
1124
+ disabled={draftSourceTime == null || snapping}
1125
+ title="Snap the marker to the rise-foot of the nearest beep tone (±1.5s)"
1126
+ >
1127
+ {snapping ? <Loader2 className="animate-spin" /> : <Crosshair />}
1128
+ Snap to beep
1129
+ </Button>
1130
+ ) : null}
1131
+ </div>
1132
+ )}
964
1133
  {proposal != null ? (
965
1134
  <SnapProposal
966
1135
  slug={slug}
@@ -14,6 +14,7 @@ import { useEffect, useRef } from "react";
14
14
  import { X } from "lucide-react";
15
15
 
16
16
  import { Button } from "@/components/ui/button";
17
+ import { modKeyGlyph } from "@/lib/platform";
17
18
  import { cn } from "@/lib/utils";
18
19
 
19
20
  export type HelpMode = "audit" | "review";
@@ -35,6 +36,7 @@ interface Section {
35
36
  }
36
37
 
37
38
  function sections(mode: HelpMode): Section[] {
39
+ const mod = modKeyGlyph();
38
40
  const playback: ShortcutRow[] = [
39
41
  { keys: ["Space"], desc: "Play / pause" },
40
42
  { keys: ["←", "→"], desc: "Nudge playhead 250 ms" },
@@ -54,14 +56,14 @@ function sections(mode: HelpMode): Section[] {
54
56
  ];
55
57
  const view: ShortcutRow[] = [
56
58
  { keys: ["L"], desc: "Toggle the marker list drawer" },
57
- { keys: ["⌘", "1"], desc: "Zoom in" },
58
- { keys: ["⌘", "2"], desc: "Fit waveform to view" },
59
- { keys: ["⌘", "3"], desc: "Zoom out" },
59
+ { keys: [mod, "1"], desc: "Zoom in" },
60
+ { keys: [mod, "2"], desc: "Fit waveform to view" },
61
+ { keys: [mod, "3"], desc: "Zoom out" },
60
62
  ];
61
63
  const edit: ShortcutRow[] = [
62
- { keys: ["⌘", "Z"], desc: "Undo last marker change" },
64
+ { keys: [mod, "Z"], desc: "Undo last marker change" },
63
65
  {
64
- keys: ["⌘", "S"],
66
+ keys: [mod, "S"],
65
67
  desc:
66
68
  mode === "audit"
67
69
  ? "Save audit JSON and jump to the next stage"
@@ -0,0 +1,71 @@
1
+ import { ArrowRight, Link2 } from "lucide-react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ import { useMatchHref } from "@/lib/matchHref";
5
+
6
+ export interface BeepAnomalyBannerProps {
7
+ /** The diagnostic produced by Audit's "first shot / overshoot" heuristic
8
+ * (Audit.tsx -> beepDiagnostic). Render-gated by the parent so this
9
+ * component only mounts when a real diagnostic exists. */
10
+ reason: string;
11
+ /** Shooter slug for the deep-link target. */
12
+ slug: string;
13
+ /** Stage number for the deep-link target. */
14
+ stageNumber: number;
15
+ /** Primary video id for the deep-link target. The /beep-review queue
16
+ * keys items by ``slug::stage::video_id``; we pass the same shape via
17
+ * ``?focus=...`` so the queue opens with the exact item active. */
18
+ videoId: string;
19
+ }
20
+
21
+ /**
22
+ * Amber "beep looks wrong" banner that lives below the audit toolbar.
23
+ *
24
+ * Replaces the old in-page sync-mode entry (the chip's "Re-pick beep"
25
+ * button). Audit is now read-only with respect to the beep; the banner
26
+ * names the suspicion and ships the operator to /beep-review where the
27
+ * waveform picker + video preview live.
28
+ *
29
+ * Scoping: the banner fires only on the active shooter's video, never
30
+ * cross-shooter. Cross-shooter awareness ("3 beeps pending on stage 02")
31
+ * belongs in the queue, not in a per-stage warning surface here.
32
+ */
33
+ export function BeepAnomalyBanner({
34
+ reason,
35
+ slug,
36
+ stageNumber,
37
+ videoId,
38
+ }: BeepAnomalyBannerProps) {
39
+ const href = useMatchHref();
40
+ const focusKey = `${slug}::${stageNumber}::${videoId}`;
41
+ const target = `${href("beep-review")}?focus=${encodeURIComponent(focusKey)}`;
42
+ return (
43
+ <div
44
+ role="status"
45
+ className="flex flex-wrap items-start gap-3 rounded-2xl border border-live/40 bg-live/[0.08] px-4 py-3.5"
46
+ >
47
+ <span
48
+ aria-hidden
49
+ className="mt-0.5 inline-flex size-5.5 shrink-0 items-center justify-center rounded-full border border-live/60 bg-live/10 font-mono text-[0.6875rem] font-bold text-live"
50
+ >
51
+ !
52
+ </span>
53
+ <div className="flex min-w-[14rem] flex-1 flex-col gap-1">
54
+ <div className="font-display text-[0.75rem] font-bold uppercase tracking-[0.08em] text-live">
55
+ Looks like the beep is wrong
56
+ </div>
57
+ <p className="m-0 text-[0.8125rem] leading-snug text-ink-2">
58
+ {reason}
59
+ </p>
60
+ </div>
61
+ <Link
62
+ to={target}
63
+ className="inline-flex items-center gap-1.5 rounded-md border-0 bg-led-fill px-3.5 py-2 font-display text-[0.6875rem] font-bold uppercase tracking-[0.08em] text-ink shadow-[0_0_0_1px_var(--color-led),0_0_14px_var(--color-led-glow)] hover:bg-led"
64
+ >
65
+ <Link2 className="size-3" strokeWidth={2} aria-hidden />
66
+ Review this beep
67
+ <ArrowRight className="size-3" aria-hidden />
68
+ </Link>
69
+ </div>
70
+ );
71
+ }