davinci-resolve-mcp 2.68.2 → 2.69.1

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.
@@ -0,0 +1,242 @@
1
+ """Beat detection for music-driven cutting.
2
+
3
+ The single most-requested missing capability from the first public audience for
4
+ this project. Music-video makers, live-music channels and motorsport editors all
5
+ asked variants of the same thing, and one put the failure precisely: the tools
6
+ here only cut interviews, they do not cut to music. A viewer editing drift
7
+ footage predicted the exact consequence of pointing a speech-silence gate at his
8
+ material — engine noise and smoke read as "dead space", and the tool cuts in all
9
+ the wrong places.
10
+
11
+ They are right, and the reason is structural. Everything else in this codebase
12
+ finds edit points in *speech*: word boundaries, pauses, fillers. Music has none
13
+ of those. It has pulse, and pulse is a completely different measurement.
14
+
15
+ ## What this gives you
16
+
17
+ - **Beats** — the pulse. Cutting on every beat is relentless and usually wrong.
18
+ - **Phrases** — beats grouped into bars and bars into phrases (typically 4 or 8
19
+ bars). Music resolves at phrase boundaries, which is where an edit feels
20
+ *inevitable* rather than merely synchronised. This is the one that matters.
21
+ - **Frame-snapped cut points**, because a cut two frames off the beat reads as a
22
+ mistake rather than as syncopation.
23
+
24
+ ## Honest limits
25
+
26
+ Tempo tracking is reliable on steady, percussive material and degrades on rubato,
27
+ live performance without a strong downbeat, and anything heavily swung. The
28
+ detector reports its own confidence and this module surfaces it rather than
29
+ presenting every result as equally solid.
30
+
31
+ Downbeats are **inferred**, not detected: the first beat is assumed to start a
32
+ bar. That assumption is wrong whenever a track has a pickup, and it is the first
33
+ thing to check if the cuts feel consistently one beat early. `beat_offset` exists
34
+ to correct it.
35
+
36
+ ## Dependency
37
+
38
+ `librosa` (ISC) is an **optional extra** — it is not in `requirements.txt`, it
39
+ pulls a large scientific stack, and this module honest-refuses without it rather
40
+ than degrading into a guess. Install with `pip install librosa`.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import importlib.util
46
+ from typing import Any, Dict, List, Sequence
47
+
48
+ #: Beats per bar. 4/4 covers the overwhelming majority of cuttable music; the
49
+ #: parameter exists because 3/4 and 6/8 do turn up and silently forcing them
50
+ #: into 4 produces phrase boundaries that land in musically wrong places.
51
+ DEFAULT_BEATS_PER_BAR = 4
52
+ #: Bars per phrase. 8 bars is the common pop/dance period; 4 suits shorter cuts.
53
+ DEFAULT_BARS_PER_PHRASE = 8
54
+
55
+
56
+ def librosa_available() -> bool:
57
+ """Presence check without importing — librosa costs seconds to import."""
58
+ try:
59
+ return importlib.util.find_spec("librosa") is not None
60
+ except (ImportError, ValueError):
61
+ return False
62
+
63
+
64
+ def detect_beats(media_path: str, *, sample_rate: int = 22050) -> Dict[str, Any]:
65
+ """Tempo and beat times for an audio or video file.
66
+
67
+ Honest-refuses when librosa is absent. A fabricated tempo would produce cuts
68
+ that are confidently, rhythmically wrong — worse than no feature, because
69
+ the failure looks intentional.
70
+ """
71
+ if not librosa_available():
72
+ return {
73
+ "success": False,
74
+ "error": "librosa is not installed — beat detection is unavailable",
75
+ "remediation": (
76
+ "pip install librosa (ISC licensed, optional extra; it pulls "
77
+ "numpy/scipy/numba and is deliberately not a core dependency)"
78
+ ),
79
+ }
80
+ import librosa
81
+
82
+ try:
83
+ y, sr = librosa.load(media_path, sr=sample_rate, mono=True)
84
+ except Exception as exc:
85
+ return {"success": False, "error": f"could not read audio from {media_path}: {exc}"}
86
+ if y is None or len(y) == 0:
87
+ return {"success": False, "error": "no audio samples decoded — is there an audio track?"}
88
+
89
+ try:
90
+ tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units="frames")
91
+ beat_times = librosa.frames_to_time(beat_frames, sr=sr)
92
+ onset_env = librosa.onset.onset_strength(y=y, sr=sr)
93
+ except Exception as exc:
94
+ return {"success": False, "error": f"beat tracking failed: {exc}"}
95
+
96
+ beats = [round(float(t), 4) for t in beat_times]
97
+ tempo_value = float(tempo if not hasattr(tempo, "__len__") else (tempo[0] if len(tempo) else 0.0))
98
+ return {
99
+ "success": True,
100
+ "tempo_bpm": round(tempo_value, 2),
101
+ "beat_count": len(beats),
102
+ "beats": beats,
103
+ "duration_seconds": round(float(len(y)) / sr, 3),
104
+ "confidence": _confidence(beats, onset_env),
105
+ }
106
+
107
+
108
+ def _confidence(beats: Sequence[float], onset_env) -> Dict[str, Any]:
109
+ """How regular the detected pulse is.
110
+
111
+ A steady grid means the tracker locked on. Wildly varying intervals mean it
112
+ did not, and the caller should be told rather than handed a tidy-looking
113
+ list of times that happens to be wrong.
114
+ """
115
+ if len(beats) < 4:
116
+ return {"band": "low", "reason": "too few beats detected to judge regularity"}
117
+ intervals = [b - a for a, b in zip(beats, beats[1:])]
118
+ mean = sum(intervals) / len(intervals)
119
+ if mean <= 0:
120
+ return {"band": "low", "reason": "degenerate beat spacing"}
121
+ variance = sum((i - mean) ** 2 for i in intervals) / len(intervals)
122
+ jitter = (variance ** 0.5) / mean
123
+ if jitter < 0.05:
124
+ band, reason = "high", "beat spacing is steady — the tracker locked on"
125
+ elif jitter < 0.15:
126
+ band, reason = "medium", "some drift in beat spacing; check a few cuts against the music"
127
+ else:
128
+ band, reason = "low", (
129
+ "beat spacing is irregular — rubato, live playing or a weak downbeat. "
130
+ "Treat these cut points as suggestions"
131
+ )
132
+ return {"band": band, "jitter": round(jitter, 4), "reason": reason}
133
+
134
+
135
+ def group_into_phrases(
136
+ beats: Sequence[float],
137
+ *,
138
+ beats_per_bar: int = DEFAULT_BEATS_PER_BAR,
139
+ bars_per_phrase: int = DEFAULT_BARS_PER_PHRASE,
140
+ beat_offset: int = 0,
141
+ ) -> Dict[str, Any]:
142
+ """Group beats into bars and phrases. Pure — no librosa needed.
143
+
144
+ `beat_offset` shifts which beat is treated as the first of a bar. Downbeats
145
+ are inferred rather than detected, so a track with a pickup will be off by
146
+ one until this is set; that is the first knob to reach for when cuts feel
147
+ consistently early.
148
+ """
149
+ if beats_per_bar < 1 or bars_per_phrase < 1:
150
+ raise ValueError("beats_per_bar and bars_per_phrase must be >= 1")
151
+ ordered = sorted(float(b) for b in beats)
152
+ per_phrase = beats_per_bar * bars_per_phrase
153
+ downbeats: List[float] = []
154
+ phrase_starts: List[float] = []
155
+ for index, time in enumerate(ordered):
156
+ position = index - beat_offset
157
+ if position < 0:
158
+ continue
159
+ if position % beats_per_bar == 0:
160
+ downbeats.append(round(time, 4))
161
+ if position % per_phrase == 0:
162
+ phrase_starts.append(round(time, 4))
163
+ return {
164
+ "beats_per_bar": beats_per_bar,
165
+ "bars_per_phrase": bars_per_phrase,
166
+ "beat_offset": beat_offset,
167
+ "downbeats": downbeats,
168
+ "phrase_starts": phrase_starts,
169
+ "bar_count": len(downbeats),
170
+ "phrase_count": len(phrase_starts),
171
+ "note": (
172
+ "Downbeats are INFERRED — the first beat is assumed to start a bar. "
173
+ "A track with a pickup will be off by one; correct it with beat_offset."
174
+ ),
175
+ }
176
+
177
+
178
+ def snap_to_frames(times: Sequence[float], fps: float) -> List[int]:
179
+ """Times to frame numbers. A cut two frames off the beat reads as a mistake."""
180
+ if fps <= 0:
181
+ raise ValueError("fps must be positive")
182
+ return [int(round(float(t) * fps)) for t in times]
183
+
184
+
185
+ def plan_beat_cuts(
186
+ beats: Sequence[float],
187
+ *,
188
+ fps: float,
189
+ mode: str = "phrase",
190
+ beats_per_bar: int = DEFAULT_BEATS_PER_BAR,
191
+ bars_per_phrase: int = DEFAULT_BARS_PER_PHRASE,
192
+ beat_offset: int = 0,
193
+ min_shot_seconds: float = 0.0,
194
+ ) -> Dict[str, Any]:
195
+ """Cut points on the beat, the bar, or the phrase. Pure and testable.
196
+
197
+ `mode`:
198
+ - `beat` — every beat. Relentless; suits montage stings, little else.
199
+ - `bar` — every downbeat.
200
+ - `phrase` — every phrase start (**default**). Music resolves here, which
201
+ is what makes a cut feel inevitable rather than merely synchronised.
202
+ """
203
+ if mode not in ("beat", "bar", "phrase"):
204
+ raise ValueError(f"unknown mode {mode!r}; expected beat, bar or phrase")
205
+ grouped = group_into_phrases(
206
+ beats, beats_per_bar=beats_per_bar,
207
+ bars_per_phrase=bars_per_phrase, beat_offset=beat_offset,
208
+ )
209
+ times = {
210
+ "beat": sorted(float(b) for b in beats),
211
+ "bar": grouped["downbeats"],
212
+ "phrase": grouped["phrase_starts"],
213
+ }[mode]
214
+
215
+ dropped = 0
216
+ if min_shot_seconds > 0 and times:
217
+ kept = [times[0]]
218
+ for time in times[1:]:
219
+ if time - kept[-1] >= min_shot_seconds:
220
+ kept.append(time)
221
+ else:
222
+ dropped += 1
223
+ times = kept
224
+
225
+ return {
226
+ "success": True,
227
+ "kind": "beat_cuts",
228
+ "mode": mode,
229
+ "cut_count": len(times),
230
+ "cut_times": [round(t, 4) for t in times],
231
+ "cut_frames": snap_to_frames(times, fps),
232
+ "grouping": grouped,
233
+ "dropped_for_min_shot": dropped,
234
+ "note": (
235
+ f"Cut points on every {mode}. "
236
+ + (f"{dropped} were dropped to honour min_shot_seconds={min_shot_seconds}. "
237
+ if dropped else "")
238
+ + "Frame-snapped: a cut two frames off the beat reads as a mistake rather "
239
+ "than syncopation. These are cut POINTS, not an assembly — what goes "
240
+ "between them is yours."
241
+ ),
242
+ }
@@ -61,6 +61,28 @@ VOLATILE_METHODS = frozenset({
61
61
  "IsPlayheadOnVideoFrame",
62
62
  })
63
63
 
64
+ #: App-global state that **the harness's own probes move**, which is a different
65
+ #: problem from `VOLATILE_METHODS` drifting on its own.
66
+ #:
67
+ #: Measured on Studio 19.1.3.7: the render probes navigate Resolve to the Deliver
68
+ #: page, and the bridge pass runs before the native pass — so the native pass
69
+ #: reads a page the bridge pass changed. That produced a `value_mismatch` on
70
+ #: `GetCurrentPage` (bridge `edit`, native `deliver`) on every run that did not
71
+ #: happen to start on Deliver, and none on the runs that did. Read at the same
72
+ #: instant the two transports always agreed.
73
+ #:
74
+ #: Excusing it unconditionally would throw away a real signal — a bridge that
75
+ #: genuinely reported the wrong page is exactly the bug this harness is for. So
76
+ #: each pass records the page on the way in and on the way out, and the value is
77
+ #: only excused when a pass proves it did not hold still. If both passes were
78
+ #: stable and the values still disagree, that is the transport and it is reported.
79
+ ORDER_SENSITIVE_METHODS = frozenset({"GetCurrentPage"})
80
+
81
+ #: Key `run_probes` adds for `compare`'s own use rather than as a probe result.
82
+ #: Underscore-prefixed so the diff skips it instead of reading it as a method
83
+ #: that one transport has and the other does not.
84
+ PASS_META_KEY = "_pass_meta"
85
+
64
86
  #: Never invoked by the harness, whatever the enumeration says. These either
65
87
  #: change the project, touch the filesystem, or block on UI. The write paths are
66
88
  #: exercised by named scenarios instead, where the setup and teardown are
@@ -326,6 +348,19 @@ def probe_one(target: Any, method: str) -> Dict[str, Any]:
326
348
  return {"outcome": "returned", "value": normalise(value), "type": describe(value)}
327
349
 
328
350
 
351
+ def _current_page(resolve: Any) -> Optional[str]:
352
+ """The page, or None if it cannot be read.
353
+
354
+ Never raises: this is bookkeeping around the probes, and a failure here must
355
+ not take down a pass that is otherwise fine.
356
+ """
357
+ try:
358
+ page = resolve.GetCurrentPage()
359
+ except Exception: # noqa: BLE001 - an unreadable page is simply unknown
360
+ return None
361
+ return page if isinstance(page, str) else None
362
+
363
+
329
364
  def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]]:
330
365
  """Every enumerated safe method, against every object that has it.
