davinci-resolve-mcp 2.67.0 → 2.68.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.
@@ -47,6 +47,40 @@ Both omissions follow the module's core rule:
47
47
  Asserting a field the render never controlled produces QC failures that say
48
48
  nothing about the deliverable — so unset fields are absent from both projections.
49
49
 
50
+ ## Loudness is a fourth projection, not a QC-spec field
51
+
52
+ `deliverable_qc` asserts what the *render* pinned. Program loudness is not one of
53
+ those things — the mix sets it, not `SetRenderSettings` — so a loudness contract
54
+ would violate the rule above if it lived in `to_qc_spec()`. It is a separate
55
+ projection, `to_loudness_target()`, feeding the `loudness_qc` action, whose
56
+ target vocabulary is `{integrated, integratedTol, truePeakMax, lraMax}`.
57
+
58
+ A loudness failure therefore means "the mix is wrong", never "the render settings
59
+ are wrong", and the two never get confused in one verdict.
60
+
61
+ **Dialogue-gated standards emit no gradeable `integrated` value.** `loudness_qc`
62
+ measures full-program via ebur128; a dialogue-gated spec measures only speech.
63
+ Grading one against the other produces a verdict that says nothing reliable, so
64
+ the integrated number is carried in `meta` for a human or a dialogue-gated meter
65
+ and the emitted target asserts only true peak and LRA — which *are* valid
66
+ full-program. That is the same rule as above: assert only what is actually
67
+ verifiable.
68
+
69
+ **No per-target custom numbers yet.** A target names a standard; it cannot carry
70
+ a bespoke integrated/tolerance pair (a show bible asking for −24 LKFS ±1 rather
71
+ than ATSC's ±2). Free-text loudness on the authored side is already handled by
72
+ the advanced server's `spec_from_authored`. If bespoke numbers are needed here,
73
+ the standards table is what should grow — not five more fields on every target.
74
+
75
+ ## No shipped target names a loudness standard by default
76
+
77
+ A ProRes master has no inherent program loudness, and even a broadcast handoff
78
+ depends on territory (EBU vs ATSC). Guessing one would assert a number the
79
+ deliverable never promised. `loudness_standard` is therefore unset everywhere in
80
+ the shipped table and is named explicitly per call, per user override, or per
81
+ project. The value here is that the *numbers* are correct and named when someone
82
+ does ask for them, instead of being invented at the call site.
83
+
50
84
  ## Not every target has a QC projection
51
85
 
52
86
  Image-sequence targets render *many* files and package targets (IMF, DCP) render
@@ -104,6 +138,94 @@ TIERS = ("master", "web", "sequence", "broadcast", "package")
104
138
  VERIFIED_ON = "DaVinci Resolve Studio 19.1.3.7 (2026-07-27, 20 formats / 271 pairs)"
105
139
 
106
140
 
141
+ # ── Loudness standards ──────────────────────────────────────────────────────
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class LoudnessStandard:
146
+ """One named program-loudness contract, projected onto `loudness_qc`.
147
+
148
+ `integrated` / `tolerance_lu` are the integrated-loudness window;
149
+ `true_peak_max_dbtp` is a ceiling. `lra_max` is None for every standard
150
+ shipped here — none of them mandate a loudness-range cap, and asserting one
151
+ would invent a failure the spec never asked for. A wide LRA is surfaced as
152
+ advice by the projection instead.
153
+
154
+ `dialogue_gated` marks a standard whose integrated figure is measured over
155
+ dialogue only. Those emit no gradeable integrated value — see the module
156
+ docstring.
157
+ """
158
+
159
+ id: str
160
+ label: str
161
+ integrated: float
162
+ tolerance_lu: float
163
+ true_peak_max_dbtp: float
164
+ lra_max: Optional[float] = None
165
+ dialogue_gated: bool = False
166
+ source: str = ""
167
+
168
+
169
+ #: Advisory threshold. Above this, a mix is unusually dynamic for delivery and
170
+ #: worth a human look — but no standard here makes it a failure, so it never is.
171
+ LRA_ADVISORY_LU = 20.0
172
+
173
+ LOUDNESS_STANDARDS: Dict[str, LoudnessStandard] = {
174
+ standard.id: standard
175
+ for standard in (
176
+ LoudnessStandard(
177
+ id="web",
178
+ label="Web / social",
179
+ integrated=-16.0,
180
+ tolerance_lu=2.0,
181
+ true_peak_max_dbtp=-1.0,
182
+ source="Common streaming/social practice: platforms normalise around -16 LUFS, true peak <= -1 dBTP.",
183
+ ),
184
+ LoudnessStandard(
185
+ id="podcast",
186
+ label="Podcast (stereo)",
187
+ integrated=-16.0,
188
+ tolerance_lu=1.5,
189
+ true_peak_max_dbtp=-1.0,
190
+ source="Podcast stereo convention: -16 LUFS +/-1.5 LU, true peak <= -1 dBTP.",
191
+ ),
192
+ LoudnessStandard(
193
+ id="ebu_r128",
194
+ label="EBU R128 broadcast",
195
+ integrated=-23.0,
196
+ tolerance_lu=0.5,
197
+ true_peak_max_dbtp=-1.0,
198
+ source="EBU R128: -23 LUFS +/-0.5 LU, true peak <= -1 dBTP.",
199
+ ),
200
+ LoudnessStandard(
201
+ id="atsc_a85",
202
+ label="ATSC A/85 (US broadcast)",
203
+ integrated=-24.0,
204
+ tolerance_lu=2.0,
205
+ true_peak_max_dbtp=-2.0,
206
+ source="ATSC A/85: -24 LKFS +/-2 LU, true peak <= -2 dBTP.",
207
+ ),
208
+ LoudnessStandard(
209
+ id="ott_dialogue_gated",
210
+ label="OTT dialogue-gated",
211
+ integrated=-27.0,
212
+ tolerance_lu=2.0,
213
+ true_peak_max_dbtp=-2.0,
214
+ dialogue_gated=True,
215
+ source="OTT dialogue-gated delivery: -27 LUFS +/-2 LU over dialogue only, true peak <= -2 dBTP.",
216
+ ),
217
+ )
218
+ }
219
+
220
+
221
+ def normalize_loudness_standard(name: Any) -> Optional[str]:
222
+ """Fold a user-supplied standard name to a canonical id, or None if unknown."""
223
+ if not isinstance(name, str):
224
+ return None
225
+ key = name.strip().lower().replace("-", "_").replace(" ", "_")
226
+ return key if key in LOUDNESS_STANDARDS else None
227
+
228
+
107
229
  # ── Target model ────────────────────────────────────────────────────────────
108
230
 
109
231
 
@@ -137,6 +259,10 @@ class DeliveryTarget:
137
259
  export_audio: bool = True
138
260
  export_alpha: bool = False
139
261
 
262
+ # Program loudness. An id into LOUDNESS_STANDARDS, or None for "do not pin".
263
+ # Unset on every shipped target — see the module docstring for why.
264
+ loudness_standard: Optional[str] = None
265
+
140
266
  verified: str = ""
141
267
  source: str = ""
142
268
  notes: Tuple[str, ...] = ()
@@ -155,6 +281,13 @@ class DeliveryTarget:
155
281
  def has_qc_projection(self) -> bool:
156
282
  return bool(self.qc_codec or self.qc_audio_codec)
157
283
 
284
+ @property
285
+ def loudness(self) -> Optional[LoudnessStandard]:
286
+ """The named standard, or None when the target pins no loudness."""
287
+ if not self.loudness_standard:
288
+ return None
289
+ return LOUDNESS_STANDARDS.get(self.loudness_standard)
290
+
158
291
 
159
292
  # ── Shipped table ───────────────────────────────────────────────────────────
160
293
 
@@ -539,6 +672,7 @@ OVERRIDE_KEYS = frozenset(
539
672
  "qc_container",
540
673
  "qc_codec",
541
674
  "qc_audio_codec",
675
+ "loudness_standard",
542
676
  }
543
677
  )
