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.
- package/CHANGELOG.md +213 -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/bridge_differential.py +22 -4
- package/scripts/doctor.py +129 -3
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +639 -18
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +27 -1
- 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
package/src/utils/edit_engine.py
CHANGED
|
@@ -25,12 +25,14 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import hashlib
|
|
27
27
|
import json
|
|
28
|
+
import math
|
|
28
29
|
import os
|
|
29
30
|
import time
|
|
30
31
|
import uuid
|
|
31
|
-
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
32
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
32
33
|
|
|
33
34
|
from src.utils import analysis_memory, analysis_store, timeline_brain_db
|
|
35
|
+
from src.utils import edit_handles as _edit_handles_mod
|
|
34
36
|
from src.utils import silence_ripple as _silence_ripple_mod
|
|
35
37
|
from src.utils import transcript_edit as _transcript_edit_mod
|
|
36
38
|
|
|
@@ -665,11 +667,19 @@ def plan_silence_ripple(
|
|
|
665
667
|
clip_row = None
|
|
666
668
|
clip_fps = fps
|
|
667
669
|
resolve_clip_id = None
|
|
670
|
+
source_total_frames = None
|
|
668
671
|
if clip_uuid:
|
|
669
672
|
clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
|
|
670
673
|
if clip_row:
|
|
671
674
|
clip_fps = _clip_fps(dict(clip_row))
|
|
672
675
|
resolve_clip_id = dict(clip_row).get("resolve_clip_id")
|
|
676
|
+
# Needed to tell a keep range that ends mid-clip (has handles)
|
|
677
|
+
# from one that runs to the last frame (has none). Absent length
|
|
678
|
+
# means unverified, which is reported as such rather than
|
|
679
|
+
# assumed clean — see edit_handles.
|
|
680
|
+
_dur = dict(clip_row).get("duration_seconds")
|
|
681
|
+
if isinstance(_dur, (int, float)) and _dur > 0:
|
|
682
|
+
source_total_frames = int(round(float(_dur) * clip_fps))
|
|
673
683
|
resolve_clip_id = resolve_clip_id or item.get("media_ref")
|
|
674
684
|
if not resolve_clip_id:
|
|
675
685
|
skipped.append({
|
|
@@ -688,6 +698,7 @@ def plan_silence_ripple(
|
|
|
688
698
|
"resolve_clip_id": resolve_clip_id,
|
|
689
699
|
"src_start_sec": src_start_sec,
|
|
690
700
|
"src_end_sec": src_end_sec,
|
|
701
|
+
"source_total_frames": source_total_frames,
|
|
691
702
|
# Default: ride along whole. Overwritten below when waveform
|
|
692
703
|
# evidence is available for this item.
|
|
693
704
|
"keep_segments": [(src_start_sec, src_end_sec)],
|
|
@@ -826,8 +837,21 @@ def plan_silence_ripple(
|
|
|
826
837
|
removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
|
|
827
838
|
audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
|
|
828
839
|
video_keep_range_count = len(keep_ranges) - audio_keep_range_count
|
|
840
|
+
|
|
841
|
+
# A tighten optimizes for removing silence and knows nothing about the media
|
|
842
|
+
# colour, VFX and sound need either side of a join. Surface it at plan time
|
|
843
|
+
# rather than letting it be discovered after picture lock.
|
|
844
|
+
handle_report = _edit_handles_mod.check_keep_range_handles(
|
|
845
|
+
keep_ranges,
|
|
846
|
+
source_bounds={
|
|
847
|
+
spec["resolve_clip_id"]: spec["source_total_frames"]
|
|
848
|
+
for spec in item_specs
|
|
849
|
+
if spec.get("resolve_clip_id") and spec.get("source_total_frames")
|
|
850
|
+
},
|
|
851
|
+
)
|
|
829
852
|
plan = save_plan(project_root, {
|
|
830
853
|
"kind": "silence_ripple",
|
|
854
|
+
"handle_report": handle_report,
|
|
831
855
|
"timeline_name": timeline_name,
|
|
832
856
|
"timeline_fps": fps,
|
|
833
857
|
"lifts": lifts,
|
|
@@ -867,6 +891,7 @@ def plan_silence_ripple(
|
|
|
867
891
|
"audio_keep_range_count": audio_keep_range_count,
|
|
868
892
|
"include_audio": bool(include_audio),
|
|
869
893
|
"skipped": skipped,
|
|
894
|
+
"handle_report": handle_report,
|
|
870
895
|
"note": (
|
|
871
896
|
"Dry-run plan. Execute with edit_engine(action='execute_silence_ripple', "
|
|
872
897
|
"params={plan_id}) — a tightened VARIANT timeline is assembled from "
|
|
@@ -884,6 +909,217 @@ def plan_silence_ripple(
|
|
|
884
909
|
return result
|
|
885
910
|
|
|
886
911
|
|
|
912
|
+
# ── E2b: dead space, marked for review ───────────────────────────────────────
|
|
913
|
+
|
|
914
|
+
#: Resolve marker colours. Red reads as "look here" and is what editors reach
|
|
915
|
+
#: for when asking to be shown something before agreeing to it.
|
|
916
|
+
DEAD_SPACE_MARKER_COLOR = "Red"
|
|
917
|
+
DEAD_SPACE_UNCERTAIN_MARKER_COLOR = "Yellow"
|
|
918
|
+
|
|
919
|
+
#: Separation above the usability floor below which a gate is "usable but only
|
|
920
|
+
#: just". Speech and room are close together at that point, so the detection is
|
|
921
|
+
#: correct-in-principle and worth a human's eye before it is acted on.
|
|
922
|
+
MARGINAL_SEPARATION_MARGIN_DB = 6.0
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _calibration_is_marginal(calibration) -> bool:
|
|
926
|
+
"""True when a calibrated gate barely cleared its own credibility floor.
|
|
927
|
+
|
|
928
|
+
Returns False for an explicit caller-supplied threshold (nothing was
|
|
929
|
+
calibrated, so there is no confidence to report) and for digital silence
|
|
930
|
+
(unambiguous by construction).
|
|
931
|
+
"""
|
|
932
|
+
if calibration is None or not getattr(calibration, "usable", False):
|
|
933
|
+
return False
|
|
934
|
+
separation = getattr(calibration, "separation_db", None)
|
|
935
|
+
if separation is None or not math.isfinite(separation):
|
|
936
|
+
return False
|
|
937
|
+
floor = _silence_ripple_mod.MIN_SEPARATION_DB
|
|
938
|
+
return separation < floor + MARGINAL_SEPARATION_MARGIN_DB
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def plan_dead_space_markers(
|
|
942
|
+
project_root: str,
|
|
943
|
+
*,
|
|
944
|
+
items: Sequence[Dict[str, Any]],
|
|
945
|
+
timeline_name: str,
|
|
946
|
+
timeline_fps: float,
|
|
947
|
+
threshold_db: Optional[float] = None,
|
|
948
|
+
tightness: Optional[str] = None,
|
|
949
|
+
min_strip_frames: float = DEFAULT_SILENCE_MIN_STRIP_FRAMES,
|
|
950
|
+
pre_head_frames: float = DEFAULT_SILENCE_PRE_HEAD_FRAMES,
|
|
951
|
+
post_tail_frames: float = DEFAULT_SILENCE_POST_TAIL_FRAMES,
|
|
952
|
+
) -> Dict[str, Any]:
|
|
953
|
+
"""Find dead space and propose MARKERS for a human to review — cut nothing.
|
|
954
|
+
|
|
955
|
+
Why this exists as its own verb rather than a flag on `plan_silence_ripple`:
|
|
956
|
+
|
|
957
|
+
An editor asked to be shown the gaps before agreeing to lose them — "mark
|
|
958
|
+
all the spots with dead space with a red marker so I can review, and then
|
|
959
|
+
I'll approve" — which is the review gate this project's own guidance
|
|
960
|
+
recommends. There was no tool for it. `plan_silence_ripple` is calibrated
|
|
961
|
+
and correct but its output is a tightened *variant timeline*, and
|
|
962
|
+
`media_analysis`'s marker plan marks *shots on a clip*, not dead space on a
|
|
963
|
+
timeline. With nothing to call, an agent asked to do this improvises its own
|
|
964
|
+
detection and places markers by hand — which is exactly what happened, and
|
|
965
|
+
the markers landed on "pretty random spots" while missing the obvious gaps.
|
|
966
|
+
|
|
967
|
+
The detection here is deliberately the *same* calibrated gate as the ripple
|
|
968
|
+
path, so what you review is what you would get. The difference is only that
|
|
969
|
+
this proposes an annotation instead of an edit.
|
|
970
|
+
"""
|
|
971
|
+
if not items:
|
|
972
|
+
return {"success": False, "error": "No timeline items supplied"}
|
|
973
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
974
|
+
|
|
975
|
+
try:
|
|
976
|
+
scaled = _silence_ripple_mod.apply_tightness(
|
|
977
|
+
tightness=tightness,
|
|
978
|
+
pre_head_frames=pre_head_frames,
|
|
979
|
+
post_tail_frames=post_tail_frames,
|
|
980
|
+
min_strip_frames=min_strip_frames,
|
|
981
|
+
)
|
|
982
|
+
except ValueError as exc:
|
|
983
|
+
return {"success": False, "error": str(exc)}
|
|
984
|
+
|
|
985
|
+
min_strip_sec = _silence_ripple_mod.frames_to_seconds(scaled["min_strip_frames"], fps)
|
|
986
|
+
pre_head_sec = _silence_ripple_mod.frames_to_seconds(scaled["pre_head_frames"], fps)
|
|
987
|
+
post_tail_sec = _silence_ripple_mod.frames_to_seconds(scaled["post_tail_frames"], fps)
|
|
988
|
+
conn = timeline_brain_db.connect(project_root)
|
|
989
|
+
|
|
990
|
+
markers: List[Dict[str, Any]] = []
|
|
991
|
+
skipped: List[Dict[str, Any]] = []
|
|
992
|
+
calibrations: List[Dict[str, Any]] = []
|
|
993
|
+
total_dead_frames = 0
|
|
994
|
+
|
|
995
|
+
for item_index, item in enumerate(items):
|
|
996
|
+
try:
|
|
997
|
+
tl_start = int(item["timeline_start_frame"])
|
|
998
|
+
tl_end = int(item["timeline_end_frame"])
|
|
999
|
+
src_start_frame = int(item.get("source_start_frame") or 0)
|
|
1000
|
+
except (KeyError, TypeError, ValueError):
|
|
1001
|
+
skipped.append({"item": item.get("item_name"), "reason": "missing frame fields"})
|
|
1002
|
+
continue
|
|
1003
|
+
|
|
1004
|
+
clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_ref"))
|
|
1005
|
+
if not clip_uuid:
|
|
1006
|
+
clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_path"))
|
|
1007
|
+
clip_fps = fps
|
|
1008
|
+
if clip_uuid:
|
|
1009
|
+
clip_row = conn.execute(
|
|
1010
|
+
"SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)
|
|
1011
|
+
).fetchone()
|
|
1012
|
+
if clip_row:
|
|
1013
|
+
clip_fps = _clip_fps(dict(clip_row))
|
|
1014
|
+
|
|
1015
|
+
src_start_sec = src_start_frame / clip_fps
|
|
1016
|
+
src_end_sec = src_start_sec + (tl_end - tl_start) / fps
|
|
1017
|
+
|
|
1018
|
+
media_path = _resolve_media_path(conn, item)
|
|
1019
|
+
if not media_path:
|
|
1020
|
+
skipped.append({
|
|
1021
|
+
"item": item.get("item_name"),
|
|
1022
|
+
"reason": "no readable media file path — not analyzed, and therefore NOT marked clean",
|
|
1023
|
+
})
|
|
1024
|
+
continue
|
|
1025
|
+
if not _silence_ripple_mod.ffmpeg_available():
|
|
1026
|
+
return {
|
|
1027
|
+
"success": False,
|
|
1028
|
+
"error": (
|
|
1029
|
+
"ffmpeg not found on PATH — waveform silence detection "
|
|
1030
|
+
"requires ffmpeg (see media_analysis capabilities)"
|
|
1031
|
+
),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
item_threshold = threshold_db
|
|
1035
|
+
calibration = None
|
|
1036
|
+
if item_threshold is None:
|
|
1037
|
+
calibration = _silence_ripple_mod.calibrate_silence_gate(
|
|
1038
|
+
media_path, src_start_sec, src_end_sec
|
|
1039
|
+
)
|
|
1040
|
+
calibrations.append({"item": item.get("item_name"), **calibration.as_dict()})
|
|
1041
|
+
if not calibration.usable:
|
|
1042
|
+
# Not markable is not the same as no dead space. Saying nothing
|
|
1043
|
+
# here would read as "this item is clean", which is a lie by
|
|
1044
|
+
# omission on exactly the material most likely to need review.
|
|
1045
|
+
skipped.append({
|
|
1046
|
+
"item": item.get("item_name"),
|
|
1047
|
+
"reason": f"silence gate could not be calibrated — NOT analyzed: {calibration.reason}",
|
|
1048
|
+
"calibration": calibration.as_dict(),
|
|
1049
|
+
})
|
|
1050
|
+
continue
|
|
1051
|
+
item_threshold = calibration.gate_db
|
|
1052
|
+
|
|
1053
|
+
strip_regions, _keep = _silence_ripple_mod.plan_item_silence_strips(
|
|
1054
|
+
media_path,
|
|
1055
|
+
src_start_sec,
|
|
1056
|
+
src_end_sec,
|
|
1057
|
+
threshold_db=float(item_threshold),
|
|
1058
|
+
min_strip_sec=min_strip_sec,
|
|
1059
|
+
pre_head_sec=pre_head_sec,
|
|
1060
|
+
post_tail_sec=post_tail_sec,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
for strip_start, strip_end in strip_regions:
|
|
1064
|
+
marker_start = tl_start + int(round((strip_start - src_start_sec) * fps))
|
|
1065
|
+
marker_end = tl_start + int(round((strip_end - src_start_sec) * fps))
|
|
1066
|
+
duration = max(1, marker_end - marker_start)
|
|
1067
|
+
total_dead_frames += duration
|
|
1068
|
+
seconds = round(duration / fps, 2)
|
|
1069
|
+
# A gate that only just cleared the separation floor is a usable
|
|
1070
|
+
# gate, not a confident one — speech and room are nearly the same
|
|
1071
|
+
# level here, so these regions deserve a second look rather than the
|
|
1072
|
+
# same red as an unambiguous hole. Yellow says "probably, check me".
|
|
1073
|
+
uncertain = _calibration_is_marginal(calibration)
|
|
1074
|
+
markers.append({
|
|
1075
|
+
"timeline_start_frame": marker_start,
|
|
1076
|
+
"duration_frames": duration,
|
|
1077
|
+
"color": DEAD_SPACE_UNCERTAIN_MARKER_COLOR if uncertain else DEAD_SPACE_MARKER_COLOR,
|
|
1078
|
+
"name": f"Dead space {seconds}s",
|
|
1079
|
+
"note": (
|
|
1080
|
+
f"{seconds}s below {round(float(item_threshold), 1)} dB in "
|
|
1081
|
+
f"{item.get('item_name') or 'item'} "
|
|
1082
|
+
f"({'auto-calibrated' if threshold_db is None else 'explicit'} gate, "
|
|
1083
|
+
f"tightness={scaled['tightness']}). "
|
|
1084
|
+
f"Guards: {scaled['pre_head_frames']:.0f} frames kept before, "
|
|
1085
|
+
f"{scaled['post_tail_frames']:.0f} after, so speech either side is untouched."
|
|
1086
|
+
),
|
|
1087
|
+
"item_name": item.get("item_name"),
|
|
1088
|
+
"item_index": item_index,
|
|
1089
|
+
"source_gap_seconds": [round(strip_start, 3), round(strip_end, 3)],
|
|
1090
|
+
"evidence": {
|
|
1091
|
+
"basis": "ffmpeg_silencedetect",
|
|
1092
|
+
"threshold_db": round(float(item_threshold), 2),
|
|
1093
|
+
"threshold_source": "explicit" if threshold_db is not None else "calibrated",
|
|
1094
|
+
},
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
skipped = _dedupe_skipped(skipped)
|
|
1098
|
+
markers.sort(key=lambda m: m["timeline_start_frame"])
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
"success": True,
|
|
1102
|
+
"kind": "dead_space_markers",
|
|
1103
|
+
"timeline_name": timeline_name,
|
|
1104
|
+
"timeline_fps": fps,
|
|
1105
|
+
"tightness": scaled["tightness"],
|
|
1106
|
+
"marker_count": len(markers),
|
|
1107
|
+
"total_dead_seconds": round(total_dead_frames / fps, 2),
|
|
1108
|
+
"markers": markers,
|
|
1109
|
+
"calibrations": calibrations,
|
|
1110
|
+
"skipped": skipped,
|
|
1111
|
+
"analyzed_item_count": len(items) - len(skipped),
|
|
1112
|
+
"note": (
|
|
1113
|
+
"Review-only. NOTHING is cut and no marker has been written yet — write "
|
|
1114
|
+
"them with timeline_markers, delete the ones you disagree with, then "
|
|
1115
|
+
"tighten. Detection is the same calibrated gate as "
|
|
1116
|
+
"plan_silence_ripple, so what you see marked here is what that would "
|
|
1117
|
+
"remove at this tightness. Items in `skipped` were NOT analyzed and are "
|
|
1118
|
+
"not certified clean."
|
|
1119
|
+
),
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
|
|
887
1123
|
# ── E3: swap alternates ──────────────────────────────────────────────────────
|
|
888
1124
|
|
|
889
1125
|
|
|
@@ -1062,6 +1298,394 @@ def plan_swap(
|
|
|
1062
1298
|
# ── transcript-driven editing (word level) ───────────────────────────────────
|
|
1063
1299
|
|
|
1064
1300
|
|
|
1301
|
+
def rule_of_six_audit(
|
|
1302
|
+
*,
|
|
1303
|
+
items: Sequence[Dict[str, Any]],
|
|
1304
|
+
timeline_name: str,
|
|
1305
|
+
timeline_fps: float,
|
|
1306
|
+
story_beats: Optional[Sequence[float]] = None,
|
|
1307
|
+
) -> Dict[str, Any]:
|
|
1308
|
+
"""Audit a timeline against the Rule of Six.
|
|
1309
|
+
|
|
1310
|
+
Computes what is computable (currently rhythm, 10%) and abstains loudly on
|
|
1311
|
+
emotion (51%) and story (23%), which are not measurable and never will be.
|
|
1312
|
+
Criteria this build does not yet compute report `NOT_IMPLEMENTED` — never a
|
|
1313
|
+
pass.
|
|
1314
|
+
"""
|
|
1315
|
+
from src.utils import rhythm_audit as _rhythm
|
|
1316
|
+
from src.utils import rule_of_six as _six
|
|
1317
|
+
|
|
1318
|
+
if not items:
|
|
1319
|
+
return {"success": False, "error": "No timeline items supplied"}
|
|
1320
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
1321
|
+
|
|
1322
|
+
rhythm = _rhythm.assess(items, fps=fps, story_beats=story_beats or [])
|
|
1323
|
+
audit = _six.audit(
|
|
1324
|
+
timeline_name=timeline_name,
|
|
1325
|
+
criterion_results={"rhythm": rhythm},
|
|
1326
|
+
findings=rhythm.get("findings") or [],
|
|
1327
|
+
)
|
|
1328
|
+
audit["rhythm"] = {
|
|
1329
|
+
"shot_count": len(rhythm.get("shots") or []),
|
|
1330
|
+
"cut_density": rhythm.get("cut_density"),
|
|
1331
|
+
"motivated_breaks": rhythm.get("motivated_breaks") or [],
|
|
1332
|
+
"beats_supplied": rhythm.get("beats_supplied", 0),
|
|
1333
|
+
}
|
|
1334
|
+
return audit
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def sound_density_audit(
|
|
1338
|
+
*,
|
|
1339
|
+
track_media: Mapping[str, str],
|
|
1340
|
+
stream_limit: Optional[float] = None,
|
|
1341
|
+
duration_seconds: Optional[float] = None,
|
|
1342
|
+
) -> Dict[str, Any]:
|
|
1343
|
+
"""The two-and-a-half rule over a set of audio stems.
|
|
1344
|
+
|
|
1345
|
+
**Honest limit:** this measures the files it is given, so it is only as
|
|
1346
|
+
timeline-accurate as those files are. Rendered stems (dialogue / music /
|
|
1347
|
+
effects) give a true reading; raw source clips give a reading of the sources,
|
|
1348
|
+
which is useful for spotting a crowded mix but is not the timeline. The
|
|
1349
|
+
result says which it got.
|
|
1350
|
+
"""
|
|
1351
|
+
from src.utils import sound_density as _density
|
|
1352
|
+
|
|
1353
|
+
if not track_media:
|
|
1354
|
+
return {
|
|
1355
|
+
"success": False,
|
|
1356
|
+
"error": "No audio supplied.",
|
|
1357
|
+
"remediation": (
|
|
1358
|
+
"Pass track_media as {name: path} — ideally rendered stems "
|
|
1359
|
+
"(dialogue/music/effects). Counting tracks without measuring them "
|
|
1360
|
+
"cannot tell a competing stream from a bed, and guessing would make "
|
|
1361
|
+
"every mixed timeline fail."
|
|
1362
|
+
),
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
measured = _density.measure_track_levels(
|
|
1366
|
+
dict(track_media), duration_seconds=duration_seconds
|
|
1367
|
+
)
|
|
1368
|
+
if not measured.get("success"):
|
|
1369
|
+
return measured
|
|
1370
|
+
|
|
1371
|
+
kwargs: Dict[str, Any] = {}
|
|
1372
|
+
if stream_limit is not None:
|
|
1373
|
+
kwargs["stream_limit"] = float(stream_limit)
|
|
1374
|
+
result = _density.audit(measured["samples"], **kwargs)
|
|
1375
|
+
result["measured_streams"] = sorted(track_media)
|
|
1376
|
+
result["source_caveat"] = (
|
|
1377
|
+
"Measured from the supplied files. If these are rendered stems this is the "
|
|
1378
|
+
"timeline; if they are source clips it is a reading of the sources, not of "
|
|
1379
|
+
"the mix."
|
|
1380
|
+
)
|
|
1381
|
+
return result
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def setup_sheet(
|
|
1385
|
+
*,
|
|
1386
|
+
items: Sequence[Dict[str, Any]],
|
|
1387
|
+
timeline_name: str,
|
|
1388
|
+
timeline_fps: float,
|
|
1389
|
+
) -> Dict[str, Any]:
|
|
1390
|
+
"""One representative frame per camera setup, ordered by first appearance."""
|
|
1391
|
+
from src.utils import setup_sheet as _sheet
|
|
1392
|
+
|
|
1393
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
1394
|
+
result = _sheet.select_representatives(items, fps=fps)
|
|
1395
|
+
result["timeline_name"] = timeline_name
|
|
1396
|
+
return result
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def split_edit_audit(
|
|
1400
|
+
*,
|
|
1401
|
+
video_items: Sequence[Dict[str, Any]],
|
|
1402
|
+
audio_items: Sequence[Dict[str, Any]],
|
|
1403
|
+
timeline_fps: float,
|
|
1404
|
+
) -> Dict[str, Any]:
|
|
1405
|
+
"""Sound leads picture: classify every join as L-cut, J-cut or straight."""
|
|
1406
|
+
from src.utils import split_edits as _split
|
|
1407
|
+
|
|
1408
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
1409
|
+
return _split.audit(video_items, audio_items, fps=fps)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def plan_beat_cuts(
|
|
1413
|
+
project_root: str,
|
|
1414
|
+
*,
|
|
1415
|
+
clip_ref: Any = None,
|
|
1416
|
+
media_path: Optional[str] = None,
|
|
1417
|
+
timeline_fps: float = 24.0,
|
|
1418
|
+
mode: str = "phrase",
|
|
1419
|
+
beats_per_bar: int = 4,
|
|
1420
|
+
bars_per_phrase: int = 8,
|
|
1421
|
+
beat_offset: int = 0,
|
|
1422
|
+
min_shot_seconds: float = 0.0,
|
|
1423
|
+
) -> Dict[str, Any]:
|
|
1424
|
+
"""Cut points from the music's own pulse, for footage with no speech.
|
|
1425
|
+
|
|
1426
|
+
The speech tools here find edit points in words and pauses. Music has
|
|
1427
|
+
neither, and pointing a silence gate at it produces exactly the failure a
|
|
1428
|
+
motorsport editor predicted before trying it: engine noise reads as content,
|
|
1429
|
+
quiet reads as dead space, and the cuts land nowhere near the music.
|
|
1430
|
+
"""
|
|
1431
|
+
from src.utils import beat_detection as _beats
|
|
1432
|
+
|
|
1433
|
+
path = media_path
|
|
1434
|
+
if not path and clip_ref is not None:
|
|
1435
|
+
conn = timeline_brain_db.connect(project_root)
|
|
1436
|
+
path = _resolve_media_path(conn, {"media_ref": clip_ref, "media_path": clip_ref})
|
|
1437
|
+
if not path:
|
|
1438
|
+
return {
|
|
1439
|
+
"success": False,
|
|
1440
|
+
"error": "Supply media_path, or a clip_ref that resolves to readable media.",
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
detected = _beats.detect_beats(path)
|
|
1444
|
+
if not detected.get("success"):
|
|
1445
|
+
return detected
|
|
1446
|
+
|
|
1447
|
+
try:
|
|
1448
|
+
plan = _beats.plan_beat_cuts(
|
|
1449
|
+
detected["beats"],
|
|
1450
|
+
fps=float(timeline_fps) if timeline_fps else 24.0,
|
|
1451
|
+
mode=mode,
|
|
1452
|
+
beats_per_bar=int(beats_per_bar),
|
|
1453
|
+
bars_per_phrase=int(bars_per_phrase),
|
|
1454
|
+
beat_offset=int(beat_offset),
|
|
1455
|
+
min_shot_seconds=float(min_shot_seconds),
|
|
1456
|
+
)
|
|
1457
|
+
except ValueError as exc:
|
|
1458
|
+
return {"success": False, "error": str(exc)}
|
|
1459
|
+
|
|
1460
|
+
plan["media_path"] = path
|
|
1461
|
+
plan["tempo_bpm"] = detected["tempo_bpm"]
|
|
1462
|
+
plan["detection_confidence"] = detected["confidence"]
|
|
1463
|
+
if detected["confidence"]["band"] == "low":
|
|
1464
|
+
plan["warning"] = (
|
|
1465
|
+
"Beat tracking confidence is LOW — " + detected["confidence"]["reason"]
|
|
1466
|
+
+ ". Check several cuts against the music before assembling."
|
|
1467
|
+
)
|
|
1468
|
+
return plan
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
def plan_prebalance(
|
|
1472
|
+
project_root: str,
|
|
1473
|
+
*,
|
|
1474
|
+
items: Sequence[Dict[str, Any]],
|
|
1475
|
+
timeline_name: str,
|
|
1476
|
+
timeline_fps: float,
|
|
1477
|
+
max_items: int = 200,
|
|
1478
|
+
) -> Dict[str, Any]:
|
|
1479
|
+
"""Measure levels per timeline item and propose a neutral pre-balance.
|
|
1480
|
+
|
|
1481
|
+
Grouping proxy: Resolve does not expose "lighting setup", so items are
|
|
1482
|
+
grouped by reel name where present and by containing folder otherwise —
|
|
1483
|
+
both usually track a camera roll, which usually tracks a setup. It is a
|
|
1484
|
+
proxy and the result says so; a colorist regrouping by eye will beat it.
|
|
1485
|
+
"""
|
|
1486
|
+
from src.utils import prebalance as _prebalance
|
|
1487
|
+
|
|
1488
|
+
if not items:
|
|
1489
|
+
return {"success": False, "error": "No timeline items supplied"}
|
|
1490
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
1491
|
+
conn = timeline_brain_db.connect(project_root)
|
|
1492
|
+
|
|
1493
|
+
clips: List[Dict[str, Any]] = []
|
|
1494
|
+
unreadable: List[Dict[str, Any]] = []
|
|
1495
|
+
for item in items[:max_items]:
|
|
1496
|
+
media_path = _resolve_media_path(conn, item)
|
|
1497
|
+
name = item.get("item_name") or "(unnamed)"
|
|
1498
|
+
start = item.get("timeline_start_frame") or 0
|
|
1499
|
+
end = item.get("timeline_end_frame") or start
|
|
1500
|
+
duration = max(0.0, (end - start) / fps)
|
|
1501
|
+
if not media_path:
|
|
1502
|
+
unreadable.append({"clip": name, "reason": "no readable media path — NOT measured"})
|
|
1503
|
+
continue
|
|
1504
|
+
# Sample the middle of the used range: heads and tails catch fades,
|
|
1505
|
+
# slates and handles, none of which represent the shot.
|
|
1506
|
+
source_start = float(item.get("source_start_frame") or 0) / fps
|
|
1507
|
+
reading = _prebalance.measure_frame_levels(media_path, source_start + duration / 2.0)
|
|
1508
|
+
if not reading.get("success"):
|
|
1509
|
+
unreadable.append({"clip": name, "reason": reading.get("error") or "levels unreadable"})
|
|
1510
|
+
continue
|
|
1511
|
+
setup = item.get("reel_name") or os.path.basename(os.path.dirname(media_path)) or "ungrouped"
|
|
1512
|
+
clips.append({
|
|
1513
|
+
"name": name,
|
|
1514
|
+
"duration_seconds": round(duration, 2),
|
|
1515
|
+
"setup": setup,
|
|
1516
|
+
"levels": reading["levels"],
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
if not clips:
|
|
1520
|
+
return {
|
|
1521
|
+
"success": False,
|
|
1522
|
+
"error": "No timeline items could be measured.",
|
|
1523
|
+
"unreadable": unreadable,
|
|
1524
|
+
"remediation": "Check media is online and ffmpeg is on PATH.",
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
result = _prebalance.plan_prebalance(clips)
|
|
1528
|
+
result["timeline_name"] = timeline_name
|
|
1529
|
+
result["grouping_basis"] = "reel name where present, else containing folder (a PROXY for lighting setup)"
|
|
1530
|
+
if unreadable:
|
|
1531
|
+
result.setdefault("unanalyzed", []).extend(unreadable)
|
|
1532
|
+
if len(items) > max_items:
|
|
1533
|
+
result["truncated"] = (
|
|
1534
|
+
f"Measured the first {max_items} of {len(items)} items. The remainder "
|
|
1535
|
+
"were NOT measured and are not certified balanced — raise max_items."
|
|
1536
|
+
)
|
|
1537
|
+
return result
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def plan_reference_match(
|
|
1541
|
+
project_root: str,
|
|
1542
|
+
*,
|
|
1543
|
+
reference_media: str,
|
|
1544
|
+
reference_at_seconds: float = 0.0,
|
|
1545
|
+
items: Sequence[Dict[str, Any]],
|
|
1546
|
+
timeline_fps: float,
|
|
1547
|
+
max_items: int = 200,
|
|
1548
|
+
) -> Dict[str, Any]:
|
|
1549
|
+
"""Match timeline clips to a graded reference still.
|
|
1550
|
+
|
|
1551
|
+
Same measurement path as `plan_prebalance`, aimed at a reference instead of
|
|
1552
|
+
at neutral. END POINTS ONLY — it does not transfer the reference's grade.
|
|
1553
|
+
"""
|
|
1554
|
+
from src.utils import prebalance as _prebalance
|
|
1555
|
+
from src.utils import reference_match as _refmatch
|
|
1556
|
+
|
|
1557
|
+
if not items:
|
|
1558
|
+
return {"success": False, "error": "No timeline items supplied"}
|
|
1559
|
+
reference = _prebalance.measure_frame_levels(reference_media, reference_at_seconds)
|
|
1560
|
+
if not reference.get("success"):
|
|
1561
|
+
return {"success": False, "error": f"reference unreadable: {reference.get('error')}"}
|
|
1562
|
+
|
|
1563
|
+
fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
|
|
1564
|
+
conn = timeline_brain_db.connect(project_root)
|
|
1565
|
+
targets: List[Dict[str, Any]] = []
|
|
1566
|
+
for item in items[:max_items]:
|
|
1567
|
+
media_path = _resolve_media_path(conn, item)
|
|
1568
|
+
name = item.get("item_name") or "(unnamed)"
|
|
1569
|
+
if not media_path:
|
|
1570
|
+
targets.append({"name": name})
|
|
1571
|
+
continue
|
|
1572
|
+
start = item.get("timeline_start_frame") or 0
|
|
1573
|
+
end = item.get("timeline_end_frame") or start
|
|
1574
|
+
source_start = float(item.get("source_start_frame") or 0) / fps
|
|
1575
|
+
reading = _prebalance.measure_frame_levels(
|
|
1576
|
+
media_path, source_start + max(0.0, (end - start) / fps) / 2.0
|
|
1577
|
+
)
|
|
1578
|
+
targets.append({"name": name, "levels": reading.get("levels")} if reading.get("success")
|
|
1579
|
+
else {"name": name})
|
|
1580
|
+
|
|
1581
|
+
result = _refmatch.plan_reference_match(
|
|
1582
|
+
reference["levels"], targets, reference_name=os.path.basename(reference_media)
|
|
1583
|
+
)
|
|
1584
|
+
if len(items) > max_items:
|
|
1585
|
+
result["truncated"] = (
|
|
1586
|
+
f"Measured the first {max_items} of {len(items)} items; the remainder "
|
|
1587
|
+
"were NOT matched and are not certified matched."
|
|
1588
|
+
)
|
|
1589
|
+
return result
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def plan_string_out(
|
|
1593
|
+
*,
|
|
1594
|
+
shots: Sequence[Dict[str, Any]],
|
|
1595
|
+
order: str = "chronological",
|
|
1596
|
+
) -> Dict[str, Any]:
|
|
1597
|
+
"""String-out for footage with no speech — shots and motion, not silence."""
|
|
1598
|
+
from src.utils import shot_assembly as _assembly
|
|
1599
|
+
return _assembly.plan_string_out(shots, order=order)
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def propose_structure(*, topics: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
|
1603
|
+
"""No-script mode: propose an order, require approval, cut nothing."""
|
|
1604
|
+
from src.utils import shot_assembly as _assembly
|
|
1605
|
+
return _assembly.propose_structure(topics)
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def plan_broll(
|
|
1609
|
+
*,
|
|
1610
|
+
beats: Sequence[Dict[str, Any]],
|
|
1611
|
+
candidates: Sequence[Dict[str, Any]],
|
|
1612
|
+
allow_reuse: bool = False,
|
|
1613
|
+
) -> Dict[str, Any]:
|
|
1614
|
+
"""Place B-roll against A-roll beats. Relevance is the caller's, not ours."""
|
|
1615
|
+
from src.utils import broll_placement as _broll
|
|
1616
|
+
return _broll.place(beats, candidates, allow_reuse=allow_reuse)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def plan_turnover(
|
|
1620
|
+
*,
|
|
1621
|
+
destinations: Sequence[str],
|
|
1622
|
+
contents: Mapping[str, Any],
|
|
1623
|
+
version: str = "v01",
|
|
1624
|
+
handle_frames: Optional[int] = None,
|
|
1625
|
+
) -> Dict[str, Any]:
|
|
1626
|
+
"""Validate turnover manifests against their specs. Exports nothing."""
|
|
1627
|
+
from src.utils import turnover as _turnover
|
|
1628
|
+
return _turnover.plan_turnover(
|
|
1629
|
+
destinations, contents, version=version, handle_frames=handle_frames
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def rank_takes(
|
|
1634
|
+
project_root: str,
|
|
1635
|
+
*,
|
|
1636
|
+
clip_refs: Sequence[Any],
|
|
1637
|
+
script: Optional[str] = None,
|
|
1638
|
+
) -> Dict[str, Any]:
|
|
1639
|
+
"""Rank several clips as takes of the same material, by measurable fluency.
|
|
1640
|
+
|
|
1641
|
+
Clips with no transcript are reported, not silently dropped — "not ranked"
|
|
1642
|
+
and "ranked last" are different facts and an editor needs to know which.
|
|
1643
|
+
"""
|
|
1644
|
+
from src.utils import strata as _strata
|
|
1645
|
+
from src.utils import take_ranking as _take_ranking
|
|
1646
|
+
|
|
1647
|
+
if not clip_refs:
|
|
1648
|
+
return {"success": False, "error": "No clip_refs supplied"}
|
|
1649
|
+
|
|
1650
|
+
takes: List[Dict[str, Any]] = []
|
|
1651
|
+
unavailable: List[Dict[str, Any]] = []
|
|
1652
|
+
for ref in clip_refs:
|
|
1653
|
+
conn, clip, err = _strata.resolve_clip(project_root, ref, require_media=False)
|
|
1654
|
+
if err:
|
|
1655
|
+
unavailable.append({"clip_ref": ref, "reason": err.get("error") or "clip not found"})
|
|
1656
|
+
continue
|
|
1657
|
+
words = _strata.read_words(conn, clip["clip_uuid"])
|
|
1658
|
+
if not words:
|
|
1659
|
+
unavailable.append({
|
|
1660
|
+
"clip_ref": ref,
|
|
1661
|
+
"clip_name": clip.get("clip_name"),
|
|
1662
|
+
"reason": "no transcript words — NOT ranked (run transcription first)",
|
|
1663
|
+
})
|
|
1664
|
+
continue
|
|
1665
|
+
takes.append({"label": clip.get("clip_name") or str(ref), "words": words})
|
|
1666
|
+
|
|
1667
|
+
if not takes:
|
|
1668
|
+
return {
|
|
1669
|
+
"success": False,
|
|
1670
|
+
"error": "None of the supplied clips have transcript words to rank.",
|
|
1671
|
+
"unavailable": unavailable,
|
|
1672
|
+
"remediation": (
|
|
1673
|
+
"Run media_analysis transcription for these clips, then strata "
|
|
1674
|
+
"backfill_transcript_words, before ranking takes."
|
|
1675
|
+
),
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
result = _take_ranking.rank_takes(takes, script=script)
|
|
1679
|
+
result["unavailable"] = unavailable
|
|
1680
|
+
if unavailable:
|
|
1681
|
+
result["note"] = (
|
|
1682
|
+
f"{len(unavailable)} of {len(clip_refs)} clips could not be ranked "
|
|
1683
|
+
"(no transcript) and are listed in `unavailable` — they are absent "
|
|
1684
|
+
"from the ranking, not last in it. " + str(result.get("note") or "")
|
|
1685
|
+
)
|
|
1686
|
+
return result
|
|
1687
|
+
|
|
1688
|
+
|
|
1065
1689
|
def plan_transcript_tighten(
|
|
1066
1690
|
project_root: str,
|
|
1067
1691
|
*,
|
|
@@ -1110,6 +1734,14 @@ def plan_transcript_tighten(
|
|
|
1110
1734
|
"word_count": len(words),
|
|
1111
1735
|
}
|
|
1112
1736
|
plan["basis"] = "transcript_words"
|
|
1737
|
+
# I3: a word-level tighten breaks a turnover exactly like a waveform-driven
|
|
1738
|
+
# one does. Deciding the cut from a transcript rather than a waveform does
|
|
1739
|
+
# not give sound anything to crossfade with.
|
|
1740
|
+
duration = clip.get("duration_seconds")
|
|
1741
|
+
plan["handle_report"] = _edit_handles_mod.check_seconds_ranges(
|
|
1742
|
+
plan.get("keep_ranges") or [],
|
|
1743
|
+
source_duration_seconds=float(duration) if isinstance(duration, (int, float)) and duration else None,
|
|
1744
|
+
)
|
|
1113
1745
|
return plan
|
|
1114
1746
|
|
|
1115
1747
|
|