splitsmith 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. splitsmith/__init__.py +3 -0
  2. splitsmith/audit.py +87 -0
  3. splitsmith/automation.py +238 -0
  4. splitsmith/backup.py +298 -0
  5. splitsmith/beep_calibration.py +324 -0
  6. splitsmith/beep_detect.py +371 -0
  7. splitsmith/cleanup.py +327 -0
  8. splitsmith/cli.py +1281 -0
  9. splitsmith/coach.py +253 -0
  10. splitsmith/coach_distributions.py +348 -0
  11. splitsmith/compare/__init__.py +7 -0
  12. splitsmith/compare/cli.py +153 -0
  13. splitsmith/compare/emitter.py +456 -0
  14. splitsmith/compare/filler.py +98 -0
  15. splitsmith/compare/layout.py +164 -0
  16. splitsmith/compare/manifest.py +91 -0
  17. splitsmith/compare/project_loader.py +195 -0
  18. splitsmith/composition.py +606 -0
  19. splitsmith/config.py +442 -0
  20. splitsmith/cross_align.py +210 -0
  21. splitsmith/csv_gen.py +66 -0
  22. splitsmith/data/ensemble_calibration.json +248 -0
  23. splitsmith/data/fonts/Antonio-OFL.txt +93 -0
  24. splitsmith/data/fonts/Antonio-VariableFont.ttf +0 -0
  25. splitsmith/data/fonts/JetBrainsMono-Bold.ttf +0 -0
  26. splitsmith/data/fonts/JetBrainsMono-OFL.txt +93 -0
  27. splitsmith/data/overlay_theme.json +40 -0
  28. splitsmith/data/templates/action-cut.yaml +19 -0
  29. splitsmith/data/templates/match-recap.yaml +20 -0
  30. splitsmith/data/voter_c_gbdt.joblib +0 -0
  31. splitsmith/data/voter_e_visual_probe.joblib +0 -0
  32. splitsmith/ensemble/__init__.py +67 -0
  33. splitsmith/ensemble/agc_state.py +165 -0
  34. splitsmith/ensemble/api.py +419 -0
  35. splitsmith/ensemble/backend.py +89 -0
  36. splitsmith/ensemble/calibration.py +367 -0
  37. splitsmith/ensemble/clap_mel.py +138 -0
  38. splitsmith/ensemble/features.py +680 -0
  39. splitsmith/ensemble/fixtures.py +222 -0
  40. splitsmith/ensemble/tta.py +115 -0
  41. splitsmith/ensemble/visual.py +294 -0
  42. splitsmith/ensemble/voters.py +202 -0
  43. splitsmith/fcp7xml_render.py +558 -0
  44. splitsmith/fcpxml_gen.py +1721 -0
  45. splitsmith/fixture_schema.py +482 -0
  46. splitsmith/lab/__init__.py +79 -0
  47. splitsmith/lab/core.py +1118 -0
  48. splitsmith/lab/promote.py +555 -0
  49. splitsmith/lab/snap_window.py +331 -0
  50. splitsmith/lab/sweeps.py +231 -0
  51. splitsmith/lab_cli.py +750 -0
  52. splitsmith/match_cli.py +315 -0
  53. splitsmith/match_model.py +793 -0
  54. splitsmith/match_registry.py +131 -0
  55. splitsmith/mcp/__init__.py +23 -0
  56. splitsmith/mcp/__main__.py +20 -0
  57. splitsmith/mcp/detect_tools.py +476 -0
  58. splitsmith/mcp/export_tools.py +356 -0
  59. splitsmith/mcp/sandbox.py +77 -0
  60. splitsmith/mcp/server.py +393 -0
  61. splitsmith/mcp/tools.py +207 -0
  62. splitsmith/mcp/write_tools.py +268 -0
  63. splitsmith/model_cli.py +153 -0
  64. splitsmith/models/__init__.py +40 -0
  65. splitsmith/models/cache.py +139 -0
  66. splitsmith/models/download.py +95 -0
  67. splitsmith/models/errors.py +50 -0
  68. splitsmith/models/manifest.py +68 -0
  69. splitsmith/models/registry.py +256 -0
  70. splitsmith/mp4_render.py +513 -0
  71. splitsmith/overlay_render.py +817 -0
  72. splitsmith/overlay_theme.py +146 -0
  73. splitsmith/relink.py +245 -0
  74. splitsmith/report.py +258 -0
  75. splitsmith/runtime.py +268 -0
  76. splitsmith/shot_detect.py +506 -0
  77. splitsmith/shot_refine.py +252 -0
  78. splitsmith/system_check.py +162 -0
  79. splitsmith/templates.py +188 -0
  80. splitsmith/thumbnail.py +230 -0
  81. splitsmith/trim.py +211 -0
  82. splitsmith/ui/__init__.py +10 -0
  83. splitsmith/ui/audio.py +536 -0
  84. splitsmith/ui/embedded.py +312 -0
  85. splitsmith/ui/exports.py +533 -0
  86. splitsmith/ui/jobs.py +652 -0
  87. splitsmith/ui/logging_setup.py +108 -0
  88. splitsmith/ui/match_exports.py +500 -0
  89. splitsmith/ui/project.py +1734 -0
  90. splitsmith/ui/scoreboard/__init__.py +77 -0
  91. splitsmith/ui/scoreboard/cache.py +237 -0
  92. splitsmith/ui/scoreboard/http.py +206 -0
  93. splitsmith/ui/scoreboard/local.py +377 -0
  94. splitsmith/ui/scoreboard/models.py +301 -0
  95. splitsmith/ui/scoreboard/protocol.py +51 -0
  96. splitsmith/ui/server.py +9178 -0
  97. splitsmith/ui_static/package-lock.json +3062 -0
  98. splitsmith/ui_static/tsconfig.app.tsbuildinfo +1 -0
  99. splitsmith/ui_static/tsconfig.node.tsbuildinfo +1 -0
  100. splitsmith/user_config.py +380 -0
  101. splitsmith/video_match.py +159 -0
  102. splitsmith/video_probe.py +143 -0
  103. splitsmith/waveform.py +121 -0
  104. splitsmith/youtube_sidecar.py +293 -0
  105. splitsmith-0.2.0.dist-info/METADATA +301 -0
  106. splitsmith-0.2.0.dist-info/RECORD +109 -0
  107. splitsmith-0.2.0.dist-info/WHEEL +4 -0
  108. splitsmith-0.2.0.dist-info/entry_points.txt +3 -0
  109. splitsmith-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,98 @@