331
366
 
@@ -333,6 +368,7 @@ def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]
333
368
  not merely that they did.
334
369
  """
335
370
  results: Dict[str, Dict[str, Any]] = {}
371
+ page_before = _current_page(resolve)
336
372
  for label, chain in OBJECT_GRAPH:
337
373
  target = walk(resolve, chain)
338
374
  if target is None:
@@ -342,13 +378,28 @@ def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]
342
378
  if getattr(target, method, None) is None:
343
379
  continue # not on this object; absence is reported by the diff
344
380
  results[f"{label}.{method}"] = probe_one(target, method)
381
+ page_after = _current_page(resolve)
382
+ results[PASS_META_KEY] = {
383
+ "page_before": page_before,
384
+ "page_after": page_after,
385
+ # None on either side means we could not tell, which must not read as
386
+ # "stable" — an unknown has to disable the value comparison, not enable it.
387
+ "page_stable": page_before is not None and page_before == page_after,
388
+ }
345
389
  return results
346
390
 
347
391
 
348
392
  def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
349
393
  """Diff two probe runs. Empty `differences` is the pass condition."""
350
394
  differences: List[Dict[str, Any]] = []
395
+ # A pass that moved the page cannot have its page compared by value against
396
+ # another pass taken at a different moment. Both must have held still.
397
+ native_meta = native.get(PASS_META_KEY) or {}
398
+ bridge_meta = bridged.get(PASS_META_KEY) or {}
399
+ page_held_still = bool(native_meta.get("page_stable")) and bool(bridge_meta.get("page_stable"))
351
400
  for key in sorted(set(native) | set(bridged)):
401
+ if key.startswith("_"):
402
+ continue # harness bookkeeping, not a probe result
352
403
  left, right = native.get(key), bridged.get(key)
353
404
  if left is None or right is None:
354
405
  differences.append({
@@ -364,17 +415,32 @@ def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]
364
415
  differences.append({"key": key, "kind": "volatile_type_mismatch",
365
416
  "native": left.get("type"), "bridge": right.get("type")})
366
417
  continue
418
+ if method in ORDER_SENSITIVE_METHODS and not page_held_still:
419
+ # The harness moved it mid-run, so the two readings are of different
420
+ # moments. Still hold the transport to returning the same kind of
421
+ # thing, and say why the value was not compared.
422
+ if left.get("type") != right.get("type") or left["outcome"] != right["outcome"]:
423
+ differences.append({"key": key, "kind": "order_sensitive_type_mismatch",
424
+ "native": left.get("type"), "bridge": right.get("type")})
425
+ continue
367
426
  if left != right:
368
427
  differences.append({"key": key, "kind": "value_mismatch",
369
428
  "native": left, "bridge": right})
370
- agreed = len(set(native) & set(bridged)) - len(
429
+ probe_keys = {k for k in set(native) | set(bridged) if not k.startswith("_")}
430
+ agreed = len({k for k in set(native) & set(bridged) if not k.startswith("_")}) - len(
371
431
  [d for d in differences if d["kind"] != "only_one_transport_reached_it"]
372
432
  )
373
433
  return {
374
- "compared": len(set(native) | set(bridged)),
434
+ "compared": len(probe_keys),
375
435
  "agreed": agreed,
376
436
  "differences": differences,
377
437
  "identical": not differences,
438
+ # Reported either way. "The page was not compared" is a fact about the
439
+ # run that a reader needs, and burying it would recreate the silence
440
+ # this fix exists to end.
441
+ "page_compared_by_value": page_held_still,
442
+ "page_native": {k: native_meta.get(k) for k in ("page_before", "page_after")},
443
+ "page_bridge": {k: bridge_meta.get(k) for k in ("page_before", "page_after")},
378
444
  }
379
445
 
380
446
 
@@ -0,0 +1,201 @@
1
+ """Placing B-roll against an A-roll spine.
2
+
3
+ Asked for repeatedly, and the clearest version came from someone making recipe
4
+ videos: *could it find the one second where I am putting the spoon in my mouth?*
5
+ That is a `find_similar` query against visual strata pointed at a specific
6
+ moment in a voiceover, and the machinery for both halves already exists — it has
7
+ simply never been joined up.
8
+
9
+ ## The shape of the problem
10
+
11
+ An A-roll spine (a voiceover or a talking head) has **beats**: sentences,
12
+ clauses, topic shifts. B-roll goes over some of them. Which B-roll goes where is
13
+ a content question, answered by whatever similarity the caller supplies; this
14
+ module answers the *placement* question, which is different and mechanical:
15
+
16
+ - **Where can B-roll go at all** — over a beat, not across one. Cutting away
17
+ mid-sentence and back is a specific effect, not a default.
18
+ - **How long can it stay** — bounded by the beat, and by how long the candidate
19
+ actually is.
20
+ - **What must not happen** — no gaps left uncovered that the caller thought were
21
+ covered, and no B-roll placed over a moment where the speaker is on camera
22
+ making a point the audience needs to see.
23
+
24
+ ## The judgement this refuses to make
25
+
26
+ Whether a shot *illustrates* a line is not measurable here. The caller supplies
27
+ candidates with a relevance score from whatever matched them — semantic
28
+ similarity, a keyword hit, a human's choice — and this module places them and
29
+ reports the score it was given. **It does not invent relevance and it does not
30
+ re-rank on anything it cannot see.** A tool that quietly reorders a human's
31
+ choices by its own opinion of relevance is worse than one that places them in
32
+ the order it was handed.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any, Dict, List, Mapping, Sequence
38
+
39
+ #: A B-roll shot shorter than this reads as a flash rather than a cutaway.
40
+ MIN_BROLL_SECONDS = 0.8
41
+
42
+ #: Leave this much of a beat uncovered at each end so the cutaway sits inside
43
+ #: the thought rather than straddling its edges.
44
+ BEAT_INSET_SECONDS = 0.15
45
+
46
+ #: Below this the caller's own matcher was not confident, and placing it anyway
47
+ #: would launder a weak match into a decision.
48
+ MIN_RELEVANCE = 0.35
49
+
50
+
51
+ def placeable_beats(
52
+ beats: Sequence[Mapping[str, Any]],
53
+ *,
54
+ min_seconds: float = MIN_BROLL_SECONDS,
55
+ inset: float = BEAT_INSET_SECONDS,
56
+ ) -> Dict[str, Any]:
57
+ """Windows inside the A-roll where a cutaway can sit without straddling a beat.
58
+
59
+ Each beat: `{"start_seconds", "end_seconds", "text"?, "protected"?}`.
60
+ A beat marked `protected` is never covered — that is how a caller says "the
61
+ speaker is making a point here and the audience needs to see them say it".
62
+ """
63
+ windows: List[Dict[str, Any]] = []
64
+ skipped: List[Dict[str, Any]] = []
65
+ for index, beat in enumerate(beats):
66
+ start = float(beat.get("start_seconds", 0)) + inset
67
+ end = float(beat.get("end_seconds", 0)) - inset
68
+ available = end - start
69
+ if beat.get("protected"):
70
+ skipped.append({
71
+ "beat_index": index,
72
+ "text": beat.get("text"),
73
+ "reason": "protected — the caller marked this as must-see on camera",
74
+ })
75
+ continue
76
+ if available < min_seconds:
77
+ skipped.append({
78
+ "beat_index": index,
79
+ "text": beat.get("text"),
80
+ "reason": f"only {max(0.0, available):.2f}s of usable window — under the "
81
+ f"{min_seconds}s floor, a cutaway here would read as a flash",
82
+ })
83
+ continue
84
+ windows.append({
85
+ "beat_index": index,
86
+ "start_seconds": round(start, 3),
87
+ "end_seconds": round(end, 3),
88
+ "available_seconds": round(available, 3),
89
+ "text": beat.get("text"),
90
+ })
91
+ return {"windows": windows, "skipped": skipped}
92
+
93
+
94
+ def place(
95
+ beats: Sequence[Mapping[str, Any]],
96
+ candidates: Sequence[Mapping[str, Any]],
97
+ *,
98
+ min_relevance: float = MIN_RELEVANCE,
99
+ min_seconds: float = MIN_BROLL_SECONDS,
100
+ allow_reuse: bool = False,
101
+ ) -> Dict[str, Any]:
102
+ """Place B-roll candidates into A-roll windows, in the order supplied.
103
+
104
+ Each candidate: `{"name", "duration_seconds", "relevance"?, "beat_index"?}`.
105
+ A candidate carrying `beat_index` is a caller's explicit choice and is placed
106
+ there or not at all — never silently moved somewhere it fits better.
107
+ """
108
+ if not beats:
109
+ return {"success": False, "error": "No A-roll beats supplied"}
110
+ if not candidates:
111
+ return {"success": False, "error": "No B-roll candidates supplied"}
112
+
113
+ available = placeable_beats(beats, min_seconds=min_seconds)
114
+ windows = {w["beat_index"]: w for w in available["windows"]}
115
+ used_windows: set = set()
116
+ used_clips: set = set()
117
+
118
+ placements: List[Dict[str, Any]] = []
119
+ unplaced: List[Dict[str, Any]] = []
120
+
121
+ for candidate in candidates:
122
+ name = candidate.get("name")
123
+ relevance = candidate.get("relevance")
124
+ if relevance is not None and float(relevance) < min_relevance:
125
+ unplaced.append({
126
+ "clip": name,
127
+ "reason": f"relevance {float(relevance):.2f} is below {min_relevance} — "
128
+ "the matcher that produced it was not confident, and placing "
129
+ "it anyway would launder a weak match into a decision",
130
+ })
131
+ continue
132
+ if not allow_reuse and name in used_clips:
133
+ unplaced.append({"clip": name, "reason": "already placed; allow_reuse is off"})
134
+ continue
135
+
136
+ requested = candidate.get("beat_index")
137
+ if requested is not None:
138
+ window = windows.get(int(requested))
139
+ if window is None:
140
+ unplaced.append({
141
+ "clip": name,
142
+ "reason": f"beat {requested} is not placeable (protected, too short, "
143
+ "or out of range) — an explicit choice is placed there or "
144
+ "not at all, never moved somewhere it fits better",
145
+ })
146
+ continue
147
+ if int(requested) in used_windows:
148
+ unplaced.append({"clip": name, "reason": f"beat {requested} already covered"})
149
+ continue
150
+ target = window
151
+ else:
152
+ free = [w for i, w in sorted(windows.items()) if i not in used_windows]
153
+ if not free:
154
+ unplaced.append({"clip": name, "reason": "no uncovered window left"})
155
+ continue
156
+ target = free[0]
157
+
158
+ duration = float(candidate.get("duration_seconds") or 0.0)
159
+ if duration < min_seconds:
160
+ unplaced.append({
161
+ "clip": name,
162
+ "reason": f"{duration:.2f}s of media — under the {min_seconds}s floor",
163
+ })
164
+ continue
165
+ length = min(duration, target["available_seconds"])
166
+ used_windows.add(target["beat_index"])
167
+ used_clips.add(name)
168
+ placements.append({
169
+ "clip": name,
170
+ "beat_index": target["beat_index"],
171
+ "over_text": target["text"],
172
+ "start_seconds": target["start_seconds"],
173
+ "end_seconds": round(target["start_seconds"] + length, 3),
174
+ "length_seconds": round(length, 3),
175
+ "trimmed": length < duration,
176
+ "relevance": float(relevance) if relevance is not None else None,
177
+ "relevance_source": (
178
+ "supplied by the caller's matcher; not re-scored here"
179
+ if relevance is not None else "not supplied"
180
+ ),
181
+ })
182
+
183
+ uncovered = [w for i, w in sorted(windows.items()) if i not in used_windows]
184
+ return {
185
+ "success": True,
186
+ "kind": "broll_placement",
187
+ "placement_count": len(placements),
188
+ "placements": placements,
189
+ "unplaced": unplaced,
190
+ "uncovered_windows": uncovered,
191
+ "unusable_beats": available["skipped"],
192
+ "note": (
193
+ f"{len(placements)} cutaways placed over {len(windows)} usable windows; "
194
+ f"{len(uncovered)} windows left uncovered and "
195
+ f"{len(available['skipped'])} beats were not usable at all. Placement is "
196
+ "mechanical — **relevance is whatever your matcher said it was and is not "
197
+ "re-scored here**, because whether a shot illustrates a line is not "
198
+ "something this can see. Cutaways sit inside a beat rather than straddling "
199
+ "one; protected beats are never covered. Nothing is cut."
200
+ ),
201
+ }