544
678
 
@@ -633,6 +767,19 @@ def resolve_target(
633
767
  patch[key] = bool(raw)
634
768
  elif key == "fps":
635
769
  patch[key] = float(raw)
770
+ elif key == "loudness_standard":
771
+ # Validate against the table rather than accepting any string:
772
+ # an unrecognised standard would otherwise ride through as a
773
+ # target that silently emits no loudness projection at all.
774
+ canonical_standard = normalize_loudness_standard(raw)
775
+ if canonical_standard is None:
776
+ logger.warning(
777
+ "ignoring unknown loudness standard %r (known: %s)",
778
+ raw,
779
+ ", ".join(sorted(LOUDNESS_STANDARDS)),
780
+ )
781
+ continue
782
+ patch[key] = canonical_standard
636
783
  else:
637
784
  patch[key] = str(raw)
638
785
  except (TypeError, ValueError):
@@ -666,6 +813,7 @@ def list_targets(
666
813
  "export_alpha": target.export_alpha,
667
814
  "has_qc_projection": target.has_qc_projection,
668
815
  "qc_skip_reason": target.qc_skip_reason,
816
+ "loudness_standard": target.loudness_standard,
669
817
  "verified": target.verified,
670
818
  "source": target.source,
671
819
  "notes": list(target.notes),
@@ -757,6 +905,83 @@ def to_qc_spec(
757
905
  return spec or None
758
906
 
759
907
 
908
+ def to_loudness_target(target: DeliveryTarget) -> Optional[Dict[str, Any]]:
909
+ """Project a target onto the `loudness_qc` target vocabulary.
910
+
911
+ Returns None when the target pins no loudness — most do not, and an absent
912
+ contract must stay absent rather than becoming a guessed one.
913
+
914
+ Shape::
915
+
916
+ {"target": {"integrated": -23.0, "integratedTol": 0.5,
917
+ "truePeakMax": -1.0},
918
+ "meta": {"standard": "ebu_r128", ...}}
919
+
920
+ `target` is handed straight to `loudness_qc`; `meta` is for the human-facing
921
+ verdict. They are separated so nothing in `meta` can ever be mistaken for a
922
+ gradeable assertion.
923
+
924
+ A dialogue-gated standard emits **no** `integrated` key: `loudness_qc`
925
+ measures full-program via ebur128, and grading a dialogue-gated number
926
+ against a full-program measurement produces a pass/fail that means nothing.
927
+ The figure travels in `meta.dialogue_gated_integrated_lufs` for a human or a
928
+ properly gated meter, and only true peak is asserted.
929
+
930
+ No `lraMax` is emitted: no standard here mandates one. `meta.lra_advisory_lu`
931
+ carries the threshold above which a mix is worth a look, as advice.
932
+ """
933
+ standard = target.loudness
934
+ if standard is None:
935
+ return None
936
+ if not target.export_audio:
937
+ return None
938
+
939
+ assertions: Dict[str, Any] = {"truePeakMax": standard.true_peak_max_dbtp}
940
+ meta: Dict[str, Any] = {
941
+ "standard": standard.id,
942
+ "label": standard.label,
943
+ "source": standard.source,
944
+ "dialogue_gated": standard.dialogue_gated,
945
+ "lra_advisory_lu": LRA_ADVISORY_LU,
946
+ }
947
+
948
+ if standard.dialogue_gated:
949
+ meta["measurement_basis"] = "dialogue_gated"
950
+ meta["dialogue_gated_integrated_lufs"] = standard.integrated
951
+ meta["dialogue_gated_tolerance_lu"] = standard.tolerance_lu
952
+ meta["integrated_not_asserted_reason"] = (
953
+ "loudness_qc measures full-program loudness; this standard is measured over "
954
+ "dialogue only. Verify the integrated figure with a dialogue-gated meter — a "
955
+ "full-program pass or fail against it would not mean anything."
956
+ )
957
+ else:
958
+ meta["measurement_basis"] = "full_program"
959
+ assertions["integrated"] = standard.integrated
960
+ assertions["integratedTol"] = standard.tolerance_lu
961
+
962
+ if standard.lra_max is not None:
963
+ assertions["lraMax"] = standard.lra_max
964
+
965
+ return {"target": assertions, "meta": meta}
966
+
967
+
968
+ def list_loudness_standards() -> Dict[str, Dict[str, Any]]:
969
+ """Introspection payload so an agent names a standard instead of inventing one."""
970
+ return {
971
+ standard.id: {
972
+ "id": standard.id,
973
+ "label": standard.label,
974
+ "integrated_lufs": standard.integrated,
975
+ "tolerance_lu": standard.tolerance_lu,
976
+ "true_peak_max_dbtp": standard.true_peak_max_dbtp,
977
+ "lra_max": standard.lra_max,
978
+ "dialogue_gated": standard.dialogue_gated,
979
+ "source": standard.source,
980
+ }
981
+ for standard in sorted(LOUDNESS_STANDARDS.values(), key=lambda s: s.id)
982
+ }
983
+
984
+
760
985
  # ── User overrides ──────────────────────────────────────────────────────────
761
986
 
762
987
 
@@ -32,6 +32,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
32
32
 
33
33
  from src.utils import analysis_memory, analysis_store, timeline_brain_db
34
34
  from src.utils import silence_ripple as _silence_ripple_mod
35
+ from src.utils import transcript_edit as _transcript_edit_mod
35
36
 
36
37
  PLAN_DIR_NAME = "edit_plans"
37
38
  DEFAULT_HANDLE_SECONDS = 0.25
@@ -623,7 +624,7 @@ def plan_silence_ripple(
623
624
  items: Sequence[Dict[str, Any]],
624
625
  timeline_name: str,
625
626
  timeline_fps: float,
626
- threshold_db: float = DEFAULT_SILENCE_THRESHOLD_DB,
627
+ threshold_db: Optional[float] = None,
627
628
  min_strip_frames: float = DEFAULT_SILENCE_MIN_STRIP_FRAMES,
628
629
  pre_head_frames: float = DEFAULT_SILENCE_PRE_HEAD_FRAMES,
629
630
  post_tail_frames: float = DEFAULT_SILENCE_POST_TAIL_FRAMES,
@@ -710,17 +711,39 @@ def plan_silence_ripple(
710
711
  ),
711
712
  }
712
713
 
714
+ # An explicit threshold is honoured as given; omitting it calibrates the
715
+ # gate from this slice's own dynamics. An unusable calibration means no
716
+ # threshold suits this material — keep the item whole rather than strip
717
+ # it against a gate that was never validated against the audio.
718
+ item_threshold = threshold_db
719
+ calibration = None
720
+ if item_threshold is None:
721
+ calibration = _silence_ripple_mod.calibrate_silence_gate(
722
+ media_path, src_start_sec, src_end_sec
723
+ )
724
+ spec["calibration"] = calibration.as_dict()
725
+ if not calibration.usable:
726
+ skipped.append({
727
+ "item": item.get("item_name"),
728
+ "reason": f"silence gate could not be calibrated — kept whole: {calibration.reason}",
729
+ "calibration": calibration.as_dict(),
730
+ })
731
+ item_specs.append(spec)
732
+ continue
733
+ item_threshold = calibration.gate_db
734
+
713
735
  strip_regions, keep_segments = _silence_ripple_mod.plan_item_silence_strips(
714
736
  media_path,
715
737
  src_start_sec,
716
738
  src_end_sec,
717
- threshold_db=float(threshold_db),
739
+ threshold_db=float(item_threshold),
718
740
  min_strip_sec=min_strip_sec,
719
741
  pre_head_sec=pre_head_sec,
720
742
  post_tail_sec=post_tail_sec,
721
743
  )
722
744
  spec["keep_segments"] = keep_segments
723
745
  spec["strip_regions"] = strip_regions
746
+ spec["threshold_db"] = float(item_threshold)
724
747
  item_specs.append(spec)
725
748
 
726
749
  for strip_start, strip_end in strip_regions:
@@ -739,11 +762,13 @@ def plan_silence_ripple(
739
762
  "source_lift_seconds": [round(strip_start, 3), round(strip_end, 3)],
740
763
  "rationale": (
741
764
  f"Waveform silence {round(strip_start, 2)}s–{round(strip_end, 2)}s "
742
- f"(threshold {threshold_db} dB, min {min_strip_frames} frames)."
765
+ f"(threshold {round(float(item_threshold), 1)} dB, min {min_strip_frames} frames)."
743
766
  ),
744
767
  "evidence": {
745
768
  "basis": "ffmpeg_silencedetect",
746
- "threshold_db": threshold_db,
769
+ "threshold_db": round(float(item_threshold), 2),
770
+ "threshold_source": "explicit" if threshold_db is not None else "calibrated",
771
+ "calibration": spec.get("calibration"),
747
772
  "source_gap_seconds": [round(strip_start, 3), round(strip_end, 3)],
748
773
  },
749
774
  })
@@ -755,10 +780,16 @@ def plan_silence_ripple(
755
780
  "error": "No silence regions found above threshold",
756
781
  "skipped": skipped,
757
782
  "note": (
758
- f"threshold_db={threshold_db}, min_strip_frames={min_strip_frames}; "
759
- "items without readable file paths carry no waveform evidence "
760
- "(kept whole — see skipped)."
783
+ f"threshold_db={'auto-calibrated per item' if threshold_db is None else threshold_db}, "
784
+ f"min_strip_frames={min_strip_frames}; items without readable file paths carry no "
785
+ "waveform evidence, and items whose gate could not be calibrated were kept whole "
786
+ "rather than stripped against an unvalidated threshold (both — see skipped)."
761
787
  ),
788
+ "calibrations": [
789
+ {"item": s["item"].get("item_name"), **s["calibration"]}
790
+ for s in item_specs
791
+ if s.get("calibration")
792
+ ],
762
793
  }
763
794
 
764
795
  lifts.sort(key=lambda l: -l["timeline_start_frame"])
@@ -810,11 +841,17 @@ def plan_silence_ripple(
810
841
  ),
811
842
  "settings": {
812
843
  "threshold_db": threshold_db,
844
+ "threshold_source": "explicit" if threshold_db is not None else "calibrated_per_item",
813
845
  "min_strip_frames": min_strip_frames,
814
846
  "pre_head_frames": pre_head_frames,
815
847
  "post_tail_frames": post_tail_frames,
816
848
  "include_audio": bool(include_audio),
817
849
  },
850
+ "calibrations": [
851
+ {"item": s["item"].get("item_name"), **s["calibration"]}
852
+ for s in item_specs
853
+ if s.get("calibration")
854
+ ],
818
855
  })
