splitsmith 0.2.0__py3-none-any.whl

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 (109) hide show
  1. splitsmith/__init__.py +3 -0
  2. splitsmith/audit.py +87 -0
  3. splitsmith/automation.py +238 -0
  4. splitsmith/backup.py +298 -0
  5. splitsmith/beep_calibration.py +324 -0
  6. splitsmith/beep_detect.py +371 -0
  7. splitsmith/cleanup.py +327 -0
  8. splitsmith/cli.py +1281 -0
  9. splitsmith/coach.py +253 -0
  10. splitsmith/coach_distributions.py +348 -0
  11. splitsmith/compare/__init__.py +7 -0
  12. splitsmith/compare/cli.py +153 -0
  13. splitsmith/compare/emitter.py +456 -0
  14. splitsmith/compare/filler.py +98 -0
  15. splitsmith/compare/layout.py +164 -0
  16. splitsmith/compare/manifest.py +91 -0
  17. splitsmith/compare/project_loader.py +195 -0
  18. splitsmith/composition.py +606 -0
  19. splitsmith/config.py +442 -0
  20. splitsmith/cross_align.py +210 -0
  21. splitsmith/csv_gen.py +66 -0
  22. splitsmith/data/ensemble_calibration.json +248 -0
  23. splitsmith/data/fonts/Antonio-OFL.txt +93 -0
  24. splitsmith/data/fonts/Antonio-VariableFont.ttf +0 -0
  25. splitsmith/data/fonts/JetBrainsMono-Bold.ttf +0 -0
  26. splitsmith/data/fonts/JetBrainsMono-OFL.txt +93 -0
  27. splitsmith/data/overlay_theme.json +40 -0
  28. splitsmith/data/templates/action-cut.yaml +19 -0
  29. splitsmith/data/templates/match-recap.yaml +20 -0
  30. splitsmith/data/voter_c_gbdt.joblib +0 -0
  31. splitsmith/data/voter_e_visual_probe.joblib +0 -0
  32. splitsmith/ensemble/__init__.py +67 -0
  33. splitsmith/ensemble/agc_state.py +165 -0
  34. splitsmith/ensemble/api.py +419 -0
  35. splitsmith/ensemble/backend.py +89 -0
  36. splitsmith/ensemble/calibration.py +367 -0
  37. splitsmith/ensemble/clap_mel.py +138 -0
  38. splitsmith/ensemble/features.py +680 -0
  39. splitsmith/ensemble/fixtures.py +222 -0
  40. splitsmith/ensemble/tta.py +115 -0
  41. splitsmith/ensemble/visual.py +294 -0
  42. splitsmith/ensemble/voters.py +202 -0
  43. splitsmith/fcp7xml_render.py +558 -0
  44. splitsmith/fcpxml_gen.py +1721 -0
  45. splitsmith/fixture_schema.py +482 -0
  46. splitsmith/lab/__init__.py +79 -0
  47. splitsmith/lab/core.py +1118 -0
  48. splitsmith/lab/promote.py +555 -0
  49. splitsmith/lab/snap_window.py +331 -0
  50. splitsmith/lab/sweeps.py +231 -0
  51. splitsmith/lab_cli.py +750 -0
  52. splitsmith/match_cli.py +315 -0
  53. splitsmith/match_model.py +793 -0
  54. splitsmith/match_registry.py +131 -0
  55. splitsmith/mcp/__init__.py +23 -0
  56. splitsmith/mcp/__main__.py +20 -0
  57. splitsmith/mcp/detect_tools.py +476 -0
  58. splitsmith/mcp/export_tools.py +356 -0
  59. splitsmith/mcp/sandbox.py +77 -0
  60. splitsmith/mcp/server.py +393 -0
  61. splitsmith/mcp/tools.py +207 -0
  62. splitsmith/mcp/write_tools.py +268 -0
  63. splitsmith/model_cli.py +153 -0
  64. splitsmith/models/__init__.py +40 -0
  65. splitsmith/models/cache.py +139 -0
  66. splitsmith/models/download.py +95 -0
  67. splitsmith/models/errors.py +50 -0
  68. splitsmith/models/manifest.py +68 -0
  69. splitsmith/models/registry.py +256 -0
  70. splitsmith/mp4_render.py +513 -0
  71. splitsmith/overlay_render.py +817 -0
  72. splitsmith/overlay_theme.py +146 -0
  73. splitsmith/relink.py +245 -0
  74. splitsmith/report.py +258 -0
  75. splitsmith/runtime.py +268 -0
  76. splitsmith/shot_detect.py +506 -0
  77. splitsmith/shot_refine.py +252 -0
  78. splitsmith/system_check.py +162 -0
  79. splitsmith/templates.py +188 -0
  80. splitsmith/thumbnail.py +230 -0
  81. splitsmith/trim.py +211 -0
  82. splitsmith/ui/__init__.py +10 -0
  83. splitsmith/ui/audio.py +536 -0
  84. splitsmith/ui/embedded.py +312 -0
  85. splitsmith/ui/exports.py +533 -0
  86. splitsmith/ui/jobs.py +652 -0
  87. splitsmith/ui/logging_setup.py +108 -0
  88. splitsmith/ui/match_exports.py +500 -0
  89. splitsmith/ui/project.py +1734 -0
  90. splitsmith/ui/scoreboard/__init__.py +77 -0
  91. splitsmith/ui/scoreboard/cache.py +237 -0
  92. splitsmith/ui/scoreboard/http.py +206 -0
  93. splitsmith/ui/scoreboard/local.py +377 -0
  94. splitsmith/ui/scoreboard/models.py +301 -0
  95. splitsmith/ui/scoreboard/protocol.py +51 -0
  96. splitsmith/ui/server.py +9178 -0
  97. splitsmith/ui_static/package-lock.json +3062 -0
  98. splitsmith/ui_static/tsconfig.app.tsbuildinfo +1 -0
  99. splitsmith/ui_static/tsconfig.node.tsbuildinfo +1 -0
  100. splitsmith/user_config.py +380 -0
  101. splitsmith/video_match.py +159 -0
  102. splitsmith/video_probe.py +143 -0
  103. splitsmith/waveform.py +121 -0
  104. splitsmith/youtube_sidecar.py +293 -0
  105. splitsmith-0.2.0.dist-info/METADATA +301 -0
  106. splitsmith-0.2.0.dist-info/RECORD +109 -0
  107. splitsmith-0.2.0.dist-info/WHEEL +4 -0
  108. splitsmith-0.2.0.dist-info/entry_points.txt +3 -0
  109. splitsmith-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,146 @@
