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
splitsmith/cli.py ADDED
@@ -0,0 +1,1281 @@
1
+ """Typer CLI for splitsmith.
2
+
3
+ Subcommands (per SPEC.md):
4
+ - ``single``: run the full pipeline against one video with an explicit stage time.
5
+ - ``detect``: same as ``single`` but only prints results, writes nothing.
6
+ - ``process``: batch over a stage JSON, matching videos by mtime.
7
+ - ``fcpxml``: regenerate a timeline from a (possibly hand-edited) splits CSV.
8
+
9
+ This module orchestrates -- all detection / IO logic lives in dedicated modules.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import shutil
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from . import (
24
+ audit,
25
+ beep_detect,
26
+ csv_gen,
27
+ fcpxml_gen,
28
+ overlay_render,
29
+ overlay_theme,
30
+ report,
31
+ shot_detect,
32
+ shot_refine,
33
+ trim,
34
+ video_match,
35
+ )
36
+ from . import (
37
+ automation as automation_settings,
38
+ )
39
+ from . import (
40
+ cleanup as cleanup_mod,
41
+ )
42
+ from .config import (
43
+ CompetitorStages,
44
+ Config,
45
+ ReportFiles,
46
+ Shot,
47
+ StageAnalysis,
48
+ StageData,
49
+ )
50
+ from .runtime import runtime
51
+ from .ui.project import MatchProject
52
+
53
+ app = typer.Typer(
54
+ name="splitsmith",
55
+ help="Extract IPSC shot splits from head-mounted camera footage.",
56
+ no_args_is_help=True,
57
+ add_completion=False,
58
+ )
59
+ console = Console()
60
+
61
+ from .compare.cli import compare_app # noqa: E402
62
+ from .lab_cli import app as _lab_app # noqa: E402
63
+ from .match_cli import match_app # noqa: E402
64
+ from .model_cli import fetch_models as _fetch_models # noqa: E402
65
+
66
+ app.add_typer(_lab_app, name="lab")
67
+ app.add_typer(compare_app, name="compare")
68
+ app.add_typer(match_app, name="match")
69
+ app.command("fetch-models")(_fetch_models)
70
+
71
+
72
+ project_app = typer.Typer(
73
+ name="project",
74
+ help="Match-project housekeeping: export/import for backup or transfer.",
75
+ no_args_is_help=True,
76
+ add_completion=False,
77
+ )
78
+ app.add_typer(project_app, name="project")
79
+
80
+
81
+ @project_app.command("export")
82
+ def project_export(
83
+ project_dir: Path = typer.Argument(
84
+ ...,
85
+ exists=True,
86
+ file_okay=False,
87
+ dir_okay=True,
88
+ readable=True,
89
+ help="Path to the MatchProject directory (the one containing project.json).",
90
+ ),
91
+ output: Path = typer.Option(
92
+ Path(),
93
+ "--output",
94
+ "-o",
95
+ help=(
96
+ "Destination. If a directory, the archive filename is "
97
+ "<project-slug>-backup-YYYYMMDD.tar.gz. If a file, used as-is."
98
+ ),
99
+ ),
100
+ include_trimmed: bool = typer.Option(
101
+ False,
102
+ "--with-trimmed",
103
+ help="Include trimmed/ (per-stage MP4s, regeneratable).",
104
+ ),
105
+ include_exports: bool = typer.Option(
106
+ False,
107
+ "--with-exports",
108
+ help="Include exports/ (FCPXML, CSV, lossless trims).",
109
+ ),
110
+ include_raw: bool = typer.Option(
111
+ False,
112
+ "--with-raw",
113
+ help="Include the raw/ subdirectory (source video).",
114
+ ),
115
+ include_audio: bool = typer.Option(
116
+ False,
117
+ "--with-audio",
118
+ help="Include the audio/ subdirectory (extracted wav).",
119
+ ),
120
+ ) -> None:
121
+ """Tar the non-regeneratable parts of a project for backup or transfer.
122
+
123
+ Default archive contains ``project.json`` plus ``audit/`` and
124
+ ``scoreboard/`` -- the only artefacts that cannot be regenerated from
125
+ the source footage. Use the ``--with-*`` flags to opt regeneratable
126
+ directories into the archive.
127
+ """
128
+ from .backup import export_project
129
+
130
+ result = export_project(
131
+ project_dir,
132
+ output,
133
+ include_trimmed=include_trimmed,
134
+ include_exports=include_exports,
135
+ include_raw=include_raw,
136
+ include_audio=include_audio,
137
+ )
138
+ size_mb = result.bytes_written / (1024 * 1024)
139
+ console.print(f"[green]Wrote[/] {result.archive_path} ({size_mb:.1f} MB)")
140
+ console.print(f" included: {', '.join(result.included)}")
141
+ if result.skipped:
142
+ for s in result.skipped:
143
+ console.print(
144
+ f" [yellow]skipped[/] {s.name} ({s.reason})"
145
+ + (f": {s.resolved_path}" if s.resolved_path else "")
146
+ )
147
+
148
+
149
+ @project_app.command("import")
150
+ def project_import(
151
+ archive: Path = typer.Argument(
152
+ ...,
153
+ exists=True,
154
+ file_okay=True,
155
+ dir_okay=False,
156
+ readable=True,
157
+ help="Path to a .tar.gz produced by `splitsmith project export`.",
158
+ ),
159
+ dest: Path = typer.Option(
160
+ ...,
161
+ "--dest",
162
+ "-d",
163
+ help="Destination directory. The archive's top-level folder is restored under it.",
164
+ ),
165
+ overwrite: bool = typer.Option(
166
+ False,
167
+ "--overwrite",
168
+ help="If the target directory already exists, replace it.",
169
+ ),
170
+ ) -> None:
171
+ """Restore a project archive produced by ``splitsmith project export``."""
172
+ from .backup import import_project
173
+
174
+ result = import_project(archive, dest, overwrite=overwrite)
175
+ console.print(f"[green]Restored[/] {result.project_name} -> {result.project_root}")
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Subcommands
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ @app.command()
184
+ def single(
185
+ video: Path = typer.Option(..., "--video", help="Path to the video file."),
186
+ time: float = typer.Option(..., "--time", help="Official stage time (seconds)."),
187
+ output: Path = typer.Option(..., "--output", help="Output directory for analysis files."),
188
+ stage_name: str = typer.Option("stage", "--stage-name", help="Slug used in output filenames."),
189
+ stage_number: int = typer.Option(1, "--stage-number", help="Stage number for the report."),
190
+ config_path: Path | None = typer.Option(None, "--config", help="Optional YAML config."),
191
+ write_trim: bool = typer.Option(True, "--trim/--no-trim"),
192
+ write_csv: bool = typer.Option(True, "--csv/--no-csv"),
193
+ write_fcpxml: bool = typer.Option(True, "--fcpxml/--no-fcpxml"),
194
+ auto_detect: bool | None = typer.Option(
195
+ None,
196
+ "--auto-detect/--no-auto-detect",
197
+ help=(
198
+ "Whether to run shot detection after the beep. Defaults to the "
199
+ "global automation setting (True). Use --no-auto-detect for a "
200
+ "trim-only export (issue #215)."
201
+ ),
202
+ ),
203
+ trim_mode: str | None = typer.Option(
204
+ None,
205
+ "--trim-mode",
206
+ help=(
207
+ "Override trim mode: 'lossless' (-c copy, instant, archival) or "
208
+ "'audit' (re-encode with short GOP for scrub-friendly playback). "
209
+ "Default: from config (lossless)."
210
+ ),
211
+ ),
212
+ ) -> None:
213
+ """Process a single video with an explicit stage time."""
214
+ config = Config.load(config_path)
215
+ if trim_mode is not None:
216
+ config = config.model_copy(update={"output": _override_trim_mode(config.output, trim_mode)})
217
+ output.mkdir(parents=True, exist_ok=True)
218
+
219
+ stage = StageData(
220
+ stage_number=stage_number,
221
+ stage_name=stage_name,
222
+ time_seconds=time,
223
+ scorecard_updated_at=_dummy_scorecard_time(),
224
+ )
225
+ files = _process_one(
226
+ stage=stage,
227
+ video=video,
228
+ output_dir=output,
229
+ config=config,
230
+ write_trim=write_trim,
231
+ write_csv=write_csv,
232
+ write_fcpxml=write_fcpxml,
233
+ auto_detect_shots=_resolve_cli_auto_detect(auto_detect),
234
+ )
235
+ _print_files_summary(files)
236
+
237
+
238
+ @app.command()
239
+ def detect(
240
+ video: Path = typer.Option(..., "--video", help="Path to the video file."),
241
+ time: float = typer.Option(..., "--time", help="Official stage time (seconds)."),
242
+ config_path: Path | None = typer.Option(None, "--config", help="Optional YAML config."),
243
+ ) -> None:
244
+ """Detect beep + shots and print results without writing any files."""
245
+ config = Config.load(config_path)
246
+ audio_path = _video_to_audio_path(video)
247
+ audio, sr = _extract_or_load_audio(video, audio_path)
248
+
249
+ console.print(f"[bold]{video.name}[/]: {len(audio) / sr:.2f}s @ {sr} Hz")
250
+ beep = beep_detect.detect_beep(audio, sr, config.beep_detect)
251
+ console.print(
252
+ f" beep: t=[cyan]{beep.time:.4f}s[/] "
253
+ f"peak={beep.peak_amplitude:.3f} duration={beep.duration_ms:.0f}ms"
254
+ )
255
+
256
+ shots = shot_detect.detect_shots(audio, sr, beep.time, time, config.shot_detect)
257
+ shots, refine_diffs = _refine_shot_times(audio, sr, shots, beep.time, config)
258
+ if refine_diffs:
259
+ console.print(
260
+ f" [cyan]refined {len(refine_diffs)} shot time(s)[/]: "
261
+ + ", ".join(f"#{d['shot_number']} {d['drift_ms']:+.1f}ms" for d in refine_diffs)
262
+ )
263
+ _print_shots_table(shots)
264
+ _print_anomalies(report.detect_anomalies(shots, beep.time, time))
265
+
266
+
267
+ @app.command()
268
+ def process(
269
+ videos: Path = typer.Option(..., "--videos", help="Directory of video files."),
270
+ stages: Path = typer.Option(..., "--stages", help="Stage JSON (SSI Scoreboard format)."),
271
+ output: Path = typer.Option(..., "--output", help="Output directory for analysis files."),
272
+ config_path: Path | None = typer.Option(None, "--config", help="Optional YAML config."),
273
+ write_trim: bool = typer.Option(True, "--trim/--no-trim"),
274
+ write_csv: bool = typer.Option(True, "--csv/--no-csv"),
275
+ write_fcpxml: bool = typer.Option(True, "--fcpxml/--no-fcpxml"),
276
+ auto_detect: bool | None = typer.Option(
277
+ None,
278
+ "--auto-detect/--no-auto-detect",
279
+ help=(
280
+ "Whether to run shot detection after the beep. Defaults to the "
281
+ "global automation setting (True). Use --no-auto-detect to skip "
282
+ "shot detection across the batch (issue #215)."
283
+ ),
284
+ ),
285
+ trim_mode: str | None = typer.Option(
286
+ None,
287
+ "--trim-mode",
288
+ help="Override trim mode: 'lossless' or 'audit'. Default: from config (lossless).",
289
+ ),
290
+ ) -> None:
291
+ """Batch-process every stage in a stage JSON, matching videos by file timestamp."""
292
+ config = Config.load(config_path)
293
+ if trim_mode is not None:
294
+ config = config.model_copy(update={"output": _override_trim_mode(config.output, trim_mode)})
295
+ output.mkdir(parents=True, exist_ok=True)
296
+
297
+ competitor_stages = _load_stage_json(stages)
298
+ video_paths = sorted(_iter_video_files(videos))
299
+ match = video_match.match_videos_to_stages(video_paths, competitor_stages.stages, config.video_match)
300
+
301
+ if match.unmatched_stages or match.ambiguous_stages or match.orphan_videos:
302
+ _print_match_diagnostics(match)
303
+ if not match.matches:
304
+ raise typer.Exit(code=1)
305
+
306
+ auto_detect_shots = _resolve_cli_auto_detect(auto_detect)
307
+ for m in match.matches:
308
+ stage = next(s for s in competitor_stages.stages if s.stage_number == m.stage_number)
309
+ console.rule(f"[bold]Stage {stage.stage_number}: {stage.stage_name}[/]")
310
+ try:
311
+ _process_one(
312
+ stage=stage,
313
+ video=m.video_path,
314
+ output_dir=output,
315
+ config=config,
316
+ write_trim=write_trim,
317
+ write_csv=write_csv,
318
+ write_fcpxml=write_fcpxml,
319
+ auto_detect_shots=auto_detect_shots,
320
+ )
321
+ except Exception as exc: # noqa: BLE001 -- soft-fail per SPEC error-handling rules
322
+ console.print(f"[red]Stage {stage.stage_number} failed: {exc}[/]")
323
+
324
+
325
+ @app.command()
326
+ def review(
327
+ fixture: Path = typer.Option(..., "--fixture", help="Fixture JSON to audit."),
328
+ video: Path | None = typer.Option(
329
+ None, "--video", help="Optional source video to align alongside the waveform."
330
+ ),
331
+ port: int = typer.Option(5174, "--port"),
332
+ host: str = typer.Option("127.0.0.1", "--host"),
333
+ no_browser: bool = typer.Option(False, "--no-browser", help="Skip auto-opening browser."),
334
+ ) -> None:
335
+ """Open the production UI's standalone fixture-review page.
336
+
337
+ Boots the same server ``splitsmith ui`` uses, with a throwaway project
338
+ root, then opens ``/review?fixture=...&video=...``. The route reads
339
+ the fixture JSON via the API, edits markers in-memory, saves back to
340
+ the same path with a ``.bak`` for the previous version. The
341
+ standalone splitsmith.review_server has been retired (#19).
342
+ """
343
+ import tempfile
344
+ from urllib.parse import urlencode
345
+
346
+ from .ui.server import serve
347
+
348
+ if not fixture.exists():
349
+ raise typer.BadParameter(f"fixture not found: {fixture}")
350
+ audio_path = fixture.with_suffix(".wav")
351
+ if not audio_path.exists():
352
+ raise typer.BadParameter(f"expected audio sibling not found: {audio_path}")
353
+ if video is not None and not video.exists():
354
+ raise typer.BadParameter(f"video not found: {video}")
355
+
356
+ fixture_resolved = fixture.resolve()
357
+ video_resolved = video.resolve() if video is not None else None
358
+
359
+ # The production UI server requires a project root, but the fixture
360
+ # endpoints don't touch project state. Use a throwaway tmpdir so the
361
+ # server boots cleanly and nothing from this run pollutes a real
362
+ # match folder. Cleanup is left to the OS.
363
+ tmp_root = Path(tempfile.mkdtemp(prefix="splitsmith-review-"))
364
+
365
+ qs = {"fixture": str(fixture_resolved)}
366
+ if video_resolved is not None:
367
+ qs["video"] = str(video_resolved)
368
+ url = f"http://{host}:{port}/review?{urlencode(qs)}"
369
+
370
+ console.print(f"[green]splitsmith review[/]: [bold]{url}[/] (Ctrl+C to stop)")
371
+ console.print(f" fixture: {fixture_resolved}")
372
+ console.print(f" audio: {audio_path.resolve()}")
373
+ if video_resolved is not None:
374
+ console.print(f" video: {video_resolved}")
375
+
376
+ if not no_browser:
377
+ import webbrowser
378
+
379
+ webbrowser.open(url)
380
+
381
+ try:
382
+ serve(project_root=tmp_root, project_name="review", host=host, port=port)
383
+ except KeyboardInterrupt:
384
+ console.print("\n[yellow]Stopped.[/]")
385
+
386
+
387
+ @app.command()
388
+ def ui(
389
+ project: Path | None = typer.Option(
390
+ None,
391
+ "--project",
392
+ help=(
393
+ "Match-project root directory. Created (with subdirs) if missing. "
394
+ "Omit to boot the picker; pass --last to reopen the most recent."
395
+ ),
396
+ ),
397
+ project_name: str | None = typer.Option(
398
+ None,
399
+ "--name",
400
+ help=(
401
+ "Display name for the match. Defaults to the project directory's "
402
+ "basename. Ignored if the project already has a name on disk."
403
+ ),
404
+ ),
405
+ last: bool = typer.Option(
406
+ False,
407
+ "--last",
408
+ help="Open the most-recently-opened project. Errors if the recent list is empty.",
409
+ ),
410
+ host: str = typer.Option("127.0.0.1", "--host"),
411
+ port: int = typer.Option(5174, "--port"),
412
+ no_browser: bool = typer.Option(False, "--no-browser", help="Skip auto-opening browser."),
413
+ lab: bool = typer.Option(
414
+ False,
415
+ "--lab",
416
+ help=(
417
+ "Expose the Algorithm Lab page (fixture eval + labeling). "
418
+ "Hidden by default since it's a developer tool that loads "
419
+ "heavy CLAP/PANN models on first use."
420
+ ),
421
+ ),
422
+ skip_system_check: bool = typer.Option(
423
+ False,
424
+ "--skip-system-check",
425
+ help=(
426
+ "Bypass the first-launch ffmpeg / ffprobe presence check. "
427
+ "Use only for debugging install issues -- detection will "
428
+ "fail with a cryptic error if either binary is missing."
429
+ ),
430
+ ),
431
+ ) -> None:
432
+ """Start the production UI server (issue #11/#12).
433
+
434
+ The UI is a localhost SPA driven by a FastAPI backend that orchestrates
435
+ the existing engine modules unchanged. State persists to disk under
436
+ ``--project`` so closing the browser and re-running resumes where you
437
+ left off. With no ``--project`` (and no ``--last``), the server boots
438
+ "unbound" -- the SPA renders a picker drawn from
439
+ ``~/.splitsmith/projects.json`` and binds in-memory once the user picks.
440
+ """
441
+ from . import user_config
442
+ from .ui.server import serve
443
+
444
+ if last:
445
+ if project is not None:
446
+ raise typer.BadParameter("--last and --project are mutually exclusive")
447
+ recents = user_config.get_recent_projects()
448
+ if not recents:
449
+ raise typer.BadParameter("no recent projects to reopen; run with --project")
450
+ project = Path(recents[0].path)
451
+
452
+ resolved: Path | None = None
453
+ name: str | None = None
454
+ if project is not None:
455
+ resolved = project.expanduser().resolve()
456
+ name = project_name or resolved.name or "match"
457
+
458
+ url = f"http://{host}:{port}/"
459
+ console.print(f"[green]splitsmith UI[/]: [bold]{url}[/] (Ctrl+C to stop)")
460
+ if resolved is not None:
461
+ console.print(f" project: {resolved}")
462
+ else:
463
+ console.print(" project: [dim]none -- showing picker[/]")
464
+ if lab:
465
+ console.print(" [cyan]Algorithm Lab[/] enabled")
466
+
467
+ if not no_browser:
468
+ import webbrowser
469
+
470
+ webbrowser.open(url)
471
+
472
+ try:
473
+ serve(
474
+ project_root=resolved,
475
+ project_name=name,
476
+ host=host,
477
+ port=port,
478
+ lab_enabled=lab,
479
+ skip_system_check=skip_system_check,
480
+ )
481
+ except KeyboardInterrupt:
482
+ console.print("\n[yellow]Stopped.[/]")
483
+
484
+
485
+ @app.command("audit-prep")
486
+ def audit_prep(
487
+ video: Path = typer.Option(..., "--video", help="Source video file (mp4/mov)."),
488
+ time: float = typer.Option(..., "--time", help="Official stage time in seconds."),
489
+ stage_number: int = typer.Option(..., "--stage-number"),
490
+ stage_name: str = typer.Option(..., "--stage-name"),
491
+ output_dir: Path = typer.Option(
492
+ Path("tests/fixtures"), "--output-dir", help="Where to write the fixture files."
493
+ ),
494
+ stem: str = typer.Option(..., "--stem", help="File stem (e.g. stage-shots-tallmilan-stage5)."),
495
+ fixture_pre_pad_s: float = typer.Option(0.5, "--pre-pad-seconds"),
496
+ fixture_post_pad_s: float = typer.Option(1.5, "--post-pad-seconds"),
497
+ config_path: Path | None = typer.Option(None, "--config"),
498
+ beep_time_override: float | None = typer.Option(
499
+ None,
500
+ "--beep-time",
501
+ help=(
502
+ "Source-time of the beep in seconds. Skips automatic beep detection "
503
+ "(use when wind / steel rings / other 3 kHz transients fool detect_beep)."
504
+ ),
505
+ ),
506
+ paper: int = typer.Option(
507
+ 0,
508
+ "--paper",
509
+ min=0,
510
+ help="Paper-target count for the stage (each scored x2 in IPSC by default).",
511
+ ),
512
+ poppers: int = typer.Option(0, "--poppers", min=0, help="Popper count."),
513
+ plates: int = typer.Option(0, "--plates", min=0, help="Plate count."),
514
+ shots_per_paper: int = typer.Option(
515
+ 2,
516
+ "--shots-per-paper",
517
+ min=1,
518
+ max=2,
519
+ help="Shots per paper target. 2 for normal stages, 1 for strong/weak-hand-only.",
520
+ ),
521
+ ) -> None:
522
+ """Build a review-ready fixture (wav + JSON + audit CSV) from a source video.
523
+
524
+ Steps:
525
+ 1. Extract mono 48 kHz audio from the source video via ffmpeg.
526
+ 2. Detect the beep, then run shot_detect over the stage window.
527
+ 3. Slice ``[beep - pre_pad, beep + stage_time + post_pad]`` of the audio
528
+ and save as ``<stem>.wav`` in ``output_dir``.
529
+ 4. Save ``<stem>.json`` (metadata + empty shots[] + candidate dump) and
530
+ ``<stem>-candidates.csv`` (with audit_keep column).
531
+
532
+ Existing files at the same paths are overwritten.
533
+ """
534
+ import csv as _csv
535
+ import json as _json
536
+ import subprocess as _subprocess
537
+
538
+ import soundfile as sf
539
+
540
+ config = Config.load(config_path)
541
+ if not video.exists():
542
+ raise typer.BadParameter(f"video not found: {video}")
543
+ ffmpeg_bin = runtime().ffmpeg_binary
544
+ if not shutil.which(ffmpeg_bin):
545
+ raise typer.BadParameter(f"ffmpeg binary not found: {ffmpeg_bin}")
546
+ output_dir.mkdir(parents=True, exist_ok=True)
547
+
548
+ full_wav = output_dir / f"{stem}-FULL.wav"
549
+ console.print(f" extracting full audio -> {full_wav}")
550
+ _subprocess.run(
551
+ [
552
+ ffmpeg_bin,
553
+ "-hide_banner",
554
+ "-loglevel",
555
+ "error",
556
+ "-y",
557
+ "-i",
558
+ str(video),
559
+ "-ac",
560
+ "1",
561
+ "-ar",
562
+ "48000",
563
+ "-vn",
564
+ str(full_wav),
565
+ ],
566
+ check=True,
567
+ capture_output=True,
568
+ text=True,
569
+ )
570
+
571
+ audio, sr = beep_detect.load_audio(full_wav)
572
+ full_wav.unlink() # we keep only the sliced fixture wav
573
+
574
+ if beep_time_override is not None:
575
+ from .config import BeepDetection
576
+
577
+ beep = BeepDetection(time=beep_time_override, peak_amplitude=0.0, duration_ms=0.0)
578
+ console.print(f" beep override: t=[cyan]{beep.time:.4f}s[/] (detection skipped)")
579
+ else:
580
+ beep = beep_detect.detect_beep(audio, sr, config.beep_detect)
581
+ console.print(
582
+ f" beep at source t=[cyan]{beep.time:.4f}s[/] "
583
+ f"peak={beep.peak_amplitude:.3f} duration={beep.duration_ms:.0f}ms"
584
+ )
585
+
586
+ fix_lo = max(0.0, beep.time - fixture_pre_pad_s)
587
+ fix_hi = min(len(audio) / sr, beep.time + time + fixture_post_pad_s)
588
+ fixture_audio = audio[int(fix_lo * sr) : int(fix_hi * sr)]
589
+ fixture_wav = output_dir / f"{stem}.wav"
590
+ sf.write(fixture_wav, fixture_audio, sr, subtype="PCM_16")
591
+ console.print(
592
+ f" fixture wav: {fixture_wav} "
593
+ f"({len(fixture_audio) / sr:.2f}s, {fixture_wav.stat().st_size / 1e6:.2f} MB)"
594
+ )
595
+
596
+ detected = shot_detect.detect_shots(audio, sr, beep.time, time, config.shot_detect)
597
+ beep_in_fixture = beep.time - fix_lo
598
+ candidates = []
599
+ for i, s in enumerate(detected, start=1):
600
+ candidates.append(
601
+ {
602
+ "candidate_number": i,
603
+ "time": round(s.time_absolute - fix_lo, 4),
604
+ "ms_after_beep": round(s.time_from_beep * 1000, 0),
605
+ "peak_amplitude": round(s.peak_amplitude, 4),
606
+ "confidence": round(s.confidence, 3),
607
+ }
608
+ )
609
+
610
+ fixture_json_data = {
611
+ "source": (f"{video.name} stage {stage_number} '{stage_name}' " "(audio extracted at 48 kHz mono)"),
612
+ # Structured absolute path to the source video. Lets the
613
+ # Lab UI's Re-label button hop straight to /review with the
614
+ # video bound, instead of needing the CLI ``--video`` flag.
615
+ "source_video": str(Path(video).resolve()),
616
+ "stage_number": stage_number,
617
+ "stage_name": stage_name,
618
+ "fixture_window_in_source": [round(fix_lo, 4), round(fix_hi, 4)],
619
+ "beep_time": round(beep_in_fixture, 4),
620
+ "tolerance_ms": 15,
621
+ "stage_time_seconds": time,
622
+ "stage_window_end_in_fixture": round(beep_in_fixture + time, 4),
623
+ "shots": [],
624
+ }
625
+ if paper or poppers or plates:
626
+ fixture_json_data["stage_rounds"] = {
627
+ "paper": paper,
628
+ "poppers": poppers,
629
+ "plates": plates,
630
+ "shots_per_paper": shots_per_paper,
631
+ "expected": paper * shots_per_paper + poppers + plates,
632
+ }
633
+ fixture_json = output_dir / f"{stem}.json"
634
+ fixture_json.write_text(
635
+ _json.dumps(
636
+ {
637
+ **fixture_json_data,
638
+ "_candidates_pending_audit": {
639
+ "_note": (
640
+ "Auto-detected by current shot_detect (half-rise leading edge). "
641
+ "NOT ground truth. Open in `splitsmith review` to audit, "
642
+ "or mark audit_keep in the companion -candidates.csv and run "
643
+ "`splitsmith audit-apply`."
644
+ ),
645
+ "candidates": candidates,
646
+ },
647
+ },
648
+ indent=2,
649
+ )
650
+ + "\n"
651
+ )
652
+ console.print(f" fixture json: {fixture_json} ({len(candidates)} candidates)")
653
+
654
+ csv_path = output_dir / f"{stem}-candidates.csv"
655
+ stage_end_ms = time * 1000
656
+ # encoding pinned: Linux with LANG=C picks ascii from the locale and crashes on non-ASCII names.
657
+ with csv_path.open("w", newline="", encoding="utf-8") as f:
658
+ w = _csv.writer(f)
659
+ w.writerow(
660
+ [
661
+ "audit_keep",
662
+ "candidate_number",
663
+ "time_fixture_s",
664
+ "time_source_s",
665
+ "ms_after_beep",
666
+ "split_from_prev_ms",
667
+ "peak_amplitude",
668
+ "confidence",
669
+ "in_stage_window",
670
+ "suspect_echo_lt_150ms",
671
+ ]
672
+ )
673
+ prev_t = beep_in_fixture
674
+ for c in candidates:
675
+ t_fix = c["time"]
676
+ t_src = round(t_fix + fix_lo, 4)
677
+ split_ms = round((t_fix - prev_t) * 1000, 1)
678
+ in_win = "Y" if c["ms_after_beep"] <= stage_end_ms + 1000 else "N"
679
+ echo = "Y" if split_ms < 150 else ""
680
+ w.writerow(
681
+ [
682
+ "",
683
+ c["candidate_number"],
684
+ t_fix,
685
+ t_src,
686
+ c["ms_after_beep"],
687
+ split_ms,
688
+ c["peak_amplitude"],
689
+ c["confidence"],
690
+ in_win,
691
+ echo,
692
+ ]
693
+ )
694
+ prev_t = t_fix
695
+ console.print(f" candidates csv: {csv_path}")
696
+ console.print(
697
+ f"\n[green]Ready.[/] Open in the UI:\n"
698
+ f" uv run splitsmith review --fixture {fixture_json} --video {video}"
699
+ )
700
+
701
+
702
+ @app.command("audit-apply")
703
+ def audit_apply(
704
+ candidates: Path = typer.Option(
705
+ ..., "--candidates", help="The audited candidates CSV (must contain audit_keep column)."
706
+ ),
707
+ fixture: Path = typer.Option(..., "--fixture", help="The corresponding fixture JSON to update in place."),
708
+ ) -> None:
709
+ """Merge audit_keep-marked rows from a candidates CSV into a fixture JSON's shots[]."""
710
+ n = audit.apply_audit_to_fixture(candidates, fixture)
711
+ console.print(
712
+ f"[green]Wrote {n} audited shots[/] to {fixture} " f"(from {candidates.name}, audit_keep column)."
713
+ )
714
+
715
+
716
+ @app.command()
717
+ def clean(
718
+ project: Path = typer.Argument(..., help="Match-project root directory."),
719
+ caches: bool = typer.Option(
720
+ False, "--caches", help="Thumbnails, ffprobes, scoreboard cache, waveform peaks."
721
+ ),
722
+ exports_light: bool = typer.Option(
723
+ False, "--exports-light", help="CSV / FCPXML / report.txt under exports/."
724
+ ),
725
+ exports_overlays: bool = typer.Option(
726
+ False, "--exports-overlays", help="Pre-rendered overlay MOVs under exports/ (large)."
727
+ ),
728
+ exports_trims: bool = typer.Option(
729
+ False, "--exports-trims", help="Lossless trimmed MP4s under exports/ (large)."
730
+ ),
731
+ audit_trims: bool = typer.Option(
732
+ False, "--audit-trims", help="Audit-mode short-GOP trims under trimmed/ (large)."
733
+ ),
734
+ audio: bool = typer.Option(
735
+ False, "--audio", help="Extracted WAVs under audio/ (medium; re-extracted by detection)."
736
+ ),
737
+ include_audit: bool = typer.Option(
738
+ False,
739
+ "--include-audit",
740
+ help=(
741
+ "Also delete per-stage audit JSONs and .bak backups. DESTRUCTIVE: "
742
+ "loses your shot-audit work for the project."
743
+ ),
744
+ ),
745
+ all_: bool = typer.Option(
746
+ False,
747
+ "--all",
748
+ help="Everything except --include-audit. Combine with --include-audit to wipe audit too.",
749
+ ),
750
+ yes: bool = typer.Option(False, "--yes", help="Actually delete. Omit for a dry-run preview."),
751
+ ) -> None:
752
+ """Reclaim disk space from a match project.
753
+
754
+ Default is dry-run: prints the plan and exits without deleting. Pass
755
+ ``--yes`` to apply. The original source video files (and the
756
+ symlinks under ``raw/``) are never touched. ``project.json`` is
757
+ never touched.
758
+ """
759
+ if not project.exists() or not (project / "project.json").exists():
760
+ raise typer.BadParameter(f"not a match project: {project}")
761
+ proj = MatchProject.load(project)
762
+
763
+ selected: set[cleanup_mod.CleanupCategory] = set()
764
+ flag_pairs: list[tuple[bool, cleanup_mod.CleanupCategory]] = [
765
+ (caches, cleanup_mod.CleanupCategory.CACHES),
766
+ (exports_light, cleanup_mod.CleanupCategory.EXPORTS_LIGHT),
767
+ (exports_overlays, cleanup_mod.CleanupCategory.EXPORTS_OVERLAYS),
768
+ (exports_trims, cleanup_mod.CleanupCategory.EXPORTS_TRIMS),
769
+ (audit_trims, cleanup_mod.CleanupCategory.AUDIT_TRIMS),
770
+ (audio, cleanup_mod.CleanupCategory.AUDIO),
771
+ ]
772
+ for flag, cat in flag_pairs:
773
+ if flag:
774
+ selected.add(cat)
775
+ if all_:
776
+ selected |= cleanup_mod.SAFE_CATEGORIES
777
+ if include_audit:
778
+ selected.add(cleanup_mod.CleanupCategory.AUDIT_DATA)
779
+
780
+ if not selected:
781
+ raise typer.BadParameter(
782
+ "select at least one category (e.g. --caches, --exports-overlays, --all). "
783
+ "Run with --help for the full list."
784
+ )
785
+
786
+ plan = cleanup_mod.plan_cleanup(proj, project, selected)
787
+ _print_cleanup_plan(plan, selected)
788
+
789
+ if not yes:
790
+ console.print("\n[dim]Dry run.[/] Re-run with [bold]--yes[/] to delete.")
791
+ return
792
+
793
+ result = cleanup_mod.apply_cleanup(plan, root=project)
794
+ freed_mb = result.bytes_freed / (1024 * 1024)
795
+ console.print(f"\n[green]Deleted {len(result.deleted)} file(s)[/], freed [bold]{freed_mb:.1f} MB[/].")
796
+ if result.failed:
797
+ console.print(f"[yellow]{len(result.failed)} failed:[/]")
798
+ for path, err in result.failed[:10]:
799
+ console.print(f" [yellow]- {path}: {err}[/]")
800
+ if len(result.failed) > 10:
801
+ console.print(f" [dim]... and {len(result.failed) - 10} more[/]")
802
+
803
+
804
+ @app.command()
805
+ def fcpxml(
806
+ csv_path: Path = typer.Option(..., "--csv", help="Splits CSV (possibly hand-edited)."),
807
+ video: Path = typer.Option(..., "--video", help="Trimmed video the markers anchor to."),
808
+ output: Path = typer.Option(..., "--output", help="Output FCPXML file."),
809
+ beep_offset: float = typer.Option(
810
+ 5.0, "--beep-offset", help="Seconds from start of trimmed video to the beep."
811
+ ),
812
+ config_path: Path | None = typer.Option(None, "--config", help="Optional YAML config."),
813
+ project_name: str | None = typer.Option(None, "--project-name"),
814
+ ) -> None:
815
+ """Regenerate FCPXML from a (possibly hand-edited) splits CSV."""
816
+ config = Config.load(config_path)
817
+ rows = csv_gen.read_splits_csv(csv_path)
818
+ shots = [
819
+ Shot(
820
+ shot_number=r.shot_number,
821
+ time_absolute=beep_offset + r.time_from_start,
822
+ time_from_beep=r.time_from_start,
823
+ split=r.split,
824
+ peak_amplitude=r.peak_amplitude,
825
+ confidence=r.confidence,
826
+ notes=r.notes,
827
+ )
828
+ for r in rows
829
+ ]
830
+ meta = fcpxml_gen.probe_video(video, ffprobe_binary=runtime().ffprobe_binary)
831
+ fcpxml_gen.generate_fcpxml(
832
+ video_path=video,
833
+ video=meta,
834
+ shots=shots,
835
+ beep_offset_seconds=beep_offset,
836
+ output_path=output,
837
+ project_name=project_name or video.stem,
838
+ config=config.output,
839
+ )
840
+ console.print(f"[green]Wrote[/] {output}")
841
+
842
+
843
+ @app.command()
844
+ def overlay(
845
+ audit_path: Path = typer.Option(
846
+ ..., "--audit", help="Audited stage JSON (e.g. <project>/audit/stage<N>.json)."
847
+ ),
848
+ video: Path = typer.Option(..., "--video", help="Trimmed video the overlay must mirror frame-for-frame."),
849
+ output: Path = typer.Option(..., "--output", help="Output overlay MOV (alpha)."),
850
+ beep_offset: float = typer.Option(
851
+ 5.0, "--beep-offset", help="Seconds from start of trimmed video to the beep."
852
+ ),
853
+ codec: str = typer.Option(
854
+ "auto",
855
+ "--codec",
856
+ help=(
857
+ "Encoder: 'auto' (HEVC w/ alpha on macOS, ProRes 4444 elsewhere), "
858
+ "'hevc-alpha' (smallest, macOS only), or 'prores-4444' (largest, "
859
+ "cross-platform / archival)."
860
+ ),
861
+ ),
862
+ max_height: int | None = typer.Option(
863
+ None,
864
+ "--max-height",
865
+ help=(
866
+ "Cap output height (aspect preserved). FCPXML emits a separate "
867
+ "format so FCP scales it back up."
868
+ ),
869
+ ),
870
+ max_fps: float | None = typer.Option(
871
+ None, "--max-fps", help="Cap output frame rate. Source rate kept when below cap."
872
+ ),
873
+ font_name: str | None = typer.Option(
874
+ None, "--font", help=f"Preset font: {', '.join(overlay_render.available_font_names())}."
875
+ ),
876
+ theme: str = typer.Option(
877
+ "splitsmith",
878
+ "--theme",
879
+ help=(
880
+ f"Color palette preset: {', '.join(overlay_theme.THEME_NAMES)}. "
881
+ f"'splitsmith' uses the same tokens as the web UI; 'clean' "
882
+ f"is the neutral white-on-amber alternative."
883
+ ),
884
+ ),
885
+ ) -> None:
886
+ """Render an alpha overlay MOV for an audited stage.
887
+
888
+ The overlay drops onto V2 in FCP as a connected clip; the renderer
889
+ mirrors the trimmed clip's resolution / fps / duration unless capped.
890
+ """
891
+ if codec not in overlay_render.OVERLAY_CODECS:
892
+ raise typer.BadParameter(f"--codec must be one of {overlay_render.OVERLAY_CODECS}, got {codec!r}")
893
+ if theme not in overlay_theme.THEME_NAMES:
894
+ raise typer.BadParameter(f"--theme must be one of {overlay_theme.THEME_NAMES}, got {theme!r}")
895
+ overlay_render.render_overlay(
896
+ audit_path=audit_path,
897
+ trimmed_video_path=video,
898
+ output_path=output,
899
+ beep_offset_seconds=beep_offset,
900
+ codec=codec, # type: ignore[arg-type]
901
+ max_height=max_height,
902
+ max_fps=max_fps,
903
+ font_name=font_name,
904
+ theme=theme, # type: ignore[arg-type]
905
+ ffmpeg_binary=runtime().ffmpeg_binary,
906
+ )
907
+ console.print(f"[green]Wrote[/] {output}")
908
+
909
+
910
+ @app.command()
911
+ def mcp(
912
+ allowed_root: Path | None = typer.Option(
913
+ None,
914
+ "--allowed-root",
915
+ help=(
916
+ "Optional sandbox root. Every path argument the agent passes "
917
+ "(project_root, video files, directories) must resolve under "
918
+ "this directory; otherwise the tool errors with SandboxError. "
919
+ "When omitted the server runs without a sandbox and the agent "
920
+ "has the same filesystem access as the launching user."
921
+ ),
922
+ ),
923
+ ) -> None:
924
+ """Run the splitsmith Model Context Protocol server over stdio (issue #211).
925
+
926
+ Exposes splitsmith's pipeline as agent-callable tools. This layer
927
+ (#211 layer 1) ships the read-only surface (probe video, discover
928
+ videos, get project, list stages, get HITL queue); subsequent
929
+ layers add write tools, detection orchestration, and the export
930
+ pipeline.
931
+
932
+ Configure your MCP-aware client (Claude Desktop, Claude Code, etc.)
933
+ to launch this command and pipe stdio. ``--allowed-root`` is the
934
+ single knob to constrain filesystem access.
935
+ """
936
+ import os
937
+
938
+ from .mcp import create_server
939
+ from .mcp.sandbox import ALLOWED_ROOT_ENV
940
+
941
+ if allowed_root is not None:
942
+ resolved = allowed_root.expanduser().resolve()
943
+ if not resolved.is_dir():
944
+ raise typer.BadParameter(f"--allowed-root {resolved} is not a directory")
945
+ os.environ[ALLOWED_ROOT_ENV] = str(resolved)
946
+ server = create_server()
947
+ server.run()
948
+
949
+
950
+ # ---------------------------------------------------------------------------
951
+ # Pipeline helpers
952
+ # ---------------------------------------------------------------------------
953
+
954
+
955
+ def _resolve_cli_auto_detect(flag: bool | None) -> bool:
956
+ """Resolve the ``--auto-detect/--no-auto-detect`` flag against the
957
+ layered automation settings (#215).
958
+
959
+ ``flag is None`` means the user didn't pass either form; fall
960
+ through to the project (CLI doesn't know which project, so just
961
+ the global settings) + global default. ``True`` / ``False``
962
+ overrides everything else.
963
+ """
964
+ cli_override = (
965
+ automation_settings.AutomationOverride(shot_detect_on_beep_verified=flag)
966
+ if flag is not None
967
+ else None
968
+ )
969
+ resolved = automation_settings.resolve_automation(cli_override=cli_override)
970
+ return resolved.settings.shot_detect_on_beep_verified
971
+
972
+
973
+ def _process_one(
974
+ *,
975
+ stage: StageData,
976
+ video: Path,
977
+ output_dir: Path,
978
+ config: Config,
979
+ write_trim: bool,
980
+ write_csv: bool,
981
+ write_fcpxml: bool,
982
+ auto_detect_shots: bool = True,
983
+ ) -> ReportFiles:
984
+ """End-to-end pipeline for a single (stage, video) pair. Returns the file footer.
985
+
986
+ ``auto_detect_shots=False`` (the CLI's ``--no-auto-detect``) skips
987
+ the shot-detection step. Trim, CSV (empty), and FCPXML (no
988
+ markers) still produce -- mirrors the permissive export gate
989
+ introduced in #214.
990
+ """
991
+ base = f"stage{stage.stage_number}_{_slugify(stage.stage_name)}"
992
+ audio_path = _video_to_audio_path(video)
993
+ audio, sr = _extract_or_load_audio(video, audio_path)
994
+
995
+ beep = beep_detect.detect_beep(audio, sr, config.beep_detect)
996
+ console.print(
997
+ f" beep: t=[cyan]{beep.time:.4f}s[/] "
998
+ f"peak={beep.peak_amplitude:.3f} duration={beep.duration_ms:.0f}ms"
999
+ )
1000
+
1001
+ if auto_detect_shots:
1002
+ shots = shot_detect.detect_shots(audio, sr, beep.time, stage.time_seconds, config.shot_detect)
1003
+ shots, refine_diffs = _refine_shot_times(audio, sr, shots, beep.time, config)
1004
+ if refine_diffs:
1005
+ console.print(
1006
+ f" [cyan]refined {len(refine_diffs)} shot time(s)[/]: "
1007
+ + ", ".join(f"#{i + 1} {d['drift_ms']:+.1f}ms" for i, d in enumerate(refine_diffs))
1008
+ )
1009
+ _print_shots_table(shots)
1010
+ else:
1011
+ shots = []
1012
+ console.print(" [yellow]shot detection skipped[/] (--no-auto-detect)")
1013
+
1014
+ files = ReportFiles()
1015
+ if write_trim:
1016
+ files.video = output_dir / f"{base}_trimmed.mp4"
1017
+ trim.trim_video(
1018
+ video,
1019
+ files.video,
1020
+ beep_time=beep.time,
1021
+ stage_time=stage.time_seconds,
1022
+ buffer_seconds=config.output.trim_buffer_seconds,
1023
+ mode=config.output.trim_mode,
1024
+ gop_frames=config.output.trim_gop_frames,
1025
+ crf=config.output.trim_audit_crf,
1026
+ preset=config.output.trim_audit_preset,
1027
+ overwrite=True,
1028
+ ffmpeg_binary=runtime().ffmpeg_binary,
1029
+ )
1030
+ console.print(f" [green]trimmed video[/]: {files.video}")
1031
+
1032
+ if write_csv and shots:
1033
+ files.csv = output_dir / f"{base}_splits.csv"
1034
+ csv_gen.write_splits_csv(shots, files.csv)
1035
+ console.print(f" [green]splits CSV[/]: {files.csv}")
1036
+ elif write_csv:
1037
+ # Mirror the permissive export gate (#214): no CSV when no
1038
+ # shots; the trim + FCPXML still ship.
1039
+ console.print(" [yellow]splits CSV[/]: skipped (no shots)")
1040
+
1041
+ if write_fcpxml and files.video and files.video.exists():
1042
+ files.fcpxml = output_dir / f"{base}.fcpxml"
1043
+ meta = fcpxml_gen.probe_video(files.video, ffprobe_binary=runtime().ffprobe_binary)
1044
+ fcpxml_gen.generate_fcpxml(
1045
+ video_path=files.video,
1046
+ video=meta,
1047
+ shots=shots,
1048
+ beep_offset_seconds=config.output.trim_buffer_seconds,
1049
+ output_path=files.fcpxml,
1050
+ project_name=base,
1051
+ config=config.output,
1052
+ )
1053
+ console.print(f" [green]FCPXML[/]: {files.fcpxml}")
1054
+
1055
+ anomalies = report.detect_anomalies(shots, beep.time, stage.time_seconds)
1056
+ analysis = StageAnalysis(
1057
+ stage=stage,
1058
+ video_path=video,
1059
+ beep_time=beep.time,
1060
+ shots=shots,
1061
+ anomalies=anomalies,
1062
+ )
1063
+ report_path = output_dir / f"{base}_report.txt"
1064
+ report.write_report(analysis, files, report_path)
1065
+ console.print(f" [green]report[/]: {report_path}")
1066
+ _print_anomalies(anomalies)
1067
+ return files
1068
+
1069
+
1070
+ def _refine_shot_times(
1071
+ audio,
1072
+ sr: int,
1073
+ shots: list[Shot],
1074
+ beep_time: float,
1075
+ config: Config,
1076
+ ) -> tuple[list[Shot], list[dict]]:
1077
+ """Run second-pass timing refinement on each shot.
1078
+
1079
+ Returns (refined_shots, diffs) where ``diffs`` lists only the shots whose
1080
+ timestamp was actually moved (accepted by ``shot_refine`` and non-zero
1081
+ drift). Splits and time_from_beep are recomputed from the refined times.
1082
+ """
1083
+ refined: list[Shot] = []
1084
+ diffs: list[dict] = []
1085
+ prev_t = beep_time
1086
+ for s in shots:
1087
+ r = shot_refine.refine_shot_time(audio, sr, s.time_absolute, config.shot_refine)
1088
+ new_t = r.time if r.accepted else s.time_absolute
1089
+ if r.accepted and abs(r.drift_ms) > 0.01:
1090
+ diffs.append({"shot_number": s.shot_number, "drift_ms": r.drift_ms})
1091
+ refined.append(
1092
+ Shot(
1093
+ shot_number=s.shot_number,
1094
+ time_absolute=new_t,
1095
+ time_from_beep=new_t - beep_time,
1096
+ split=new_t - prev_t,
1097
+ peak_amplitude=s.peak_amplitude,
1098
+ confidence=s.confidence,
1099
+ notes=s.notes,
1100
+ )
1101
+ )
1102
+ prev_t = new_t
1103
+ return refined, diffs
1104
+
1105
+
1106
+ def _video_to_audio_path(video: Path) -> Path:
1107
+ """Return a sibling path where the extracted mono wav can be cached."""
1108
+ return video.with_suffix(".wav")
1109
+
1110
+
1111
+ def _extract_or_load_audio(video: Path, audio_path: Path):
1112
+ """Extract mono 48 kHz wav via ffmpeg if not already cached, then load it.
1113
+
1114
+ The cached wav is intentionally placed next to the source video; the caller
1115
+ decides what to do with it. For a one-off ``detect`` we never delete it,
1116
+ which trades disk for repeat-run speed.
1117
+ """
1118
+ if video.suffix.lower() == ".wav":
1119
+ # Input is already a WAV; skip the ffmpeg pass entirely. The
1120
+ # downstream detection code handles arbitrary sample rates.
1121
+ # This avoids two failure modes that bit the slim-smoke job:
1122
+ # (1) Linux ffmpeg refusing input==output with exit 254;
1123
+ # (2) macOS ffmpeg with ``-y`` silently overwriting the source.
1124
+ return beep_detect.load_audio(video)
1125
+ if audio_path == video:
1126
+ # Defensive: caller-supplied audio_path that collides with the
1127
+ # video. Load in place rather than ffmpeg-ing into the input.
1128
+ return beep_detect.load_audio(video)
1129
+ if not audio_path.exists() or audio_path.stat().st_mtime < video.stat().st_mtime:
1130
+ ffmpeg_bin = runtime().ffmpeg_binary
1131
+ if not shutil.which(ffmpeg_bin):
1132
+ raise typer.BadParameter(f"ffmpeg binary not found: {ffmpeg_bin} (required to extract audio)")
1133
+ import subprocess
1134
+
1135
+ subprocess.run(
1136
+ [
1137
+ ffmpeg_bin,
1138
+ "-hide_banner",
1139
+ "-loglevel",
1140
+ "error",
1141
+ "-y",
1142
+ "-i",
1143
+ str(video),
1144
+ "-ac",
1145
+ "1",
1146
+ "-ar",
1147
+ "48000",
1148
+ "-vn",
1149
+ str(audio_path),
1150
+ ],
1151
+ check=True,
1152
+ capture_output=True,
1153
+ text=True,
1154
+ )
1155
+ return beep_detect.load_audio(audio_path)
1156
+
1157
+
1158
+ def _load_stage_json(path: Path) -> CompetitorStages:
1159
+ """Load the SSI Scoreboard JSON and return the first competitor's stages."""
1160
+ raw = json.loads(path.read_text())
1161
+ competitors = raw.get("competitors") or []
1162
+ if not competitors:
1163
+ raise typer.BadParameter(f"no competitors in {path}")
1164
+ return CompetitorStages.model_validate(competitors[0])
1165
+
1166
+
1167
+ def _iter_video_files(directory: Path):
1168
+ if not directory.is_dir():
1169
+ raise typer.BadParameter(f"not a directory: {directory}")
1170
+ return [p for p in directory.iterdir() if p.suffix.lower() in {".mp4", ".mov", ".m4v"}]
1171
+
1172
+
1173
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
1174
+
1175
+
1176
+ def _slugify(name: str) -> str:
1177
+ return _SLUG_RE.sub("-", name.lower()).strip("-") or "stage"
1178
+
1179
+
1180
+ def _override_trim_mode(output_config, value: str):
1181
+ """Validate ``--trim-mode`` against the OutputConfig.trim_mode literal and
1182
+ return a copy with the override applied."""
1183
+ if value not in ("lossless", "audit"):
1184
+ raise typer.BadParameter("--trim-mode must be 'lossless' or 'audit'")
1185
+ return output_config.model_copy(update={"trim_mode": value})
1186
+
1187
+
1188
+ def _dummy_scorecard_time():
1189
+ """A placeholder ``scorecard_updated_at`` for ``single``-mode pipelines that
1190
+ never touch ``video_match``."""
1191
+ from datetime import UTC, datetime
1192
+
1193
+ return datetime(1970, 1, 1, tzinfo=UTC)
1194
+
1195
+
1196
+ # ---------------------------------------------------------------------------
1197
+ # Pretty-printing
1198
+ # ---------------------------------------------------------------------------
1199
+
1200
+
1201
+ def _print_shots_table(shots: list[Shot]) -> None:
1202
+ table = Table(title=f"{len(shots)} shots", show_header=True)
1203
+ table.add_column("#", justify="right")
1204
+ table.add_column("t_abs (s)", justify="right")
1205
+ table.add_column("from beep (s)", justify="right")
1206
+ table.add_column("split (s)", justify="right")
1207
+ table.add_column("peak", justify="right")
1208
+ table.add_column("conf", justify="right")
1209
+ for s in shots:
1210
+ table.add_row(
1211
+ str(s.shot_number),
1212
+ f"{s.time_absolute:.3f}",
1213
+ f"{s.time_from_beep:.3f}",
1214
+ f"{s.split:.3f}",
1215
+ f"{s.peak_amplitude:.3f}",
1216
+ f"{s.confidence:.2f}",
1217
+ )
1218
+ console.print(table)
1219
+
1220
+
1221
+ def _print_anomalies(anomalies: list[str]) -> None:
1222
+ if not anomalies:
1223
+ console.print("[green]No anomalies.[/]")
1224
+ return
1225
+ console.print("[yellow]Anomalies:[/]")
1226
+ for a in anomalies:
1227
+ console.print(f" [yellow]- {a}[/]")
1228
+
1229
+
1230
+ def _print_match_diagnostics(match) -> None:
1231
+ if match.unmatched_stages:
1232
+ console.print(f"[yellow]Unmatched stages:[/] {match.unmatched_stages}")
1233
+ if match.ambiguous_stages:
1234
+ console.print("[yellow]Ambiguous stages:[/]")
1235
+ for stage_num, candidates in match.ambiguous_stages.items():
1236
+ names = ", ".join(p.name for p in candidates)
1237
+ console.print(f" stage {stage_num}: {names}")
1238
+ if match.orphan_videos:
1239
+ console.print(f"[yellow]Orphan videos:[/] {[p.name for p in match.orphan_videos]}")
1240
+
1241
+
1242
+ _CATEGORY_LABELS: dict[cleanup_mod.CleanupCategory, str] = {
1243
+ cleanup_mod.CleanupCategory.CACHES: "Caches (thumbs, probes, scoreboard, peaks)",
1244
+ cleanup_mod.CleanupCategory.EXPORTS_LIGHT: "Light exports (CSV / FCPXML / report)",
1245
+ cleanup_mod.CleanupCategory.EXPORTS_OVERLAYS: "Overlay MOVs",
1246
+ cleanup_mod.CleanupCategory.EXPORTS_TRIMS: "Lossless trims",
1247
+ cleanup_mod.CleanupCategory.AUDIT_TRIMS: "Audit-mode trims",
1248
+ cleanup_mod.CleanupCategory.AUDIO: "Extracted audio",
1249
+ cleanup_mod.CleanupCategory.AUDIT_DATA: "Audit JSON + backups (DESTRUCTIVE)",
1250
+ }
1251
+
1252
+
1253
+ def _print_cleanup_plan(
1254
+ plan: cleanup_mod.CleanupPlan,
1255
+ selected: set[cleanup_mod.CleanupCategory],
1256
+ ) -> None:
1257
+ table = Table(title=f"{plan.total_file_count} files / {plan.total_bytes / (1024*1024):.1f} MB")
1258
+ table.add_column("Category")
1259
+ table.add_column("Files", justify="right")
1260
+ table.add_column("Size", justify="right")
1261
+ for cat in cleanup_mod.CleanupCategory:
1262
+ if cat not in selected:
1263
+ continue
1264
+ totals = plan.totals_by_category.get(cat)
1265
+ if totals is None:
1266
+ continue
1267
+ size_mb = totals.bytes / (1024 * 1024)
1268
+ table.add_row(_CATEGORY_LABELS[cat], str(totals.file_count), f"{size_mb:.1f} MB")
1269
+ console.print(table)
1270
+
1271
+
1272
+ def _print_files_summary(files: ReportFiles) -> None:
1273
+ if any([files.video, files.csv, files.fcpxml]):
1274
+ console.print("[bold]Wrote:[/]")
1275
+ for label, p in (("video", files.video), ("csv", files.csv), ("fcpxml", files.fcpxml)):
1276
+ if p:
1277
+ console.print(f" {label:>6}: {p}")
1278
+
1279
+
1280
+ if __name__ == "__main__":
1281
+ app()