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