davinci-resolve-mcp 2.68.2 → 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.
@@ -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