davinci-resolve-mcp 2.67.1 → 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.
- package/CHANGELOG.md +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -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
|
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -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 =
|
|
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(
|
|
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 {
|
|
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":
|
|
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},
|
|
759
|
-
"items without readable file paths carry no
|
|
760
|
-
"
|
|
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
|
+
}
|