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,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"