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
|
@@ -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
|
+
)
|