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/ui/exports.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Per-stage export pipeline for the production UI (issue #17).
|
|
2
|
+
|
|
3
|
+
The Audit screen produces a per-stage audit JSON at
|
|
4
|
+
``<project>/audit/stage<N>.json`` whose ``shots[]`` is the user's source of
|
|
5
|
+
truth. This module is a thin orchestrator that converts that JSON into the
|
|
6
|
+
existing engine's :class:`Shot` records and calls the unchanged
|
|
7
|
+
:mod:`csv_gen`, :mod:`fcpxml_gen`, and :mod:`report` writers so the
|
|
8
|
+
production UI's exports are byte-comparable with ``splitsmith single``
|
|
9
|
+
output for the same audit data.
|
|
10
|
+
|
|
11
|
+
Pure of detection: never re-runs beep / shot detection. The whole point of
|
|
12
|
+
the production UI is that the user-audited shots are the truth.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .. import csv_gen, fcpxml_gen, overlay_render, report, trim
|
|
24
|
+
from ..config import Config, ReportFiles, Shot, StageAnalysis, StageData
|
|
25
|
+
from ..overlay_render import OverlayCodec
|
|
26
|
+
from ..overlay_theme import ThemeName
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class StageExportRequest:
|
|
31
|
+
"""One stage's export job: which artefacts to write.
|
|
32
|
+
|
|
33
|
+
The lossless trim is part of the export -- it's the archival deliverable
|
|
34
|
+
that ships to FCP, distinct from the audit-mode short-GOP scrub copy
|
|
35
|
+
that lives in ``<project>/trimmed/``. The FCPXML references the lossless
|
|
36
|
+
trim, mirroring ``splitsmith single``: the SPA's exports are
|
|
37
|
+
byte-comparable with the CLI's for the same audit data.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
stage_number: int
|
|
41
|
+
write_trim: bool = True
|
|
42
|
+
write_csv: bool = True
|
|
43
|
+
write_fcpxml: bool = True
|
|
44
|
+
write_report: bool = True
|
|
45
|
+
write_overlay: bool = False
|
|
46
|
+
# Overlay format knobs (issue #45 follow-up). ``overlay_codec="auto"``
|
|
47
|
+
# picks ``hevc-alpha`` on macOS w/ VideoToolbox (~10-20x smaller than
|
|
48
|
+
# ProRes 4444 for sparse-text overlays), else ``prores-4444``.
|
|
49
|
+
# ``overlay_max_height`` / ``overlay_max_fps`` cap the overlay's
|
|
50
|
+
# geometry / frame rate; the FCPXML emits a dedicated format element
|
|
51
|
+
# so FCP scales the smaller overlay across the timeline.
|
|
52
|
+
overlay_codec: OverlayCodec = "auto"
|
|
53
|
+
overlay_max_height: int | None = None
|
|
54
|
+
overlay_max_fps: float | None = None
|
|
55
|
+
# Palette preset. ``"splitsmith"`` (default) pulls the same tokens
|
|
56
|
+
# the web UI uses out of overlay_theme.json so the overlay matches
|
|
57
|
+
# the brand. ``"clean"`` is the neutral white-on-amber alternative.
|
|
58
|
+
overlay_theme: ThemeName = "splitsmith"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class SecondaryExport:
|
|
63
|
+
"""One secondary cam to ship alongside the primary (issue #54).
|
|
64
|
+
|
|
65
|
+
The export pipeline trims each secondary into ``exports/`` with a
|
|
66
|
+
``cam_<video_id>`` suffix and references it from the multi-cam FCPXML
|
|
67
|
+
as a connected clip. ``video_id`` is the project's stable per-video
|
|
68
|
+
handle (:attr:`StageVideo.video_id`); secondaries without a beep can't
|
|
69
|
+
sync, so the caller filters them out before passing them in.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
video_id: str
|
|
73
|
+
source_path: Path
|
|
74
|
+
beep_time_in_source: float
|
|
75
|
+
label: str = "Secondary cam"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class StageExportResult:
|
|
80
|
+
"""Paths produced (or skipped) by :func:`export_stage`."""
|
|
81
|
+
|
|
82
|
+
stage_number: int
|
|
83
|
+
trimmed_video_path: Path | None
|
|
84
|
+
csv_path: Path | None
|
|
85
|
+
fcpxml_path: Path | None
|
|
86
|
+
report_path: Path | None
|
|
87
|
+
overlay_path: Path | None
|
|
88
|
+
shots_written: int
|
|
89
|
+
anomalies: list[str]
|
|
90
|
+
# Per-cam lossless trims keyed by ``StageVideo.video_id`` (issue #54).
|
|
91
|
+
# Empty when the stage is single-cam or all secondaries failed to trim.
|
|
92
|
+
# The FCPXML references each present file as a connected clip.
|
|
93
|
+
secondary_trimmed_paths: dict[str, Path] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StageExportError(RuntimeError):
|
|
97
|
+
"""Raised when the audit JSON is missing / malformed / lacks the data
|
|
98
|
+
needed to produce an export. Endpoints surface this as a 400."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def audit_shots_to_engine_shots(
|
|
102
|
+
audit_data: dict[str, Any],
|
|
103
|
+
*,
|
|
104
|
+
beep_time_in_source: float,
|
|
105
|
+
) -> list[Shot]:
|
|
106
|
+
"""Convert the audit JSON's ``shots[]`` to engine :class:`Shot` records.
|
|
107
|
+
|
|
108
|
+
``beep_time_in_source`` is the beep position in the source video's
|
|
109
|
+
timeline (seconds from start). Audit ``shots[].time`` is clip-local;
|
|
110
|
+
we never use it directly here -- the engine wants ``time_absolute`` in
|
|
111
|
+
the source, which is ``beep_time_in_source + time_from_beep``.
|
|
112
|
+
|
|
113
|
+
``peak_amplitude`` and ``confidence`` are looked up from the candidate
|
|
114
|
+
pool (``_candidates_pending_audit.candidates``) by ``candidate_number``
|
|
115
|
+
when present; otherwise default to 0.0 (manually-added shots that
|
|
116
|
+
weren't tied to a detector candidate).
|
|
117
|
+
|
|
118
|
+
Splits: shot 1's split is the draw (= ``time_from_beep``); shot N>1 is
|
|
119
|
+
the difference between successive ``time_from_beep`` values. This
|
|
120
|
+
mirrors :func:`csv_gen.write_splits_csv`'s expectations from the CLI.
|
|
121
|
+
"""
|
|
122
|
+
raw_shots = audit_data.get("shots") or []
|
|
123
|
+
if not isinstance(raw_shots, list) or not raw_shots:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
candidates_block = audit_data.get("_candidates_pending_audit") or {}
|
|
127
|
+
candidates = candidates_block.get("candidates") if isinstance(candidates_block, dict) else None
|
|
128
|
+
by_cand: dict[int, dict[str, Any]] = {}
|
|
129
|
+
if isinstance(candidates, list):
|
|
130
|
+
for c in candidates:
|
|
131
|
+
num = c.get("candidate_number") if isinstance(c, dict) else None
|
|
132
|
+
if isinstance(num, int):
|
|
133
|
+
by_cand[num] = c
|
|
134
|
+
|
|
135
|
+
# Sort by shot_number so the output is deterministic regardless of the
|
|
136
|
+
# JSON's row order. Audits saved by the SPA preserve order, but external
|
|
137
|
+
# tools (audit-apply) write append-style, so don't trust order.
|
|
138
|
+
ordered = sorted(raw_shots, key=lambda s: s.get("shot_number", 0))
|
|
139
|
+
|
|
140
|
+
out: list[Shot] = []
|
|
141
|
+
prev_time_from_beep: float | None = None
|
|
142
|
+
for raw in ordered:
|
|
143
|
+
if not isinstance(raw, dict):
|
|
144
|
+
continue
|
|
145
|
+
ms = raw.get("ms_after_beep")
|
|
146
|
+
if ms is None:
|
|
147
|
+
continue
|
|
148
|
+
time_from_beep = float(ms) / 1000.0
|
|
149
|
+
time_absolute = beep_time_in_source + time_from_beep
|
|
150
|
+
cand_num = raw.get("candidate_number")
|
|
151
|
+
cand = by_cand.get(cand_num) if isinstance(cand_num, int) else None
|
|
152
|
+
peak = float(cand.get("peak_amplitude", 0.0)) if isinstance(cand, dict) else 0.0
|
|
153
|
+
conf = (
|
|
154
|
+
float(cand.get("confidence", 0.0))
|
|
155
|
+
if isinstance(cand, dict) and cand.get("confidence") is not None
|
|
156
|
+
else 0.0
|
|
157
|
+
)
|
|
158
|
+
# Clamp confidence to the model's [0, 1] domain in case the
|
|
159
|
+
# candidate carries a raw classifier score that escaped the band.
|
|
160
|
+
conf = max(0.0, min(1.0, conf))
|
|
161
|
+
notes_raw = raw.get("notes")
|
|
162
|
+
notes = str(notes_raw) if isinstance(notes_raw, str) else ""
|
|
163
|
+
shot_number = int(raw.get("shot_number", len(out) + 1))
|
|
164
|
+
if prev_time_from_beep is None:
|
|
165
|
+
split = time_from_beep # draw
|
|
166
|
+
else:
|
|
167
|
+
split = time_from_beep - prev_time_from_beep
|
|
168
|
+
prev_time_from_beep = time_from_beep
|
|
169
|
+
out.append(
|
|
170
|
+
Shot(
|
|
171
|
+
shot_number=shot_number,
|
|
172
|
+
time_absolute=time_absolute,
|
|
173
|
+
time_from_beep=time_from_beep,
|
|
174
|
+
split=split,
|
|
175
|
+
peak_amplitude=peak,
|
|
176
|
+
confidence=conf,
|
|
177
|
+
notes=notes,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def export_stage(
|
|
184
|
+
*,
|
|
185
|
+
request: StageExportRequest,
|
|
186
|
+
audit_path: Path,
|
|
187
|
+
exports_dir: Path,
|
|
188
|
+
source_video_path: Path | None,
|
|
189
|
+
stage_data: StageData,
|
|
190
|
+
beep_time_in_source: float,
|
|
191
|
+
pre_buffer_seconds: float,
|
|
192
|
+
post_buffer_seconds: float,
|
|
193
|
+
config: Config,
|
|
194
|
+
secondaries: list[SecondaryExport] | None = None,
|
|
195
|
+
) -> StageExportResult:
|
|
196
|
+
"""Run the export for one stage. Pure orchestration over the engine
|
|
197
|
+
modules; never re-detects.
|
|
198
|
+
|
|
199
|
+
Produces (subject to the request flags):
|
|
200
|
+
- ``stage<N>_<slug>_trimmed.mp4`` -- lossless stream-copy trim of the
|
|
201
|
+
source, matching ``splitsmith single``. This is the FCP-bound
|
|
202
|
+
deliverable, distinct from the audit-mode short-GOP scrub copy in
|
|
203
|
+
``<project>/trimmed/``.
|
|
204
|
+
- ``stage<N>_<slug>_splits.csv``
|
|
205
|
+
- ``stage<N>_<slug>.fcpxml`` (references the lossless trim above)
|
|
206
|
+
- ``stage<N>_<slug>_report.txt``
|
|
207
|
+
|
|
208
|
+
``audit_path`` must exist and contain at least one shot. ``exports_dir``
|
|
209
|
+
is created if missing. ``source_video_path`` is the primary's source
|
|
210
|
+
file (resolved through any symlink); it is required when ``write_trim``
|
|
211
|
+
or ``write_fcpxml`` is set.
|
|
212
|
+
"""
|
|
213
|
+
if not audit_path.exists():
|
|
214
|
+
raise StageExportError(f"no audit JSON at {audit_path}; finish auditing this stage first")
|
|
215
|
+
try:
|
|
216
|
+
audit_data = json.loads(audit_path.read_text(encoding="utf-8"))
|
|
217
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
218
|
+
raise StageExportError(f"failed to read audit JSON {audit_path}: {exc}") from exc
|
|
219
|
+
|
|
220
|
+
shots = audit_shots_to_engine_shots(audit_data, beep_time_in_source=beep_time_in_source)
|
|
221
|
+
# Empty ``shots[]`` is permissive (#214): the user may want a trim-only
|
|
222
|
+
# export. CSV / overlay / shot-markers depend on shots; the trimmed
|
|
223
|
+
# clip + FCPXML spine do not. ``report.detect_anomalies`` already
|
|
224
|
+
# surfaces "No shots detected in the stage window" for this case so
|
|
225
|
+
# the audit trail stays clean.
|
|
226
|
+
|
|
227
|
+
exports_dir.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
base = f"stage{stage_data.stage_number}_{_slugify(stage_data.stage_name)}"
|
|
229
|
+
|
|
230
|
+
skip_reasons: list[str] = []
|
|
231
|
+
|
|
232
|
+
# Source-reachability is the most common reason an export degrades:
|
|
233
|
+
# the project stores symlinks to a USB drive that's not plugged in.
|
|
234
|
+
# Build one shared, specific message so the user gets the same hint
|
|
235
|
+
# regardless of which artefact tripped over the missing source.
|
|
236
|
+
source_missing = source_video_path is None or not source_video_path.exists()
|
|
237
|
+
missing_msg: str | None = None
|
|
238
|
+
if source_missing and (request.write_trim or request.write_fcpxml):
|
|
239
|
+
if source_video_path is None:
|
|
240
|
+
missing_msg = (
|
|
241
|
+
"source video is not registered for this stage; assign a "
|
|
242
|
+
"primary on the Ingest screen first."
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
missing_msg = (
|
|
246
|
+
f"source video not reachable: {source_video_path}. If it lives "
|
|
247
|
+
"on external storage (USB drive, SD card), reconnect and "
|
|
248
|
+
"re-run Generate. CSV and report still wrote -- they only "
|
|
249
|
+
"need the audit JSON."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Lossless trim into exports/. Always reference *this* file from FCPXML
|
|
253
|
+
# so SPA-produced output lines up with ``splitsmith single``. The
|
|
254
|
+
# audit-mode short-GOP file in <project>/trimmed/ is a scrub cache,
|
|
255
|
+
# not an export.
|
|
256
|
+
trimmed_path: Path | None = None
|
|
257
|
+
if request.write_trim:
|
|
258
|
+
if source_missing:
|
|
259
|
+
assert missing_msg is not None # populated above
|
|
260
|
+
skip_reasons.append(f"trim not written: {missing_msg}")
|
|
261
|
+
# If a prior run left a lossless trim, surface it -- the user
|
|
262
|
+
# has *something* to ship while the source is unreachable.
|
|
263
|
+
stale = exports_dir / f"{base}_trimmed.mp4"
|
|
264
|
+
if stale.exists():
|
|
265
|
+
trimmed_path = stale
|
|
266
|
+
else:
|
|
267
|
+
assert source_video_path is not None # narrowed by source_missing
|
|
268
|
+
trimmed_path = exports_dir / f"{base}_trimmed.mp4"
|
|
269
|
+
try:
|
|
270
|
+
trim.trim_video(
|
|
271
|
+
source_video_path,
|
|
272
|
+
trimmed_path,
|
|
273
|
+
beep_time=beep_time_in_source,
|
|
274
|
+
stage_time=stage_data.time_seconds,
|
|
275
|
+
pre_buffer_seconds=pre_buffer_seconds,
|
|
276
|
+
post_buffer_seconds=post_buffer_seconds,
|
|
277
|
+
mode="lossless",
|
|
278
|
+
overwrite=True,
|
|
279
|
+
)
|
|
280
|
+
except (trim.FFmpegError, FileNotFoundError, RuntimeError) as exc:
|
|
281
|
+
# Don't fail the whole export -- CSV / report are still
|
|
282
|
+
# useful even if ffmpeg blew up. Surface as anomaly.
|
|
283
|
+
skip_reasons.append(f"trim not written: {exc}")
|
|
284
|
+
trimmed_path = None
|
|
285
|
+
if (exports_dir / f"{base}_trimmed.mp4").exists():
|
|
286
|
+
# Stale artefact from a prior run -- still reference it
|
|
287
|
+
# from FCPXML so the user gets *something* usable.
|
|
288
|
+
trimmed_path = exports_dir / f"{base}_trimmed.mp4"
|
|
289
|
+
|
|
290
|
+
# Per-cam lossless trims (issue #54). Each secondary's trim lands at
|
|
291
|
+
# ``stage<N>_<slug>_cam_<video_id>_trimmed.mp4`` so its name mirrors the
|
|
292
|
+
# audit-mode cache slot in <project>/trimmed/. Skipped silently when
|
|
293
|
+
# ``write_trim`` is off (CSV-only re-run); per-cam ffmpeg failures are
|
|
294
|
+
# surfaced as anomalies so the FCPXML can still reference the cams that
|
|
295
|
+
# did make it. Stale prior-run trims are kept so an aborted re-run still
|
|
296
|
+
# ships a multi-cam timeline.
|
|
297
|
+
secondary_trimmed: dict[str, Path] = {}
|
|
298
|
+
secondary_inputs = list(secondaries or [])
|
|
299
|
+
if request.write_trim:
|
|
300
|
+
for sec in secondary_inputs:
|
|
301
|
+
sec_target = exports_dir / f"{base}_cam_{sec.video_id}_trimmed.mp4"
|
|
302
|
+
if not sec.source_path.exists():
|
|
303
|
+
skip_reasons.append(
|
|
304
|
+
f"secondary cam {sec.video_id} trim not written: source not "
|
|
305
|
+
f"reachable: {sec.source_path}"
|
|
306
|
+
)
|
|
307
|
+
if sec_target.exists():
|
|
308
|
+
secondary_trimmed[sec.video_id] = sec_target
|
|
309
|
+
continue
|
|
310
|
+
try:
|
|
311
|
+
trim.trim_video(
|
|
312
|
+
sec.source_path,
|
|
313
|
+
sec_target,
|
|
314
|
+
beep_time=sec.beep_time_in_source,
|
|
315
|
+
stage_time=stage_data.time_seconds,
|
|
316
|
+
pre_buffer_seconds=pre_buffer_seconds,
|
|
317
|
+
post_buffer_seconds=post_buffer_seconds,
|
|
318
|
+
mode="lossless",
|
|
319
|
+
overwrite=True,
|
|
320
|
+
)
|
|
321
|
+
secondary_trimmed[sec.video_id] = sec_target
|
|
322
|
+
except (trim.FFmpegError, FileNotFoundError, RuntimeError) as exc:
|
|
323
|
+
skip_reasons.append(f"secondary cam {sec.video_id} trim not written: {exc}")
|
|
324
|
+
if sec_target.exists():
|
|
325
|
+
secondary_trimmed[sec.video_id] = sec_target
|
|
326
|
+
else:
|
|
327
|
+
# When trim is off, surface stale per-cam trims so the FCPXML can
|
|
328
|
+
# still wire them up (mirrors the primary's stale-trim handling).
|
|
329
|
+
for sec in secondary_inputs:
|
|
330
|
+
sec_target = exports_dir / f"{base}_cam_{sec.video_id}_trimmed.mp4"
|
|
331
|
+
if sec_target.exists():
|
|
332
|
+
secondary_trimmed[sec.video_id] = sec_target
|
|
333
|
+
|
|
334
|
+
csv_path: Path | None = None
|
|
335
|
+
if request.write_csv:
|
|
336
|
+
if shots:
|
|
337
|
+
csv_path = exports_dir / f"{base}_splits.csv"
|
|
338
|
+
csv_gen.write_splits_csv(shots, csv_path)
|
|
339
|
+
else:
|
|
340
|
+
skip_reasons.append("csv not written: no shots audited")
|
|
341
|
+
|
|
342
|
+
# Overlay render (issue #45). Gated on having a trimmed clip to mirror;
|
|
343
|
+
# the overlay must match the trim frame-for-frame or it will drift on
|
|
344
|
+
# the FCP timeline. ``write_overlay`` defaults False so existing flows
|
|
345
|
+
# (and CSV-only re-runs without a source) don't pay the cost.
|
|
346
|
+
overlay_path: Path | None = None
|
|
347
|
+
fcp_overlay_path: Path | None = None
|
|
348
|
+
overlay_target = exports_dir / f"{base}_overlay.mov"
|
|
349
|
+
if request.write_overlay and not shots:
|
|
350
|
+
# Overlay annotates shot times; with no shots there's nothing to
|
|
351
|
+
# render. Surface as a skip reason so the user sees why the
|
|
352
|
+
# checkbox didn't produce output. (#214 / #217.)
|
|
353
|
+
skip_reasons.append("overlay not written: no shots audited")
|
|
354
|
+
elif request.write_overlay:
|
|
355
|
+
# Resolve the trim we'll mirror: prefer the one we just wrote, then
|
|
356
|
+
# a stale lossless trim from a prior run. If none exists -- e.g.
|
|
357
|
+
# source unreachable AND no prior trim -- skip with a clear reason.
|
|
358
|
+
mirror_target: Path | None = None
|
|
359
|
+
if trimmed_path is not None and trimmed_path.exists():
|
|
360
|
+
mirror_target = trimmed_path
|
|
361
|
+
elif (exports_dir / f"{base}_trimmed.mp4").exists():
|
|
362
|
+
mirror_target = exports_dir / f"{base}_trimmed.mp4"
|
|
363
|
+
|
|
364
|
+
if mirror_target is None:
|
|
365
|
+
if missing_msg:
|
|
366
|
+
skip_reasons.append(f"overlay not written: {missing_msg}")
|
|
367
|
+
else:
|
|
368
|
+
skip_reasons.append(
|
|
369
|
+
"overlay not written: no lossless trim in exports/. "
|
|
370
|
+
"Re-run Generate with the Trim toggle enabled."
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
try:
|
|
374
|
+
overlay_render.render_overlay(
|
|
375
|
+
audit_path=audit_path,
|
|
376
|
+
trimmed_video_path=mirror_target,
|
|
377
|
+
output_path=overlay_target,
|
|
378
|
+
beep_offset_seconds=pre_buffer_seconds,
|
|
379
|
+
codec=request.overlay_codec,
|
|
380
|
+
max_height=request.overlay_max_height,
|
|
381
|
+
max_fps=request.overlay_max_fps,
|
|
382
|
+
theme=request.overlay_theme,
|
|
383
|
+
)
|
|
384
|
+
overlay_path = overlay_target
|
|
385
|
+
except (overlay_render.OverlayRenderError, OSError) as exc:
|
|
386
|
+
skip_reasons.append(f"overlay not written: {exc}")
|
|
387
|
+
overlay_path = None
|
|
388
|
+
if overlay_target.exists():
|
|
389
|
+
# Stale render from a prior run; still surface so the
|
|
390
|
+
# FCPXML can reference it.
|
|
391
|
+
overlay_path = overlay_target
|
|
392
|
+
|
|
393
|
+
# Whether or not the overlay was just rendered, an existing
|
|
394
|
+
# ``<base>_overlay.mov`` should be referenced from the FCPXML so the
|
|
395
|
+
# same XML works regardless of which render produced it.
|
|
396
|
+
if overlay_target.exists():
|
|
397
|
+
fcp_overlay_path = overlay_target
|
|
398
|
+
|
|
399
|
+
fcpxml_path: Path | None = None
|
|
400
|
+
if request.write_fcpxml:
|
|
401
|
+
# FCPXML needs a video to reference. Prefer the lossless trim we
|
|
402
|
+
# just produced; fall back to a stale lossless trim from a prior
|
|
403
|
+
# export if it exists; only as a last resort look for a lossless
|
|
404
|
+
# trim independent of this run.
|
|
405
|
+
fcp_video: Path | None = None
|
|
406
|
+
candidate = exports_dir / f"{base}_trimmed.mp4"
|
|
407
|
+
if trimmed_path is not None and trimmed_path.exists():
|
|
408
|
+
fcp_video = trimmed_path
|
|
409
|
+
elif candidate.exists():
|
|
410
|
+
fcp_video = candidate
|
|
411
|
+
|
|
412
|
+
if fcp_video is None:
|
|
413
|
+
if missing_msg:
|
|
414
|
+
# The trim couldn't run because the source is unreachable.
|
|
415
|
+
# Surface that as the FCPXML reason too -- avoids the user
|
|
416
|
+
# chasing two separate "no lossless trim" messages.
|
|
417
|
+
skip_reasons.append(f"fcpxml not written: {missing_msg}")
|
|
418
|
+
else:
|
|
419
|
+
skip_reasons.append(
|
|
420
|
+
"fcpxml not written: no lossless trim in exports/. "
|
|
421
|
+
"Re-run Generate with the Trim toggle enabled."
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
fcpxml_path = exports_dir / f"{base}.fcpxml"
|
|
425
|
+
try:
|
|
426
|
+
meta = fcpxml_gen.probe_video(fcp_video)
|
|
427
|
+
# Multi-cam wiring (issue #54). Probe each surviving secondary
|
|
428
|
+
# trim and pass it as a connected clip; ffprobe failures only
|
|
429
|
+
# drop that cam from the timeline (other cams still ship).
|
|
430
|
+
fcp_secondaries: list[fcpxml_gen.SecondaryClip] = []
|
|
431
|
+
# Preserve the input order so cam lane assignments are stable
|
|
432
|
+
# across re-runs (the dict was built in input-order earlier).
|
|
433
|
+
for sec in secondary_inputs:
|
|
434
|
+
sec_path = secondary_trimmed.get(sec.video_id)
|
|
435
|
+
if sec_path is None or not sec_path.exists():
|
|
436
|
+
continue
|
|
437
|
+
try:
|
|
438
|
+
sec_meta = fcpxml_gen.probe_video(sec_path)
|
|
439
|
+
except fcpxml_gen.FFprobeError as exc:
|
|
440
|
+
skip_reasons.append(f"secondary cam {sec.video_id} dropped from FCPXML: {exc}")
|
|
441
|
+
continue
|
|
442
|
+
# Each cam was trimmed with the same pre-buffer as the
|
|
443
|
+
# primary, so its clip-local beep is at
|
|
444
|
+
# ``min(pre_buffer, beep_time_in_source)`` -- short heads
|
|
445
|
+
# truncate the pre-roll, in which case the cam's beep
|
|
446
|
+
# sits earlier in the file.
|
|
447
|
+
sec_beep_offset = min(pre_buffer_seconds, sec.beep_time_in_source)
|
|
448
|
+
fcp_secondaries.append(
|
|
449
|
+
fcpxml_gen.SecondaryClip(
|
|
450
|
+
video_path=sec_path,
|
|
451
|
+
video=sec_meta,
|
|
452
|
+
beep_offset_seconds=sec_beep_offset,
|
|
453
|
+
label=sec.label,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
# Beep offset within the lossless trim: the trim cut at
|
|
457
|
+
# ``beep_time - pre_buffer`` from source, so the beep lives
|
|
458
|
+
# ``pre_buffer`` seconds into the clip.
|
|
459
|
+
# Probe the overlay so the FCPXML can emit a dedicated
|
|
460
|
+
# ``<format>`` for it when its dims/fps differ from the
|
|
461
|
+
# primary. ffprobe failures fall back to "assume mirrors
|
|
462
|
+
# source" -- the overlay still ships, FCP just won't auto-
|
|
463
|
+
# scale it.
|
|
464
|
+
overlay_meta: fcpxml_gen.VideoMetadata | None = None
|
|
465
|
+
if fcp_overlay_path is not None:
|
|
466
|
+
try:
|
|
467
|
+
overlay_meta = fcpxml_gen.probe_video(fcp_overlay_path)
|
|
468
|
+
except fcpxml_gen.FFprobeError:
|
|
469
|
+
overlay_meta = None
|
|
470
|
+
fcpxml_gen.generate_fcpxml(
|
|
471
|
+
video_path=fcp_video,
|
|
472
|
+
video=meta,
|
|
473
|
+
shots=shots,
|
|
474
|
+
beep_offset_seconds=pre_buffer_seconds,
|
|
475
|
+
output_path=fcpxml_path,
|
|
476
|
+
project_name=base,
|
|
477
|
+
config=config.output,
|
|
478
|
+
overlay_path=fcp_overlay_path,
|
|
479
|
+
overlay_video=overlay_meta,
|
|
480
|
+
secondaries=fcp_secondaries or None,
|
|
481
|
+
)
|
|
482
|
+
except (fcpxml_gen.FFprobeError, OSError) as exc:
|
|
483
|
+
skip_reasons.append(f"fcpxml not written: {exc}")
|
|
484
|
+
fcpxml_path = None
|
|
485
|
+
|
|
486
|
+
anomalies = report.detect_anomalies(shots, beep_time_in_source, stage_data.time_seconds)
|
|
487
|
+
if skip_reasons:
|
|
488
|
+
anomalies = [*anomalies, *skip_reasons]
|
|
489
|
+
|
|
490
|
+
report_path: Path | None = None
|
|
491
|
+
if request.write_report:
|
|
492
|
+
files = ReportFiles(
|
|
493
|
+
video=trimmed_path if trimmed_path and trimmed_path.exists() else None,
|
|
494
|
+
csv=csv_path,
|
|
495
|
+
fcpxml=fcpxml_path,
|
|
496
|
+
)
|
|
497
|
+
analysis = StageAnalysis(
|
|
498
|
+
stage=stage_data,
|
|
499
|
+
video_path=trimmed_path or source_video_path or Path(),
|
|
500
|
+
beep_time=beep_time_in_source,
|
|
501
|
+
shots=shots,
|
|
502
|
+
anomalies=anomalies,
|
|
503
|
+
)
|
|
504
|
+
report_path = exports_dir / f"{base}_report.txt"
|
|
505
|
+
report.write_report(
|
|
506
|
+
analysis,
|
|
507
|
+
files,
|
|
508
|
+
report_path,
|
|
509
|
+
color_thresholds=config.output.split_color_thresholds,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
secondary_paths_present = {vid: p for vid, p in secondary_trimmed.items() if p.exists()}
|
|
513
|
+
return StageExportResult(
|
|
514
|
+
stage_number=stage_data.stage_number,
|
|
515
|
+
trimmed_video_path=(trimmed_path if trimmed_path is not None and trimmed_path.exists() else None),
|
|
516
|
+
csv_path=csv_path,
|
|
517
|
+
fcpxml_path=fcpxml_path,
|
|
518
|
+
report_path=report_path,
|
|
519
|
+
overlay_path=overlay_path if overlay_path is not None and overlay_path.exists() else None,
|
|
520
|
+
shots_written=len(shots),
|
|
521
|
+
anomalies=anomalies,
|
|
522
|
+
secondary_trimmed_paths=secondary_paths_present,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _slugify(name: str) -> str:
|
|
530
|
+
"""Filesystem-friendly slug. Mirrors the CLI's ``_slugify`` exactly so
|
|
531
|
+
exports have identical names whether produced via the CLI or the
|
|
532
|
+
production UI -- byte-comparable filenames are part of the AC."""
|
|
533
|
+
return _SLUG_RE.sub("-", name.lower()).strip("-") or "stage"
|