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.
- package/CHANGELOG.md +184 -0
- package/README.md +46 -3
- package/docs/SKILL.md +181 -1
- package/docs/install.md +27 -1
- package/install.py +26 -1
- package/package.json +1 -1
- package/resolve-advanced/LICENSE +21 -0
- package/resolve-advanced/package.json +2 -1
- package/resolve-advanced/vendor/conform-qc/package.json +1 -0
- package/resolve-advanced/vendor/drp-format/package.json +1 -0
- package/resolve-advanced/vendor/drt-format/package.json +1 -0
- package/scripts/doctor.py +129 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +522 -3
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +68 -2
- package/src/utils/broll_placement.py +201 -0
- package/src/utils/conform_lint.py +437 -0
- package/src/utils/edit_engine.py +633 -1
- package/src/utils/edit_handles.py +206 -0
- package/src/utils/edit_report.py +360 -0
- package/src/utils/first_impression.py +212 -0
- package/src/utils/prebalance.py +471 -0
- package/src/utils/project_journal.py +360 -0
- package/src/utils/reference_match.py +203 -0
- package/src/utils/rhythm_audit.py +252 -0
- package/src/utils/rule_of_six.py +217 -0
- package/src/utils/setup_sheet.py +121 -0
- package/src/utils/shot_assembly.py +259 -0
- package/src/utils/silence_ripple.py +89 -2
- package/src/utils/sound_density.py +253 -0
- package/src/utils/split_edits.py +180 -0
- package/src/utils/take_ranking.py +206 -0
- package/src/utils/transcript_edit.py +101 -5
- package/src/utils/turnover.py +187 -0
|
@@ -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
|
-
|
|
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(
|
|
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
|
+
}
|