davinci-resolve-mcp 2.68.1 → 2.69.0

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 (37) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +46 -3
  3. package/docs/SKILL.md +181 -1
  4. package/docs/install.md +27 -1
  5. package/install.py +26 -1
  6. package/package.json +1 -1
  7. package/resolve-advanced/LICENSE +21 -0
  8. package/resolve-advanced/package.json +2 -1
  9. package/resolve-advanced/vendor/conform-qc/package.json +1 -0
  10. package/resolve-advanced/vendor/drp-format/package.json +1 -0
  11. package/resolve-advanced/vendor/drt-format/package.json +1 -0
  12. package/scripts/bridge_differential.py +22 -4
  13. package/scripts/doctor.py +129 -3
  14. package/scripts/install_resolve_bridge.py +13 -3
  15. package/src/granular/common.py +1 -1
  16. package/src/server.py +639 -18
  17. package/src/utils/beat_detection.py +242 -0
  18. package/src/utils/bridge_differential.py +27 -1
  19. package/src/utils/broll_placement.py +201 -0
  20. package/src/utils/conform_lint.py +437 -0
  21. package/src/utils/edit_engine.py +633 -1
  22. package/src/utils/edit_handles.py +206 -0
  23. package/src/utils/edit_report.py +360 -0
  24. package/src/utils/first_impression.py +212 -0
  25. package/src/utils/prebalance.py +471 -0
  26. package/src/utils/project_journal.py +360 -0
  27. package/src/utils/reference_match.py +203 -0
  28. package/src/utils/rhythm_audit.py +252 -0
  29. package/src/utils/rule_of_six.py +217 -0
  30. package/src/utils/setup_sheet.py +121 -0
  31. package/src/utils/shot_assembly.py +259 -0
  32. package/src/utils/silence_ripple.py +89 -2
  33. package/src/utils/sound_density.py +253 -0
  34. package/src/utils/split_edits.py +180 -0
  35. package/src/utils/take_ranking.py +206 -0
  36. package/src/utils/transcript_edit.py +101 -5
  37. 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
+ }
@@ -182,12 +182,22 @@ def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
182
182
  #: MediaPoolItem or Graph — which between them carry most of the read surface
183
183
  #: worth qualifying (LUTs, node graphs, source timings, metadata).
184
184
  _INDEX = "[]"
185
+ #: Pick the first element of a returned list for which `method()` is truthy.
186
+ #: Indexing blindly is not good enough: a timeline's first video item is often a
187
+ #: generator or a title, which has no MediaPoolItem and answers None to every
188
+ #: node-graph question. Measured on a real timeline — `GetNumNodes` and
189
+ #: `GetNodeGraph` both None on an SMPTE Color Bar, and 1 and a live graph on the
190
+ #: clip sitting next to it. Taking index 0 therefore reported the whole grading
191
+ #: surface as unreachable, which is the silent coverage gap this module exists to
192
+ #: refuse.
193
+ _FIRST_WITH = "[first-with]"
185
194
 
186
195
  _PM = ("GetProjectManager", ())
187
196
  _PROJECT = (_PM, ("GetCurrentProject", ()))
188
197
  _POOL = _PROJECT + (("GetMediaPool", ()),)
189
198
  _TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
190
- _TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)), (_INDEX, (0,)))
199
+ _TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
200
+ (_FIRST_WITH, ("GetMediaPoolItem",)))
191
201
  _GALLERY = _PROJECT + (("GetGallery", ()),)
192
202
 
193
203
  OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
@@ -229,6 +239,22 @@ def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
229
239
  return None
230
240
  current = current[index]
231
241
  continue
242
+ if method == _FIRST_WITH:
243
+ if not isinstance(current, (list, tuple)):
244
+ return None
245
+ probe = args[0]
246
+ chosen = None
247
+ for candidate in current:
248
+ try:
249
+ if getattr(candidate, probe, lambda: None)():
250
+ chosen = candidate
251
+ break
252
+ except Exception: # noqa: BLE001 - an unusable candidate, not a verdict
253
+ continue
254
+ if chosen is None:
255
+ return None
256
+ current = chosen
257
+ continue
232
258
  function = getattr(current, method, None)
233
259
  if function is None:
234
260
  return None
@@ -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
+ }