819
856
  result = {
820
857
  "success": True,
@@ -1020,3 +1057,140 @@ def plan_swap(
1020
1057
  "version-archived timeline (lift + positioned append, same slot)."
1021
1058
  ),
1022
1059
  }
1060
+
1061
+
1062
+ # ── transcript-driven editing (word level) ───────────────────────────────────
1063
+
1064
+
1065
+ def plan_transcript_tighten(
1066
+ project_root: str,
1067
+ *,
1068
+ clip_ref: Any,
1069
+ remove_fillers: bool = True,
1070
+ remove_false_starts: bool = True,
1071
+ collapse_pauses: bool = True,
1072
+ max_pause: float = _transcript_edit_mod.DEFAULT_MAX_PAUSE_S,
1073
+ handle: float = _transcript_edit_mod.DEFAULT_HANDLE_S,
1074
+ min_cut: float = _transcript_edit_mod.DEFAULT_MIN_CUT_S,
1075
+ ) -> Dict[str, Any]:
1076
+ """Word-level tightening for one clip: fillers, false starts, long pauses.
1077
+
1078
+ Complementary to `plan_silence_ripple`, which is silence-driven and cannot
1079
+ touch an audible "um" mid-phrase. Emits `keep_ranges` in the same shape, so
1080
+ the existing variant assembler consumes either without changes.
1081
+ """
1082
+ from src.utils import strata as _strata
1083
+
1084
+ conn, clip, err = _strata.resolve_clip(project_root, clip_ref, require_media=False)
1085
+ if err:
1086
+ return {"success": False, **err}
1087
+ words = _strata.read_words(conn, clip["clip_uuid"])
1088
+ if not words:
1089
+ return {
1090
+ "success": False,
1091
+ "error": "No transcript words for this clip.",
1092
+ "remediation": (
1093
+ "Run media_analysis transcription for the clip, then strata "
1094
+ "backfill_transcript_words, before planning a word-level tighten."
1095
+ ),
1096
+ "clip": {"clip_uuid": clip["clip_uuid"], "clip_name": clip.get("clip_name")},
1097
+ }
1098
+ plan = _transcript_edit_mod.plan_transcript_cuts(
1099
+ words,
1100
+ remove_fillers=remove_fillers,
1101
+ remove_false_starts=remove_false_starts,
1102
+ collapse_pauses=collapse_pauses,
1103
+ max_pause=max_pause,
1104
+ handle=handle,
1105
+ min_cut=min_cut,
1106
+ )
1107
+ plan["clip"] = {
1108
+ "clip_uuid": clip["clip_uuid"],
1109
+ "clip_name": clip.get("clip_name"),
1110
+ "word_count": len(words),
1111
+ }
1112
+ plan["basis"] = "transcript_words"
1113
+ return plan
1114
+
1115
+
1116
+ def search_spoken_content(
1117
+ project_root: str,
1118
+ *,
1119
+ query: str,
1120
+ mode: str = "phrase",
1121
+ context_seconds: float = 1.5,
1122
+ handle_seconds: float = 0.5,
1123
+ max_hits: int = 200,
1124
+ ) -> Dict[str, Any]:
1125
+ """Search the spoken content of every transcribed clip; build selects.
1126
+
1127
+ A different axis from `find_similar`, which retrieves on semantic-visual
1128
+ embedding. This finds *what was said*, which no embedding search surfaces
1129
+ reliably, and returns hits in deterministic order (clip name, then time) so
1130
+ two identical searches produce identical selects.
1131
+ """
1132
+ from src.utils import strata as _strata
1133
+
1134
+ conn = timeline_brain_db.connect(project_root)
1135
+ rows = conn.execute(
1136
+ "SELECT clip_uuid, clip_name, file_path FROM clips ORDER BY clip_name COLLATE NOCASE"
1137
+ ).fetchall()
1138
+ if not rows:
1139
+ return {"success": False, "error": "No clips in the DB — analyze (or db_ingest) first."}
1140
+
1141
+ hits: List[Dict[str, Any]] = []
1142
+ searched: List[str] = []
1143
+ without_speech: List[Dict[str, str]] = []
1144
+ for row in rows:
1145
+ clip = dict(row)
1146
+ words = _strata.read_words(conn, clip["clip_uuid"])
1147
+ if not words:
1148
+ without_speech.append({
1149
+ "clip_uuid": clip["clip_uuid"],
1150
+ "clip_name": clip.get("clip_name"),
1151
+ "reason": "no transcript words",
1152
+ })
1153
+ continue
1154
+ searched.append(clip.get("clip_name") or clip["clip_uuid"])
1155
+ try:
1156
+ found = _transcript_edit_mod.search_words(
1157
+ words, query, mode=mode, context_seconds=context_seconds
1158
+ )
1159
+ except ValueError as exc:
1160
+ return {"success": False, "error": str(exc)}
1161
+ for hit in found:
1162
+ hits.append({
1163
+ **hit,
1164
+ "clip_uuid": clip["clip_uuid"],
1165
+ "clip_name": clip.get("clip_name"),
1166
+ "file_path": clip.get("file_path"),
1167
+ })
1168
+
1169
+ truncated = len(hits) > max_hits
1170
+ hits = hits[:max_hits]
1171
+ selects = [
1172
+ {
1173
+ "clip_uuid": hit["clip_uuid"],
1174
+ "clip_name": hit["clip_name"],
1175
+ "in_seconds": round(max(0.0, hit["start_seconds"] - handle_seconds), 3),
1176
+ "out_seconds": round(hit["end_seconds"] + handle_seconds, 3),
1177
+ "text": hit["text"],
1178
+ }
1179
+ for hit in hits
1180
+ ]
1181
+ return {
1182
+ "success": True,
1183
+ "query": query,
1184
+ "mode": mode,
1185
+ "hit_count": len(hits),
1186
+ "truncated": truncated,
1187
+ "hits": hits,
1188
+ "selects": selects,
1189
+ "handle_seconds": handle_seconds,
1190
+ "clips_searched": searched,
1191
+ "clips_without_speech": without_speech,
1192
+ "note": (
1193
+ "Proposal only — selects are in/out pairs in clip-relative seconds, ordered "
1194
+ "by clip name then time so an identical search yields an identical list."
1195
+ ),
1196
+ }