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.
- splitsmith/__init__.py +3 -0
- splitsmith/audit.py +87 -0
- splitsmith/automation.py +238 -0
- splitsmith/backup.py +298 -0
- splitsmith/beep_calibration.py +324 -0
- splitsmith/beep_detect.py +371 -0
- splitsmith/cleanup.py +327 -0
- splitsmith/cli.py +1281 -0
- splitsmith/coach.py +253 -0
- splitsmith/coach_distributions.py +348 -0
- splitsmith/compare/__init__.py +7 -0
- splitsmith/compare/cli.py +153 -0
- splitsmith/compare/emitter.py +456 -0
- splitsmith/compare/filler.py +98 -0
- splitsmith/compare/layout.py +164 -0
- splitsmith/compare/manifest.py +91 -0
- splitsmith/compare/project_loader.py +195 -0
- splitsmith/composition.py +606 -0
- splitsmith/config.py +442 -0
- splitsmith/cross_align.py +210 -0
- splitsmith/csv_gen.py +66 -0
- splitsmith/data/ensemble_calibration.json +248 -0
- splitsmith/data/fonts/Antonio-OFL.txt +93 -0
- splitsmith/data/fonts/Antonio-VariableFont.ttf +0 -0
- splitsmith/data/fonts/JetBrainsMono-Bold.ttf +0 -0
- splitsmith/data/fonts/JetBrainsMono-OFL.txt +93 -0
- splitsmith/data/overlay_theme.json +40 -0
- splitsmith/data/templates/action-cut.yaml +19 -0
- splitsmith/data/templates/match-recap.yaml +20 -0
- splitsmith/data/voter_c_gbdt.joblib +0 -0
- splitsmith/data/voter_e_visual_probe.joblib +0 -0
- splitsmith/ensemble/__init__.py +67 -0
- splitsmith/ensemble/agc_state.py +165 -0
- splitsmith/ensemble/api.py +419 -0
- splitsmith/ensemble/backend.py +89 -0
- splitsmith/ensemble/calibration.py +367 -0
- splitsmith/ensemble/clap_mel.py +138 -0
- splitsmith/ensemble/features.py +680 -0
- splitsmith/ensemble/fixtures.py +222 -0
- splitsmith/ensemble/tta.py +115 -0
- splitsmith/ensemble/visual.py +294 -0
- splitsmith/ensemble/voters.py +202 -0
- splitsmith/fcp7xml_render.py +558 -0
- splitsmith/fcpxml_gen.py +1721 -0
- splitsmith/fixture_schema.py +482 -0
- splitsmith/lab/__init__.py +79 -0
- splitsmith/lab/core.py +1118 -0
- splitsmith/lab/promote.py +555 -0
- splitsmith/lab/snap_window.py +331 -0
- splitsmith/lab/sweeps.py +231 -0
- splitsmith/lab_cli.py +750 -0
- splitsmith/match_cli.py +315 -0
- splitsmith/match_model.py +793 -0
- splitsmith/match_registry.py +131 -0
- splitsmith/mcp/__init__.py +23 -0
- splitsmith/mcp/__main__.py +20 -0
- splitsmith/mcp/detect_tools.py +476 -0
- splitsmith/mcp/export_tools.py +356 -0
- splitsmith/mcp/sandbox.py +77 -0
- splitsmith/mcp/server.py +393 -0
- splitsmith/mcp/tools.py +207 -0
- splitsmith/mcp/write_tools.py +268 -0
- splitsmith/model_cli.py +153 -0
- splitsmith/models/__init__.py +40 -0
- splitsmith/models/cache.py +139 -0
- splitsmith/models/download.py +95 -0
- splitsmith/models/errors.py +50 -0
- splitsmith/models/manifest.py +68 -0
- splitsmith/models/registry.py +256 -0
- splitsmith/mp4_render.py +513 -0
- splitsmith/overlay_render.py +817 -0
- splitsmith/overlay_theme.py +146 -0
- splitsmith/relink.py +245 -0
- splitsmith/report.py +258 -0
- splitsmith/runtime.py +268 -0
- splitsmith/shot_detect.py +506 -0
- splitsmith/shot_refine.py +252 -0
- splitsmith/system_check.py +162 -0
- splitsmith/templates.py +188 -0
- splitsmith/thumbnail.py +230 -0
- splitsmith/trim.py +211 -0
- splitsmith/ui/__init__.py +10 -0
- splitsmith/ui/audio.py +536 -0
- splitsmith/ui/embedded.py +312 -0
- splitsmith/ui/exports.py +533 -0
- splitsmith/ui/jobs.py +652 -0
- splitsmith/ui/logging_setup.py +108 -0
- splitsmith/ui/match_exports.py +500 -0
- splitsmith/ui/project.py +1734 -0
- splitsmith/ui/scoreboard/__init__.py +77 -0
- splitsmith/ui/scoreboard/cache.py +237 -0
- splitsmith/ui/scoreboard/http.py +206 -0
- splitsmith/ui/scoreboard/local.py +377 -0
- splitsmith/ui/scoreboard/models.py +301 -0
- splitsmith/ui/scoreboard/protocol.py +51 -0
- splitsmith/ui/server.py +9178 -0
- splitsmith/ui_static/package-lock.json +3062 -0
- splitsmith/ui_static/tsconfig.app.tsbuildinfo +1 -0
- splitsmith/ui_static/tsconfig.node.tsbuildinfo +1 -0
- splitsmith/user_config.py +380 -0
- splitsmith/video_match.py +159 -0
- splitsmith/video_probe.py +143 -0
- splitsmith/waveform.py +121 -0
- splitsmith/youtube_sidecar.py +293 -0
- splitsmith-0.2.0.dist-info/METADATA +301 -0
- splitsmith-0.2.0.dist-info/RECORD +109 -0
- splitsmith-0.2.0.dist-info/WHEEL +4 -0
- splitsmith-0.2.0.dist-info/entry_points.txt +3 -0
- 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()
|