1
+ """Color palette for the alpha overlay renderer.
2
+
3
+ Two presets ship today:
4
+
5
+ - ``splitsmith`` (default): tokens lifted from the web UI's
6
+ ``src/splitsmith/ui_static/src/styles/index.css`` ``@theme`` block --
7
+ the Shot Timer brand palette. Built into
8
+ ``src/splitsmith/data/overlay_theme.json`` by
9
+ ``scripts/build_overlay_theme.py`` so the overlay can't silently drift
10
+ from the rest of the design system.
11
+ - ``clean``: a neutral white-on-amber palette with a pure-black stroke.
12
+ No brand colours; useful when the overlay needs to read against any
13
+ background without identifying the tool.
14
+
15
+ The JSON mirror is intentional: parsing CSS at runtime would mean a CSS
16
+ parser as a runtime dep, and the overlay only needs a handful of tokens.
17
+ Re-run the build script after touching ``index.css``.
18
+
19
+ Bundled fonts (Antonio + JetBrains Mono, SIL OFL 1.1) live under
20
+ ``src/splitsmith/data/fonts/`` so the ``splitsmith`` theme renders
21
+ deterministic typography without depending on whatever the host machine
22
+ happens to have installed. The numeric readouts use JetBrains Mono Bold
23
+ today; Antonio is bundled for future templates that mix in display
24
+ labels.
25
+
26
+ Down the line, swapping PIL for a Skia-based renderer would buy proper
27
+ HarfBuzz shaping (kerning, ligatures, condensed-face width control)
28
+ which only starts to matter once the template grows real label content.
29
+ The font names already live in the JSON so that swap doesn't need new
30
+ tokens.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from dataclasses import dataclass
37
+ from importlib import resources
38
+ from typing import Literal
39
+
40
+ ThemeName = Literal["splitsmith", "clean"]
41
+ """Stable identifiers exposed in the export request + CLI."""
42
+
43
+ THEME_NAMES: tuple[ThemeName, ...] = ("splitsmith", "clean")
44
+
45
+ RGB = tuple[int, int, int]
46
+
47
+
48
+ class OverlayThemeError(RuntimeError):
49
+ """Raised when the design-system JSON is missing or malformed."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class OverlayTheme:
54
+ """Palette + font name hints used by ``DefaultTemplate``.
55
+
56
+ All colors are 8-bit RGB tuples; alpha is applied at draw time by the
57
+ template (the last-split label fades, shadows track foreground alpha,
58
+ etc.). Font names are advisory: today they only matter when the caller
59
+ also passes ``font_name=None`` so the template can fall back to a
60
+ sensible default for the theme.
61
+ """
62
+
63
+ name: ThemeName
64
+ ink: RGB
65
+ split: RGB
66
+ stroke: RGB
67
+ accent: RGB
68
+ font_display: str
69
+ font_mono: str
70
+
71
+ @property
72
+ def shadow(self) -> RGB:
73
+ """Drop shadow color. Today this matches the stroke -- a dark halo
74
+ reads cleanly on both bright and busy backgrounds. Kept as a
75
+ property so a future variant can carry an explicit token without
76
+ churning callers."""
77
+ return self.stroke
78
+
79
+
80
+ _CLEAN = OverlayTheme(
81
+ name="clean",
82
+ ink=(255, 255, 255),
83
+ split=(255, 220, 80),
84
+ stroke=(0, 0, 0),
85
+ accent=(255, 45, 45),
86
+ font_display="Antonio",
87
+ font_mono="JetBrains Mono",
88
+ )
89
+
90
+
91
+ def _load_splitsmith() -> OverlayTheme:
92
+ try:
93
+ with (
94
+ resources.files("splitsmith.data")
95
+ .joinpath("overlay_theme.json")
96
+ .open("r", encoding="utf-8") as fh
97
+ ):
98
+ data = json.load(fh)
99
+ except (FileNotFoundError, OSError) as exc:
100
+ raise OverlayThemeError("overlay_theme.json missing; run scripts/build_overlay_theme.py") from exc
101
+ except json.JSONDecodeError as exc:
102
+ raise OverlayThemeError(f"overlay_theme.json is not valid JSON: {exc}") from exc
103
+
104
+ colors = data.get("colors") or {}
105
+ fonts = data.get("fonts") or {}
106
+ try:
107
+ return OverlayTheme(
108
+ name="splitsmith",
109
+ ink=_rgb(colors, "ink"),
110
+ split=_rgb(colors, "split"),
111
+ stroke=_rgb(colors, "stroke"),
112
+ accent=_rgb(colors, "accent"),
113
+ font_display=str(fonts.get("display", "Antonio")),
114
+ font_mono=str(fonts.get("mono", "JetBrains Mono")),
115
+ )
116
+ except (KeyError, TypeError, ValueError) as exc:
117
+ raise OverlayThemeError(f"overlay_theme.json malformed: {exc}") from exc
118
+
119
+
120
+ def _rgb(colors: dict, role: str) -> RGB:
121
+ raw = colors[role]
122
+ if not isinstance(raw, (list, tuple)) or len(raw) != 3:
123
+ raise ValueError(f"{role!r} must be a 3-element list, got {raw!r}")
124
+ r, g, b = (int(v) for v in raw)
125
+ for v in (r, g, b):
126
+ if not 0 <= v <= 255:
127
+ raise ValueError(f"{role!r} channel out of 0..255 range: {raw!r}")
128
+ return r, g, b
129
+
130
+
131
+ def load_theme(name: ThemeName) -> OverlayTheme:
132
+ """Resolve a theme name to its palette. Cached at the module level so
133
+ repeated stage exports don't re-read the JSON. Raises
134
+ ``OverlayThemeError`` for unknown names or a missing splitsmith JSON
135
+ artefact."""
136
+ if name == "clean":
137
+ return _CLEAN
138
+ if name == "splitsmith":
139
+ global _SPLITSMITH
140
+ if _SPLITSMITH is None:
141
+ _SPLITSMITH = _load_splitsmith()
142
+ return _SPLITSMITH
143
+ raise OverlayThemeError(f"unknown theme {name!r}; expected one of {THEME_NAMES}")
144
+
145
+
146
+ _SPLITSMITH: OverlayTheme | None = None
splitsmith/relink.py ADDED
@@ -0,0 +1,245 @@
1
+ """Relink registered video sources after they move on disk.
2
+
3
+ Project ``raw/<name>`` entries are typically symlinks to the original
4
+ recordings; ``project.json`` stores the relative ``raw/<name>`` path.
5
+ When the originals move (e.g. onto a network share with a different
6
+ folder layout), nothing in ``project.json`` needs to change -- only the
7
+ symlink targets under ``raw/``.
8
+
9
+ This module provides pure helpers used by both the API and tests:
10
+
11
+ - :func:`inspect_links` reports the per-video link status (ok / broken /
12
+ missing / not-a-symlink) for the current project.
13
+ - :func:`index_search_root` recursively walks a folder and indexes
14
+ videos by lowercase basename.
15
+ - :func:`plan_relink` matches the project's registered videos against
16
+ that index, defaulting to the single-candidate match when the basename
17
+ is unique inside the search root.
18
+ - :func:`apply_relink` rewrites the symlinks atomically (delete + create
19
+ in one step, mirroring ``ln -sfn``).
20
+
21
+ All functions are pure aside from :func:`apply_relink`, which is the
22
+ single place that mutates the filesystem.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ from .ui.project import VIDEO_EXTENSIONS, MatchProject
32
+
33
+ LinkStatus = Literal["ok", "broken", "missing_link", "not_a_symlink"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LinkInfo:
38
+ """Filesystem state of one ``raw/<name>`` entry.
39
+
40
+ ``target`` is the symlink target as stored on disk (may be relative).
41
+ ``status`` reduces the four-state matrix to a single label the UI
42
+ can colour-code.
43
+ """
44
+
45
+ video_id: str
46
+ name: str
47
+ link_path: Path
48
+ target: Path | None
49
+ is_symlink: bool
50
+ target_exists: bool
51
+ status: LinkStatus
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class RelinkCandidate:
56
+ """One row in the relink plan: registered video + matches found in
57
+ the search root.
58
+
59
+ ``chosen_path`` defaults to the only candidate when exactly one was
60
+ found, leaves ``None`` for zero or many (the UI surfaces ambiguity).
61
+ """
62
+
63
+ video_id: str
64
+ name: str
65
+ link_path: Path
66
+ current_target: Path | None
67
+ current_status: LinkStatus
68
+ candidates: list[Path] = field(default_factory=list)
69
+ chosen_path: Path | None = None
70
+
71
+ @property
72
+ def ambiguous(self) -> bool:
73
+ return len(self.candidates) > 1
74
+
75
+ @property
76
+ def found(self) -> bool:
77
+ return bool(self.candidates)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class AppliedRelink:
82
+ """Result of one applied symlink rewrite."""
83
+
84
+ video_id: str
85
+ name: str
86
+ link_path: Path
87
+ previous_target: Path | None
88
+ new_target: Path
89
+
90
+
91
+ def _link_status(link_path: Path) -> tuple[LinkStatus, Path | None, bool, bool]:
92
+ """Inspect a ``raw/<name>`` entry. Returns ``(status, target,
93
+ is_symlink, target_exists)``.
94
+
95
+ Uses ``os.path.islink`` semantics via ``Path.is_symlink`` -- a broken
96
+ symlink is detected as a symlink whose target doesn't resolve.
97
+ """
98
+ if not link_path.exists() and not link_path.is_symlink():
99
+ return "missing_link", None, False, False
100
+ if link_path.is_symlink():
101
+ target = Path(link_path.readlink())
102
+ # Resolve relative targets against the link's parent so we report
103
+ # the actual file we're pointing at.
104
+ resolved = target if target.is_absolute() else (link_path.parent / target).resolve()
105
+ target_exists = resolved.exists()
106
+ return ("ok" if target_exists else "broken"), target, True, target_exists
107
+ # Plain file -- not a symlink. Could be a registered copy (link_mode
108
+ # = "copy") or a stray. Either way relinking doesn't apply.
109
+ return "not_a_symlink", None, False, link_path.exists()
110
+
111
+
112
+ def inspect_links(project: MatchProject, root: Path) -> list[LinkInfo]:
113
+ """Report current link status for every registered video.
114
+
115
+ Stages are walked in declaration order and each video appears once;
116
+ if the same path is registered to multiple roles, the first
117
+ occurrence wins (matches :meth:`MatchProject.all_videos`).
118
+ """
119
+ raw_dir = project.raw_path(root)
120
+ out: list[LinkInfo] = []
121
+ seen: set[str] = set()
122
+ for video in project.all_videos():
123
+ link_path = (root / video.path) if not video.path.is_absolute() else video.path
124
+ # Project paths are stored as ``raw/<name>``; the registry only
125
+ # ever points inside ``raw_dir``. Resolving against root keeps
126
+ # the helper correct even if the user has overridden ``raw_dir``.
127
+ if not link_path.is_absolute():
128
+ link_path = raw_dir / link_path.name
129
+ key = str(link_path)
130
+ if key in seen:
131
+ continue
132
+ seen.add(key)
133
+ status, target, is_symlink, target_exists = _link_status(link_path)
134
+ out.append(
135
+ LinkInfo(
136
+ video_id=video.video_id,
137
+ name=link_path.name,
138
+ link_path=link_path,
139
+ target=target,
140
+ is_symlink=is_symlink,
141
+ target_exists=target_exists,
142
+ status=status,
143
+ )
144
+ )
145
+ return out
146
+
147
+
148
+ def index_search_root(search_root: Path) -> dict[str, list[Path]]:
149
+ """Recursively index video files under ``search_root`` by
150
+ lowercase basename.
151
+
152
+ Multiple files with the same basename in different subfolders are
153
+ all collected; callers surface the ambiguity to the user. Only files
154
+ with extensions in :data:`VIDEO_EXTENSIONS` are indexed.
155
+
156
+ Symlinks inside the search root are followed via ``rglob`` default
157
+ behaviour (``Path.rglob`` does not follow dir symlinks by default,
158
+ which is what we want -- avoids cycles on network shares).
159
+ """
160
+ if not search_root.exists():
161
+ raise FileNotFoundError(f"search root does not exist: {search_root}")
162
+ if not search_root.is_dir():
163
+ raise NotADirectoryError(f"search root is not a directory: {search_root}")
164
+ index: dict[str, list[Path]] = {}
165
+ for entry in search_root.rglob("*"):
166
+ if not entry.is_file():
167
+ continue
168
+ if entry.suffix.lower() not in VIDEO_EXTENSIONS:
169
+ continue
170
+ index.setdefault(entry.name.lower(), []).append(entry.resolve())
171
+ # Stable order so dry-run output is deterministic for tests.
172
+ for paths in index.values():
173
+ paths.sort()
174
+ return index
175
+
176
+
177
+ def plan_relink(
178
+ links: list[LinkInfo],
179
+ index: dict[str, list[Path]],
180
+ ) -> list[RelinkCandidate]:
181
+ """Build a relink plan: each registered video gets the candidate
182
+ paths found in the search root (matched by lowercase basename).
183
+
184
+ ``chosen_path`` is filled in only when exactly one candidate exists
185
+ *and* it differs from the current target. The UI can override on
186
+ apply.
187
+ """
188
+ out: list[RelinkCandidate] = []
189
+ for info in links:
190
+ candidates = list(index.get(info.name.lower(), []))
191
+ chosen: Path | None = None
192
+ if len(candidates) == 1:
193
+ single = candidates[0]
194
+ # Skip the no-op case so the apply step doesn't re-write
195
+ # an already-correct symlink.
196
+ if info.target is None or info.target.resolve() != single:
197
+ chosen = single
198
+ out.append(
199
+ RelinkCandidate(
200
+ video_id=info.video_id,
201
+ name=info.name,
202
+ link_path=info.link_path,
203
+ current_target=info.target,
204
+ current_status=info.status,
205
+ candidates=candidates,
206
+ chosen_path=chosen,
207
+ )
208
+ )
209
+ return out
210
+
211
+
212
+ def apply_relink(
213
+ decisions: list[tuple[Path, Path]],
214
+ ) -> list[AppliedRelink]:
215
+ """Rewrite each symlink to its new target (``ln -sfn`` equivalent).
216
+
217
+ ``decisions`` is a list of ``(link_path, new_target)`` pairs. The
218
+ new target is stored as an absolute path so the symlink survives
219
+ project-root moves. The previous target is captured for the
220
+ response so the UI can show a "was -> now" diff.
221
+
222
+ Refuses to operate on entries that exist and are not symlinks (the
223
+ ``not_a_symlink`` status). Callers should filter those out first.
224
+ """
225
+ applied: list[AppliedRelink] = []
226
+ for link_path, new_target in decisions:
227
+ new_target_abs = new_target if new_target.is_absolute() else new_target.resolve()
228
+ previous: Path | None = None
229
+ if link_path.is_symlink():
230
+ previous = Path(link_path.readlink())
231
+ link_path.unlink()
232
+ elif link_path.exists():
233
+ raise ValueError(f"refusing to overwrite non-symlink at {link_path} (status not_a_symlink)")
234
+ link_path.parent.mkdir(parents=True, exist_ok=True)
235
+ link_path.symlink_to(new_target_abs)
236
+ applied.append(
237
+ AppliedRelink(
238
+ video_id="", # filled in by callers that know the project
239
+ name=link_path.name,
240
+ link_path=link_path,
241
+ previous_target=previous,
242
+ new_target=new_target_abs,
243
+ )
244
+ )
245
+ return applied
splitsmith/report.py ADDED
@@ -0,0 +1,258 @@
1
+ """Human-readable per-stage analysis reports + anomaly flagging.
2
+
3
+ Anomaly rules (from SPEC.md):
4
+ - Beep-to-last-shot window differs from official ``stage.time_seconds`` by >500 ms.
5
+ - Any split <80 ms (likely double-detection of a single shot).
6
+ - Any split >3 s within the stage window (likely a missed shot, or a long transition).
7
+ - Shot count outside a "typical IPSC stage" band (informational, not a hard error).
8
+
9
+ ASCII-only output (per CLAUDE.md): tags use ``[OK]``, ``[!]``, etc. instead of
10
+ Unicode glyphs so the report renders the same in any terminal / pager.
11
+
12
+ Two flavours of the anomaly check live here:
13
+
14
+ - :func:`detect_anomalies_structured` returns :class:`Anomaly` records
15
+ carrying ``kind`` + ``shot_number`` + ``time`` so the audit screen can
16
+ render clickable entries that jump to the offending marker (issue #42).
17
+ - :func:`detect_anomalies` is the legacy string-list shape used by the
18
+ CLI / report.txt; it stringifies the structured output so the rendered
19
+ report bytes are unchanged.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Literal
26
+
27
+ from pydantic import BaseModel
28
+
29
+ from .config import ReportFiles, Shot, SplitColorThresholds, StageAnalysis
30
+
31
+ # Anomaly thresholds.
32
+ _OFFICIAL_TIME_TOLERANCE_S = 0.500 # beep -> last shot vs stage.time_seconds
33
+ _DOUBLE_DETECTION_MAX_S = 0.080 # min legitimate split
34
+ _LONG_PAUSE_MAX_S = 3.000 # split above this is suspicious within the stage window
35
+ _SLOW_DRAW_S = 1.500 # shot 1 split greater than this gets a slow-draw note
36
+ _TYPICAL_ROUND_RANGE = (8, 32) # informational shot-count band
37
+
38
+
39
+ AnomalyKind = Literal[
40
+ "no_shots",
41
+ "stage_time_mismatch",
42
+ "double_detection",
43
+ "long_pause",
44
+ "shot_count_low",
45
+ "shot_count_high",
46
+ ]
47
+ AnomalySeverity = Literal["info", "warn"]
48
+
49
+
50
+ class Anomaly(BaseModel):
51
+ """Structured anomaly emitted by :func:`detect_anomalies_structured`.
52
+
53
+ ``kind`` tags the rule so the SPA can group / filter without parsing
54
+ ``message``. ``shot_number`` (1-based, matches :class:`Shot.shot_number`)
55
+ and ``time`` (seconds from beep) are populated for shot-bound anomalies
56
+ so the audit screen can scroll to the offending marker on click.
57
+ Stage-level anomalies (count band, no shots) leave both null.
58
+ """
59
+
60
+ kind: AnomalyKind
61
+ severity: AnomalySeverity
62
+ message: str
63
+ shot_number: int | None = None
64
+ time: float | None = None
65
+
66
+
67
+ def detect_anomalies_structured(
68
+ shots: list[Shot],
69
+ beep_time: float, # noqa: ARG001 -- kept for symmetry with caller; absolute beep time
70
+ stage_time: float,
71
+ ) -> list[Anomaly]:
72
+ """Return structured :class:`Anomaly` records; empty list means "all clean"."""
73
+ anomalies: list[Anomaly] = []
74
+
75
+ if not shots:
76
+ anomalies.append(
77
+ Anomaly(
78
+ kind="no_shots",
79
+ severity="warn",
80
+ message="No shots detected in the stage window.",
81
+ )
82
+ )
83
+ return anomalies
84
+
85
+ last_after_beep = shots[-1].time_from_beep
86
+ delta = last_after_beep - stage_time
87
+ if abs(delta) > _OFFICIAL_TIME_TOLERANCE_S:
88
+ anomalies.append(
89
+ Anomaly(
90
+ kind="stage_time_mismatch",
91
+ severity="warn",
92
+ message=(
93
+ f"Last detected shot is {abs(delta) * 1000:.0f} ms "
94
+ f"{'after' if delta > 0 else 'before'} official stage time "
95
+ f"({last_after_beep:.3f} s vs {stage_time:.3f} s)."
96
+ ),
97
+ shot_number=shots[-1].shot_number,
98
+ time=last_after_beep,
99
+ )
100
+ )
101
+
102
+ for s in shots[1:]: # shot 1's "split" is the draw, not a real split
103
+ if s.split < _DOUBLE_DETECTION_MAX_S:
104
+ anomalies.append(
105
+ Anomaly(
106
+ kind="double_detection",
107
+ severity="warn",
108
+ message=(
109
+ f"Shot {s.shot_number} split is {s.split * 1000:.0f} ms "
110
+ f"(< {_DOUBLE_DETECTION_MAX_S * 1000:.0f} ms): "
111
+ f"possible double-detection."
112
+ ),
113
+ shot_number=s.shot_number,
114
+ time=s.time_from_beep,
115
+ )
116
+ )
117
+ elif s.split > _LONG_PAUSE_MAX_S:
118
+ anomalies.append(
119
+ Anomaly(
120
+ kind="long_pause",
121
+ severity="warn",
122
+ message=(
123
+ f"Shot {s.shot_number} split is {s.split:.3f} s "
124
+ f"(> {_LONG_PAUSE_MAX_S:.1f} s): missed shot or long transition?"
125
+ ),
126
+ shot_number=s.shot_number,
127
+ time=s.time_from_beep,
128
+ )
129
+ )
130
+
131
+ lo, hi = _TYPICAL_ROUND_RANGE
132
+ if not (lo <= len(shots) <= hi):
133
+ is_low = len(shots) < lo
134
+ anomalies.append(
135
+ Anomaly(
136
+ kind="shot_count_low" if is_low else "shot_count_high",
137
+ severity="info",
138
+ message=(
139
+ f"Detected {len(shots)} shots; typical IPSC stages have {lo}-{hi}. "
140
+ f"Review for "
141
+ f"{'missed shots' if is_low else 'false positives (echoes / other bays)'}."
142
+ ),
143
+ )
144
+ )
145
+
146
+ return anomalies
147
+
148
+
149
+ def detect_anomalies(
150
+ shots: list[Shot],
151
+ beep_time: float,
152
+ stage_time: float,
153
+ ) -> list[str]:
154
+ """Return human-readable anomaly strings; empty list means "all clean".
155
+
156
+ Thin wrapper over :func:`detect_anomalies_structured` so the report.txt
157
+ bullet rendering stays byte-identical to its pre-#42 output.
158
+ """
159
+ return [a.message for a in detect_anomalies_structured(shots, beep_time, stage_time)]
160
+
161
+
162
+ def render_report(
163
+ analysis: StageAnalysis,
164
+ files: ReportFiles | None = None,
165
+ *,
166
+ color_thresholds: SplitColorThresholds | None = None,
167
+ ) -> str:
168
+ """Render the SPEC.md-shaped per-stage report as a single ASCII string."""
169
+ files = files or ReportFiles()
170
+ thresholds = color_thresholds or SplitColorThresholds()
171
+
172
+ stage = analysis.stage
173
+ shots = analysis.shots
174
+
175
+ lines: list[str] = []
176
+ lines.append(f'Stage {stage.stage_number} -- "{stage.stage_name}"')
177
+ lines.append(f"Official time: {stage.time_seconds:.3f}s")
178
+ lines.append(f"Detected beep at: {analysis.beep_time:.3f}s")
179
+
180
+ if shots:
181
+ last = shots[-1]
182
+ delta_ms = (last.time_from_beep - stage.time_seconds) * 1000.0
183
+ match_marker = "[OK]" if abs(delta_ms) <= _OFFICIAL_TIME_TOLERANCE_S * 1000 else "[!]"
184
+ lines.append(
185
+ f"Detected last shot: {last.time_absolute:.3f}s "
186
+ f"({last.time_from_beep:.3f}s after beep) -- "
187
+ f"{'matches' if match_marker == '[OK]' else 'differs from'} "
188
+ f"official by {abs(delta_ms):.0f}ms {match_marker}"
189
+ )
190
+ lines.append(f"Detected {len(shots)} shot{'s' if len(shots) != 1 else ''}.")
191
+ lines.append("")
192
+
193
+ lines.append("Splits:")
194
+ if not shots:
195
+ lines.append(" (none)")
196
+ else:
197
+ for s in shots:
198
+ lines.append(_render_shot_line(s, thresholds))
199
+ lines.append("")
200
+
201
+ lines.append("Anomalies:")
202
+ if analysis.anomalies:
203
+ for a in analysis.anomalies:
204
+ lines.append(f" - {a}")
205
+ else:
206
+ lines.append(" None.")
207
+ lines.append("")
208
+
209
+ if files.video or files.csv or files.fcpxml:
210
+ lines.append("Files:")
211
+ if files.video:
212
+ lines.append(f" Video: {files.video}")
213
+ if files.csv:
214
+ lines.append(f" CSV: {files.csv}")
215
+ if files.fcpxml:
216
+ lines.append(f" FCPXML: {files.fcpxml}")
217
+
218
+ return "\n".join(lines).rstrip() + "\n"
219
+
220
+
221
+ def _render_shot_line(s: Shot, thresholds: SplitColorThresholds) -> str:
222
+ label_parts: list[str] = []
223
+ if s.shot_number == 1:
224
+ label_parts.append("draw")
225
+ elif s.split > thresholds.transition_min:
226
+ label_parts.append("transition")
227
+ label = f" ({', '.join(label_parts)})" if label_parts else ""
228
+ flag = _shot_flag(s, thresholds)
229
+ return f" Shot {s.shot_number:>2}{label:<14}: {s.split:.3f}s {flag}".rstrip()
230
+
231
+
232
+ def _shot_flag(s: Shot, thresholds: SplitColorThresholds) -> str:
233
+ if s.shot_number == 1:
234
+ return "[!] slow draw" if s.split > _SLOW_DRAW_S else "[OK]"
235
+ if s.split < _DOUBLE_DETECTION_MAX_S:
236
+ return "[!] possible double"
237
+ if s.split > _LONG_PAUSE_MAX_S:
238
+ return "[!] long pause"
239
+ if s.split > thresholds.transition_min:
240
+ return "" # transitions speak for themselves; no good/bad call
241
+ if s.split <= thresholds.green_max:
242
+ return "[OK]"
243
+ if s.split <= thresholds.yellow_max:
244
+ return "[~] yellow"
245
+ return "[!] red"
246
+
247
+
248
+ def write_report(
249
+ analysis: StageAnalysis,
250
+ files: ReportFiles | None,
251
+ output_path: Path,
252
+ *,
253
+ color_thresholds: SplitColorThresholds | None = None,
254
+ ) -> None:
255
+ """Write the rendered report to ``output_path``."""
256
+ output_path.write_text(
257
+ render_report(analysis, files, color_thresholds=color_thresholds), encoding="utf-8"
258
+ )