1
+ """Black filler video for empty grid tiles in compare exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ Runner = Callable[..., subprocess.CompletedProcess]
10
+
11
+
12
+ class FillerRenderError(RuntimeError):
13
+ """ffmpeg refused to render the black filler clip."""
14
+
15
+
16
+ def filler_filename(
17
+ *, width: int, height: int, frame_rate_num: int, frame_rate_den: int, duration_seconds: float
18
+ ) -> str:
19
+ """Deterministic filename so two stages with matching geometry reuse the same file.
20
+
21
+ Encodes ``(W, H, fps_num, fps_den, duration_ms)``; duration is rounded
22
+ to milliseconds because the same compound clip should resolve to the
23
+ same filler file across runs even when the source duration differs by
24
+ sub-millisecond rounding.
25
+ """
26
+ duration_ms = int(round(duration_seconds * 1000))
27
+ return f"_compare_filler_{width}x{height}_{frame_rate_num}-{frame_rate_den}" f"_{duration_ms}ms.mp4"
28
+
29
+
30
+ def ensure_filler(
31
+ *,
32
+ width: int,
33
+ height: int,
34
+ frame_rate_num: int,
35
+ frame_rate_den: int,
36
+ duration_seconds: float,
37
+ output_dir: Path,
38
+ ffmpeg_binary: str = "ffmpeg",
39
+ runner: Runner = subprocess.run,
40
+ ) -> Path:
41
+ """Render (or reuse) a silent black filler video.
42
+
43
+ Idempotent: the filename encodes geometry + duration, so a second call
44
+ with matching arguments returns the existing file without re-running
45
+ ffmpeg. ``output_dir`` is created if needed.
46
+
47
+ The clip has no audio (``-an``) so the emitter doesn't need to mute it
48
+ explicitly. ``libx264 -pix_fmt yuv420p`` is used for cross-platform
49
+ reproducibility -- this stays predictable in CI / Linux even though
50
+ the rest of the pipeline can use VideoToolbox locally.
51
+ """
52
+ if width <= 0 or height <= 0:
53
+ raise ValueError(f"width/height must be positive, got {width}x{height}")
54
+ if frame_rate_num <= 0 or frame_rate_den <= 0:
55
+ raise ValueError(f"frame rate must be positive, got {frame_rate_num}/{frame_rate_den}")
56
+ if duration_seconds <= 0.0:
57
+ raise ValueError(f"duration_seconds must be positive, got {duration_seconds}")
58
+
59
+ output_dir.mkdir(parents=True, exist_ok=True)
60
+ target = output_dir / filler_filename(
61
+ width=width,
62
+ height=height,
63
+ frame_rate_num=frame_rate_num,
64
+ frame_rate_den=frame_rate_den,
65
+ duration_seconds=duration_seconds,
66
+ )
67
+ if target.exists():
68
+ return target
69
+
70
+ fps = frame_rate_num / frame_rate_den
71
+ cmd = [
72
+ ffmpeg_binary,
73
+ "-hide_banner",
74
+ "-loglevel",
75
+ "error",
76
+ "-y",
77
+ "-f",
78
+ "lavfi",
79
+ "-i",
80
+ f"color=c=black:s={width}x{height}:r={fps:.6f}",
81
+ "-t",
82
+ f"{duration_seconds:.3f}",
83
+ "-c:v",
84
+ "libx264",
85
+ "-pix_fmt",
86
+ "yuv420p",
87
+ "-an",
88
+ str(target),
89
+ ]
90
+ try:
91
+ runner(cmd, check=True, capture_output=True, text=True)
92
+ except FileNotFoundError as exc:
93
+ raise FillerRenderError(f"ffmpeg binary not found: {ffmpeg_binary}") from exc
94
+ except subprocess.CalledProcessError as exc:
95
+ raise FillerRenderError(
96
+ f"ffmpeg failed rendering filler ({exc.returncode}): " f"{exc.stderr or exc.stdout!r}"
97
+ ) from exc
98
+ return target
@@ -0,0 +1,164 @@
1
+ """Grid-tile placement math for compare exports.
2
+
3
+ Pure functions: no I/O, no FCPXML. Slot positions are returned in
4
+ sequence-frame pixels (centre-of-tile, sequence-centre origin, +Y up);
5
+ the FCPXML emitter converts them to FCP's normalised units using the
6
+ same ``unit_per_px = 100.0 / sequence_height`` rule
7
+ :func:`splitsmith.fcpxml_gen._pip_transform_attrs` uses.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ GridKind = Literal["1up", "2up-h", "2up-v", "2x2", "3x3", "4x4"]
16
+ Layout2Up = Literal["horizontal", "vertical"]
17
+
18
+ # (rows, cols) per grid kind. ``2up-h`` is one row of two; ``2up-v`` is
19
+ # two stacked rows. Bigger grids are square.
20
+ _GRID_SHAPE: dict[GridKind, tuple[int, int]] = {
21
+ "1up": (1, 1),
22
+ "2up-h": (1, 2),
23
+ "2up-v": (2, 1),
24
+ "2x2": (2, 2),
25
+ "3x3": (3, 3),
26
+ "4x4": (4, 4),
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class GridSlot:
32
+ """One tile's transform inside the sequence frame."""
33
+
34
+ scale: float
35
+ """Uniform letterbox factor vs. native cam size (``1.0`` == native)."""
36
+
37
+ position_px: tuple[float, float]
38
+ """Centre-of-tile pixel offset from the sequence centre, +Y up."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class GridLayout:
43
+ """A resolved grid for one stage of one compare export."""
44
+
45
+ kind: GridKind
46
+ slots_per_label: dict[str, GridSlot]
47
+ """Slot keyed by label, in alphabetical order. Only labels actually
48
+ present in this stage appear; missing-tile labels are absent."""
49
+
50
+ empty_slots: list[GridSlot]
51
+ """Filler-tile slots for cells the chosen grid leaves empty."""
52
+
53
+
54
+ def choose_grid(roster_count: int, *, layout_2up: Layout2Up = "horizontal") -> GridKind:
55
+ """Smallest grid whose capacity is ``>= roster_count``.
56
+
57
+ Sized for the *full* manifest roster, not the per-stage present
58
+ subset, so slot indices stay stable across stages of one export
59
+ (a label always lands in the same tile; missing tiles become
60
+ filler). 1 -> ``1up``; 2 -> ``2up-h`` or ``2up-v`` per
61
+ ``layout_2up``; 3..4 -> ``2x2``; 5..9 -> ``3x3``; 10..16 ->
62
+ ``4x4``. Counts of 0 or above 16 raise :class:`ValueError`.
63
+ """
64
+ if roster_count <= 0:
65
+ raise ValueError(f"roster_count must be >= 1, got {roster_count}")
66
+ if roster_count == 1:
67
+ return "1up"
68
+ if roster_count == 2:
69
+ return "2up-h" if layout_2up == "horizontal" else "2up-v"
70
+ if roster_count <= 4:
71
+ return "2x2"
72
+ if roster_count <= 9:
73
+ return "3x3"
74
+ if roster_count <= 16:
75
+ return "4x4"
76
+ raise ValueError(f"roster_count={roster_count} exceeds the largest supported grid (16)")
77
+
78
+
79
+ def _slot_for_index(
80
+ *,
81
+ index: int,
82
+ rows: int,
83
+ cols: int,
84
+ sequence_width: int,
85
+ sequence_height: int,
86
+ cam_width: int,
87
+ cam_height: int,
88
+ ) -> GridSlot:
89
+ """Compute the transform for tile ``index`` (0-based, row-major)."""
90
+ row = index // cols
91
+ col = index % cols
92
+ cell_w = sequence_width / cols
93
+ cell_h = sequence_height / rows
94
+ scale = min(cell_w / cam_width, cell_h / cam_height)
95
+ # Centre of cell (col, row) in the same +Y-up convention the emitter
96
+ # uses (row 0 sits at the top of the frame, so y is positive).
97
+ centre_x = (col + 0.5) * cell_w - sequence_width / 2.0
98
+ centre_y = sequence_height / 2.0 - (row + 0.5) * cell_h
99
+ return GridSlot(scale=scale, position_px=(centre_x, centre_y))
100
+
101
+
102
+ def compute_layout(
103
+ *,
104
+ sorted_labels: list[str],
105
+ present_labels: set[str],
106
+ sequence_width: int,
107
+ sequence_height: int,
108
+ cam_width: int,
109
+ cam_height: int,
110
+ layout_2up: Layout2Up = "horizontal",
111
+ ) -> GridLayout:
112
+ """Resolve per-tile transforms for one stage.
113
+
114
+ ``sorted_labels`` is the alphabetically-sorted list of every label
115
+ in the manifest (the full roster -- ``len(sorted_labels)`` drives
116
+ the chosen grid kind). Slot indices follow that order so a label
117
+ always lands in the same tile across every stage of the export,
118
+ regardless of who's missing. Missing labels leave their cell empty
119
+ for the filler; remaining unused cells in the chosen grid (when
120
+ the roster doesn't fill it perfectly) also go into ``empty_slots``.
121
+ """
122
+ if not sorted_labels:
123
+ raise ValueError("sorted_labels must not be empty")
124
+ if not present_labels:
125
+ raise ValueError("present_labels must not be empty")
126
+ unknown = present_labels - set(sorted_labels)
127
+ if unknown:
128
+ raise ValueError(f"present_labels has unknown labels: {sorted(unknown)}")
129
+
130
+ kind = choose_grid(len(sorted_labels), layout_2up=layout_2up)
131
+ rows, cols = _GRID_SHAPE[kind]
132
+ capacity = rows * cols
133
+
134
+ # Slot index = position in sorted_labels. Stable across stages even
135
+ # when a different shooter is missing each stage.
136
+ slots_per_label: dict[str, GridSlot] = {}
137
+ used_indices: set[int] = set()
138
+ for index, label in enumerate(sorted_labels):
139
+ if label in present_labels:
140
+ slots_per_label[label] = _slot_for_index(
141
+ index=index,
142
+ rows=rows,
143
+ cols=cols,
144
+ sequence_width=sequence_width,
145
+ sequence_height=sequence_height,
146
+ cam_width=cam_width,
147
+ cam_height=cam_height,
148
+ )
149
+ used_indices.add(index)
150
+
151
+ empty_slots = [
152
+ _slot_for_index(
153
+ index=i,
154
+ rows=rows,
155
+ cols=cols,
156
+ sequence_width=sequence_width,
157
+ sequence_height=sequence_height,
158
+ cam_width=cam_width,
159
+ cam_height=cam_height,
160
+ )
161
+ for i in range(capacity)
162
+ if i not in used_indices
163
+ ]
164
+ return GridLayout(kind=kind, slots_per_label=slots_per_label, empty_slots=empty_slots)
@@ -0,0 +1,91 @@
1
+ """Pydantic schema + YAML loader for compare-export manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, field_validator, model_validator
10
+
11
+
12
+ class CompareShooter(BaseModel):
13
+ """One shooter contributing tiles to the comparison."""
14
+
15
+ project: Path
16
+ label: str = Field(min_length=1)
17
+
18
+ @field_validator("project", mode="before")
19
+ @classmethod
20
+ def _expand(cls, value: object) -> object:
21
+ if isinstance(value, str):
22
+ return Path(value).expanduser()
23
+ if isinstance(value, Path):
24
+ return value.expanduser()
25
+ return value
26
+
27
+
28
+ class CompareManifest(BaseModel):
29
+ """A compare-export manifest loaded from YAML.
30
+
31
+ Path resolution: ``output`` and shooter ``project`` paths in the
32
+ YAML are taken verbatim. ``~`` is expanded; relative paths are NOT
33
+ rewritten here -- :func:`load_manifest` does the manifest-dir
34
+ resolution because it's the only caller that knows where the
35
+ manifest lives on disk.
36
+ """
37
+
38
+ output: Path
39
+ audio_from: str = Field(min_length=1)
40
+ layout_2up: Literal["horizontal", "vertical"] = "horizontal"
41
+ shooters: list[CompareShooter] = Field(min_length=1)
42
+
43
+ @field_validator("output", mode="before")
44
+ @classmethod
45
+ def _expand_output(cls, value: object) -> object:
46
+ if isinstance(value, str):
47
+ return Path(value).expanduser()
48
+ if isinstance(value, Path):
49
+ return value.expanduser()
50
+ return value
51
+
52
+ @model_validator(mode="after")
53
+ def _labels_unique(self) -> CompareManifest:
54
+ labels = [s.label for s in self.shooters]
55
+ if len(set(labels)) != len(labels):
56
+ dupes = sorted({lab for lab in labels if labels.count(lab) > 1})
57
+ raise ValueError(f"duplicate shooter labels: {dupes}")
58
+ return self
59
+
60
+ @model_validator(mode="after")
61
+ def _audio_from_matches(self) -> CompareManifest:
62
+ labels = {s.label for s in self.shooters}
63
+ if self.audio_from not in labels:
64
+ raise ValueError(
65
+ f"audio_from={self.audio_from!r} does not match any shooter label " f"({sorted(labels)})"
66
+ )
67
+ return self
68
+
69
+
70
+ def load_manifest(path: Path) -> CompareManifest:
71
+ """Read ``path`` and return a validated :class:`CompareManifest`.
72
+
73
+ Resolves the manifest's ``output`` path against the manifest's
74
+ parent directory when it's relative, and rewrites each shooter's
75
+ ``project`` path against the same base when relative -- so a
76
+ manifest is portable as long as the YAML and the project roots
77
+ move together.
78
+ """
79
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
80
+ if not isinstance(raw, dict):
81
+ raise ValueError(f"manifest {path} must be a YAML mapping at the top level")
82
+ manifest = CompareManifest.model_validate(raw)
83
+ base = path.parent
84
+ if not manifest.output.is_absolute():
85
+ manifest = manifest.model_copy(update={"output": (base / manifest.output).resolve()})
86
+
87
+ def _resolve(p: Path) -> Path:
88
+ return p if p.is_absolute() else (base / p).resolve()
89
+
90
+ resolved_shooters = [s.model_copy(update={"project": _resolve(s.project)}) for s in manifest.shooters]
91
+ return manifest.model_copy(update={"shooters": resolved_shooters})
@@ -0,0 +1,195 @@
1
+ """Load per-stage trim metadata from a shooter -- legacy project or merged Match."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from .. import fcpxml_gen
10
+ from ..fcpxml_gen import VideoMetadata
11
+ from ..match_model import Match, Shooter
12
+ from ..ui.match_exports import _slugify
13
+ from ..ui.project import MatchProject
14
+
15
+ ProbeFn = Callable[[Path], VideoMetadata]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CompareStageBundle:
20
+ """All the per-stage facts the emitter needs from one shooter."""
21
+
22
+ stage_number: int
23
+ stage_name: str
24
+ trim_path: Path
25
+ audit_path: Path
26
+ beep_offset_in_clip: float
27
+ duration_seconds: float
28
+ width: int
29
+ height: int
30
+ frame_rate_num: int
31
+ frame_rate_den: int
32
+
33
+ @property
34
+ def metadata(self) -> VideoMetadata:
35
+ return VideoMetadata(
36
+ width=self.width,
37
+ height=self.height,
38
+ duration_seconds=self.duration_seconds,
39
+ frame_rate_num=self.frame_rate_num,
40
+ frame_rate_den=self.frame_rate_den,
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class CompareShooterBundle:
46
+ """A shooter's project + the per-stage bundles ready for export.
47
+
48
+ ``project`` is the legacy :class:`MatchProject` when this bundle came
49
+ from a single-shooter project; ``None`` when it came from a shooter
50
+ inside a merged :class:`splitsmith.match_model.Match`. The emitter
51
+ only reads ``label`` and ``stages_by_number``, so the optional field
52
+ is informational for callers that want to inspect it.
53
+ """
54
+
55
+ label: str
56
+ project_root: Path
57
+ project: MatchProject | None = None
58
+ stages_by_number: dict[int, CompareStageBundle] = field(default_factory=dict)
59
+
60
+
61
+ def trim_path_for_stage(
62
+ project: MatchProject, project_root: Path, stage_number: int, stage_name: str
63
+ ) -> Path:
64
+ """Return the lossless-trim path the per-stage exporter would write.
65
+
66
+ Mirrors :func:`splitsmith.ui.exports.export_audit_clip`'s naming:
67
+ ``<exports>/stage<N>_<slug>_trimmed.mp4``.
68
+ """
69
+ base = f"stage{stage_number}_{_slugify(stage_name)}"
70
+ return project.exports_path(project_root) / f"{base}_trimmed.mp4"
71
+
72
+
73
+ def audit_path_for_stage(project: MatchProject, project_root: Path, stage_number: int) -> Path:
74
+ return project.audit_path(project_root) / f"stage{stage_number}.json"
75
+
76
+
77
+ def load_shooter(
78
+ project_root: Path,
79
+ label: str,
80
+ *,
81
+ probe: ProbeFn | None = None,
82
+ ) -> CompareShooterBundle:
83
+ """Open ``project_root`` and build per-stage bundles for ``label``.
84
+
85
+ Stages are skipped (omitted from ``stages_by_number``) when:
86
+ - the stage is marked ``skipped``;
87
+ - there is no primary video, or the primary has no ``beep_time``;
88
+ - the lossless trim is not on disk.
89
+
90
+ ``probe`` defaults to :func:`splitsmith.fcpxml_gen.probe_video`;
91
+ pass a stub in tests to avoid shelling out to ffprobe.
92
+ """
93
+ if probe is None:
94
+ probe = fcpxml_gen.probe_video
95
+ project = MatchProject.load(project_root)
96
+ pre_buffer = project.trim_pre_buffer_seconds
97
+ bundles: dict[int, CompareStageBundle] = {}
98
+ for stage in project.stages:
99
+ if stage.skipped:
100
+ continue
101
+ primary = stage.primary()
102
+ if primary is None or primary.beep_time is None:
103
+ continue
104
+ trim = trim_path_for_stage(project, project_root, stage.stage_number, stage.stage_name)
105
+ if not trim.exists():
106
+ continue
107
+ meta = probe(trim)
108
+ bundles[stage.stage_number] = CompareStageBundle(
109
+ stage_number=stage.stage_number,
110
+ stage_name=stage.stage_name,
111
+ trim_path=trim,
112
+ audit_path=audit_path_for_stage(project, project_root, stage.stage_number),
113
+ beep_offset_in_clip=min(pre_buffer, primary.beep_time),
114
+ duration_seconds=meta.duration_seconds,
115
+ width=meta.width,
116
+ height=meta.height,
117
+ frame_rate_num=meta.frame_rate_num,
118
+ frame_rate_den=meta.frame_rate_den,
119
+ )
120
+ return CompareShooterBundle(
121
+ label=label,
122
+ project_root=project_root,
123
+ project=project,
124
+ stages_by_number=bundles,
125
+ )
126
+
127
+
128
+ def _trim_path_for_shooter_stage(
129
+ shooter: Shooter,
130
+ shooter_root: Path,
131
+ stage_number: int,
132
+ stage_name: str,
133
+ ) -> Path:
134
+ """Same naming as :func:`trim_path_for_stage` but rooted at a shooter dir."""
135
+ base = f"stage{stage_number}_{_slugify(stage_name)}"
136
+ exports = Path(shooter.exports_dir).expanduser() if shooter.exports_dir else shooter_root / "exports"
137
+ if not exports.is_absolute():
138
+ exports = shooter_root / exports
139
+ return exports / f"{base}_trimmed.mp4"
140
+
141
+
142
+ def load_shooter_from_match(
143
+ match_root: Path,
144
+ slug: str,
145
+ label: str,
146
+ *,
147
+ probe: ProbeFn | None = None,
148
+ ) -> CompareShooterBundle:
149
+ """Build a :class:`CompareShooterBundle` from one shooter inside a merged Match.
150
+
151
+ Stage definitions come from the match (shared across shooters); per-
152
+ stage data (time + videos) comes from the shooter. Same skip rules
153
+ as :func:`load_shooter`: a stage is omitted when it's marked skipped,
154
+ has no primary video with a beep time, or its lossless trim is
155
+ missing from the shooter's exports dir.
156
+ """
157
+ if probe is None:
158
+ probe = fcpxml_gen.probe_video
159
+ match = Match.load(match_root)
160
+ shooter = match.load_shooter(match_root, slug)
161
+ shooter_root = Match.shooter_root(match_root, slug)
162
+ # Stage name lookup from the match-level definitions.
163
+ stage_names: dict[int, str] = {s.stage_number: s.stage_name for s in match.stages}
164
+
165
+ bundles: dict[int, CompareStageBundle] = {}
166
+ for stage in shooter.stages:
167
+ if stage.skipped:
168
+ continue
169
+ primary = next((v for v in stage.videos if v.role == "primary"), None)
170
+ if primary is None or primary.beep_time is None:
171
+ continue
172
+ stage_name = stage_names.get(stage.stage_number, f"stage{stage.stage_number}")
173
+ trim = _trim_path_for_shooter_stage(shooter, shooter_root, stage.stage_number, stage_name)
174
+ if not trim.exists():
175
+ continue
176
+ meta = probe(trim)
177
+ bundles[stage.stage_number] = CompareStageBundle(
178
+ stage_number=stage.stage_number,
179
+ stage_name=stage_name,
180
+ trim_path=trim,
181
+ audit_path=shooter_root / "audit" / f"stage{stage.stage_number}.json",
182
+ beep_offset_in_clip=min(shooter.trim_pre_buffer_seconds, primary.beep_time),
183
+ duration_seconds=meta.duration_seconds,
184
+ width=meta.width,
185
+ height=meta.height,
186
+ frame_rate_num=meta.frame_rate_num,
187
+ frame_rate_den=meta.frame_rate_den,
188
+ )
189
+
190
+ return CompareShooterBundle(
191
+ label=label,
192
+ project_root=shooter_root,
193
+ project=None,
194
+ stages_by_number=bundles,
195
+ )