davinci-resolve-mcp 2.44.0 → 2.46.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/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.44.0"
14
+ VERSION = "2.46.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -112,6 +112,7 @@ from src.utils.fusion_group_settings import (
112
112
  )
113
113
  from src.utils import analysis_runs as _analysis_runs
114
114
  from src.utils import brain_edits as _brain_edits
115
+ from src.utils import edit_engine as _edit_engine_mod
115
116
  from src.utils import media_pool_changes as _media_pool_changes
116
117
  from src.utils import timeline_versioning as _timeline_versioning
117
118
  from src.utils import project_spec as _project_spec
@@ -782,6 +783,10 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
782
783
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
783
784
  ("timeline", "delete_track"),
784
785
  ("timeline", "apply_cuts"),
786
+ # Phase E edit-engine loops: plan → confirm → execute.
787
+ ("edit_engine", "execute_selects"),
788
+ ("edit_engine", "execute_tighten"),
789
+ ("edit_engine", "execute_swap"),
785
790
  ("graph", "apply_grade_from_drx"),
786
791
  ("graph", "reset_all_grades"),
787
792
  # 21.0 AI ops that render/generate NEW media files (additive, but expensive
@@ -1342,6 +1347,46 @@ def _timeline_timecode_to_frame_id(tl, timecode):
1342
1347
  return _timecode_to_frame_id(timecode, fps)
1343
1348
 
1344
1349
 
1350
+ def _marker_rebase_to_timeline_start(tl, frame):
1351
+ """Rebase an absolute frame to a Timeline marker frameId.
1352
+
1353
+ Timeline.AddMarker frameIds are relative to the timeline start (frame 0 ==
1354
+ first frame of the timeline), while GetCurrentTimecode and the timecodes
1355
+ shown in the Resolve UI are absolute. GetMarkers() echoes back whatever
1356
+ frameId was passed without validating, so a marker stored at an absolute
1357
+ frame displays past the end of the timeline (invisible in the UI).
1358
+ Frames below the timeline start are treated as already relative (elapsed
1359
+ time), and the frame passes through unchanged when the start frame is
1360
+ unavailable.
1361
+ """
1362
+ start = _timeline_start_frame(tl)
1363
+ if start and frame >= start:
1364
+ return frame - start
1365
+ return frame
1366
+
1367
+
1368
+ def _marker_timecode_to_frame_id(tl, timecode):
1369
+ frame, err = _timeline_timecode_to_frame_id(tl, timecode)
1370
+ if err:
1371
+ return None, err
1372
+ return _marker_rebase_to_timeline_start(tl, frame), None
1373
+
1374
+
1375
+ def _marker_display_frame(tl, frame):
1376
+ """Absolute frame for driving the playhead at a stored marker frameId.
1377
+
1378
+ Inverse of _marker_rebase_to_timeline_start: stored timeline marker keys
1379
+ are relative to the timeline start, while SetCurrentTimecode is absolute.
1380
+ Frames at or past the start frame are assumed to be legacy absolute keys
1381
+ and pass through unchanged so they still sample the intended frame.
1382
+ """
1383
+ frame = int(frame)
1384
+ start = _timeline_start_frame(tl)
1385
+ if start and frame < start:
1386
+ return frame + start
1387
+ return frame
1388
+
1389
+
1345
1390
  def _frame_id_to_timecode(frame: int, fps: float, separator: str = ":") -> str:
1346
1391
  nominal_fps = max(1, int(round(float(fps))))
1347
1392
  frame = max(0, int(frame))
@@ -1359,6 +1404,7 @@ def _timeline_frame_id_to_timecode(tl, frame: int) -> Tuple[Optional[str], Optio
1359
1404
 
1360
1405
 
1361
1406
  def _current_timeline_frame_id(tl):
1407
+ """Absolute frame at the playhead, matching TimelineItem.GetStart()."""
1362
1408
  if tl is None:
1363
1409
  return None, _err("current playhead marker requires a timeline")
1364
1410
  try:
@@ -1370,23 +1416,37 @@ def _current_timeline_frame_id(tl):
1370
1416
  return _timeline_timecode_to_frame_id(tl, timecode)
1371
1417
 
1372
1418
 
1419
+ def _current_timeline_marker_frame_id(tl):
1420
+ frame, err = _current_timeline_frame_id(tl)
1421
+ if err:
1422
+ return None, err
1423
+ return _marker_rebase_to_timeline_start(tl, frame), None
1424
+
1425
+
1373
1426
  def _marker_frame_from_params(p: Dict[str, Any], tl=None, default_to_current=False):
1427
+ """Resolve marker frame params to a marker frameId.
1428
+
1429
+ With a timeline, timecodes (and the playhead default) are rebased so the
1430
+ returned frameId is relative to the timeline start. Raw frame numbers are
1431
+ passed through unchanged: timeline marker frames are relative to the
1432
+ timeline start, clip marker frames are relative to the clip start.
1433
+ """
1374
1434
  raw_timecode = _first_param(p, "timecode", "time_code", "tc")
1375
1435
  if raw_timecode is not None:
1376
- return _timeline_timecode_to_frame_id(tl, raw_timecode)
1436
+ return _marker_timecode_to_frame_id(tl, raw_timecode)
1377
1437
 
1378
1438
  raw_frame = _first_param(p, "frame", "frame_id", "frameId", "frame_num", "frameNum")
1379
1439
  if raw_frame is not None:
1380
1440
  if isinstance(raw_frame, str):
1381
1441
  lowered = raw_frame.strip().lower()
1382
1442
  if lowered in {"current", "playhead", "current_playhead", "now"}:
1383
- return _current_timeline_frame_id(tl)
1443
+ return _current_timeline_marker_frame_id(tl)
1384
1444
  if ":" in raw_frame or ";" in raw_frame:
1385
- return _timeline_timecode_to_frame_id(tl, raw_frame)
1445
+ return _marker_timecode_to_frame_id(tl, raw_frame)
1386
1446
  return _coerce_marker_number(raw_frame, "frame")
1387
1447
 
1388
1448
  if default_to_current:
1389
- return _current_timeline_frame_id(tl)
1449
+ return _current_timeline_marker_frame_id(tl)
1390
1450
  return None, _err("Missing marker frame. Provide frame, frame_id/frameId, or timecode.")
1391
1451
 
1392
1452
 
@@ -4816,7 +4876,7 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
4816
4876
  sampled = []
4817
4877
  try:
4818
4878
  for sample in samples:
4819
- timecode, tc_err = _timeline_frame_id_to_timecode(tl, int(sample["frame"]))
4879
+ timecode, tc_err = _timeline_frame_id_to_timecode(tl, _marker_display_frame(tl, sample["frame"]))
4820
4880
  if tc_err:
4821
4881
  sample["error"] = tc_err.get("error")
4822
4882
  sampled.append(sample)
@@ -6134,6 +6194,7 @@ _MEDIA_POOL_KERNEL_ACTIONS = [
6134
6194
  "metadata_field_inventory",
6135
6195
  "safe_relink",
6136
6196
  "safe_unlink",
6197
+ "check_proxy_media_compatibility",
6137
6198
  "link_proxy_checked",
6138
6199
  "link_full_resolution_checked",
6139
6200
  "set_clip_marks",
@@ -9469,7 +9530,8 @@ def _media_pool_ingest_capabilities():
9469
9530
  "relink/unlink through Resolve MediaPool APIs",
9470
9531
  "media_pool.safe_relink and safe_unlink with path/clip validation",
9471
9532
  "proxy link/unlink through MediaPoolItem APIs",
9472
- "media_pool.link_proxy_checked with file validation",
9533
+ "media_pool.check_proxy_media_compatibility with ffprobe/source signature diagnostics",
9534
+ "media_pool.link_proxy_checked with file validation and optional compatibility guard",
9473
9535
  "full-resolution media link where Resolve 20 exposes it",
9474
9536
  "media_pool.link_full_resolution_checked with version/path validation",
9475
9537
  ],
@@ -9954,6 +10016,466 @@ def _safe_unlink(mp, root, p: Dict[str, Any]):
9954
10016
  return {"success": bool(mp.UnlinkClips(clips)), "count": len(clips), "missing": missing}
9955
10017
 
9956
10018
 
10019
+ def _parse_rate(value):
10020
+ if value in (None, "", "0/0", "N/A"):
10021
+ return None
10022
+ if isinstance(value, (int, float)):
10023
+ return float(value)
10024
+ text = str(value).strip()
10025
+ if not text:
10026
+ return None
10027
+ if "/" in text:
10028
+ left, right = text.split("/", 1)
10029
+ try:
10030
+ numerator = float(left.strip())
10031
+ denominator = float(right.strip())
10032
+ return numerator / denominator if denominator else None
10033
+ except (TypeError, ValueError):
10034
+ return None
10035
+ match = re.search(r"\d+(?:\.\d+)?", text.replace(",", "."))
10036
+ return float(match.group(0)) if match else None
10037
+
10038
+
10039
+ def _parse_int_text(value):
10040
+ if value in (None, "", "N/A"):
10041
+ return None
10042
+ if isinstance(value, bool):
10043
+ return None
10044
+ if isinstance(value, int):
10045
+ return value
10046
+ if isinstance(value, float):
10047
+ return int(value)
10048
+ match = re.search(r"\d+", str(value).replace(",", ""))
10049
+ return int(match.group(0)) if match else None
10050
+
10051
+
10052
+ def _parse_resolution(value):
10053
+ if value in (None, "", "N/A"):
10054
+ return None
10055
+ if isinstance(value, (list, tuple)) and len(value) >= 2:
10056
+ width = _parse_int_text(value[0])
10057
+ height = _parse_int_text(value[1])
10058
+ return (width, height) if width and height else None
10059
+ match = re.search(r"(\d+)\s*[xX]\s*(\d+)", str(value))
10060
+ if not match:
10061
+ return None
10062
+ return int(match.group(1)), int(match.group(2))
10063
+
10064
+
10065
+ def _normalize_media_token(value):
10066
+ return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
10067
+
10068
+
10069
+ def _media_token_matches(actual, expected):
10070
+ expected_token = _normalize_media_token(expected)
10071
+ if not expected_token:
10072
+ return True
10073
+ actual_token = _normalize_media_token(actual)
10074
+ return bool(actual_token) and (
10075
+ actual_token == expected_token
10076
+ or expected_token in actual_token
10077
+ or actual_token in expected_token
10078
+ )
10079
+
10080
+
10081
+ def _clip_media_signature(clip):
10082
+ properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "")
10083
+ properties = properties if isinstance(properties, dict) else {}
10084
+ resolution = _parse_resolution(
10085
+ properties.get("Resolution")
10086
+ or properties.get("Video Resolution")
10087
+ or properties.get("Image Size")
10088
+ )
10089
+ signature = {
10090
+ "name": _safe_media_pool_item_name(clip),
10091
+ "id": _safe_media_pool_item_id(clip),
10092
+ "file_path": properties.get("File Path") or properties.get("FilePath"),
10093
+ "resolution": {"width": resolution[0], "height": resolution[1]} if resolution else None,
10094
+ "fps": _parse_rate(
10095
+ properties.get("FPS")
10096
+ or properties.get("Frame Rate")
10097
+ or properties.get("Video Frame Rate")
10098
+ ),
10099
+ "frames": _parse_int_text(
10100
+ properties.get("Frames")
10101
+ or properties.get("Frame Count")
10102
+ or properties.get("Duration Frames")
10103
+ ),
10104
+ "sample_rate": _parse_int_text(
10105
+ properties.get("Sample Rate")
10106
+ or properties.get("Audio Sample Rate")
10107
+ ),
10108
+ }
10109
+ if properties_error:
10110
+ signature["properties_error"] = properties_error
10111
+ return signature
10112
+
10113
+
10114
+ def _probe_media_file(path: str):
10115
+ ffprobe = shutil.which("ffprobe")
10116
+ if not ffprobe:
10117
+ return {
10118
+ "success": False,
10119
+ "code": "FFPROBE_MISSING",
10120
+ "error": "ffprobe is not installed or not on PATH",
10121
+ "path": path,
10122
+ }
10123
+ try:
10124
+ proc = subprocess.run(
10125
+ [
10126
+ ffprobe,
10127
+ "-v",
10128
+ "error",
10129
+ "-show_streams",
10130
+ "-of",
10131
+ "json",
10132
+ path,
10133
+ ],
10134
+ capture_output=True,
10135
+ encoding="utf-8",
10136
+ errors="replace",
10137
+ timeout=20,
10138
+ stdin=subprocess.DEVNULL,
10139
+ )
10140
+ except subprocess.TimeoutExpired:
10141
+ return {"success": False, "code": "FFPROBE_TIMEOUT", "error": "ffprobe timed out", "path": path}
10142
+ except Exception as exc:
10143
+ return {"success": False, "code": "FFPROBE_FAILED", "error": str(exc), "path": path}
10144
+ if proc.returncode != 0:
10145
+ return {
10146
+ "success": False,
10147
+ "code": "FFPROBE_FAILED",
10148
+ "returncode": proc.returncode,
10149
+ "error": (proc.stderr or proc.stdout or "").strip(),
10150
+ "path": path,
10151
+ }
10152
+ try:
10153
+ payload = json.loads(proc.stdout or "{}")
10154
+ except json.JSONDecodeError as exc:
10155
+ return {"success": False, "code": "FFPROBE_JSON_ERROR", "error": str(exc), "path": path}
10156
+ streams = payload.get("streams") if isinstance(payload, dict) else []
10157
+ streams = streams if isinstance(streams, list) else []
10158
+ video = next((stream for stream in streams if stream.get("codec_type") == "video"), {})
10159
+ audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), {})
10160
+ return {"success": True, "path": path, "video": video, "audio": audio, "stream_count": len(streams)}
10161
+
10162
+
10163
+ def _proxy_media_signature(probe: Dict[str, Any]):
10164
+ video = probe.get("video") if isinstance(probe.get("video"), dict) else {}
10165
+ audio = probe.get("audio") if isinstance(probe.get("audio"), dict) else {}
10166
+ width = _parse_int_text(video.get("width"))
10167
+ height = _parse_int_text(video.get("height"))
10168
+ return {
10169
+ "path": probe.get("path"),
10170
+ "codec": video.get("codec_name"),
10171
+ "codec_long_name": video.get("codec_long_name"),
10172
+ "profile": video.get("profile"),
10173
+ "resolution": {"width": width, "height": height} if width and height else None,
10174
+ "fps": _parse_rate(video.get("avg_frame_rate") or video.get("r_frame_rate")),
10175
+ "frames": _parse_int_text(video.get("nb_frames") or (video.get("tags") or {}).get("NUMBER_OF_FRAMES")),
10176
+ "sample_rate": _parse_int_text(audio.get("sample_rate")),
10177
+ "channels": _parse_int_text(audio.get("channels")),
10178
+ }
10179
+
10180
+
10181
+ def _add_proxy_resolution_mismatch(result: Dict[str, Any], source, proxy):
10182
+ """Flag resolution only when the aspect ratio differs.
10183
+
10184
+ Proxies are routinely lower resolution than the source — Resolve links a
10185
+ half/quarter-res proxy happily (that is the point of proxies; verified live
10186
+ on Resolve Studio 21). Only a different aspect ratio means the file cannot
10187
+ be a proxy of this source.
10188
+ """
10189
+ if not source or not proxy:
10190
+ return
10191
+ src_w, src_h = source.get("width"), source.get("height")
10192
+ pxy_w, pxy_h = proxy.get("width"), proxy.get("height")
10193
+ if not (src_w and src_h and pxy_w and pxy_h):
10194
+ return
10195
+ if (src_w, src_h) == (pxy_w, pxy_h):
10196
+ return
10197
+ src_aspect = src_w / src_h
10198
+ pxy_aspect = pxy_w / pxy_h
10199
+ if abs(src_aspect - pxy_aspect) / src_aspect <= 0.02:
10200
+ result["warnings"].append(
10201
+ f"Proxy is a same-aspect downscale ({src_w}x{src_h} -> {pxy_w}x{pxy_h})"
10202
+ )
10203
+ return
10204
+ result["mismatches"].append({"field": "resolution", "source": source, "proxy": proxy})
10205
+
10206
+
10207
+ def _add_proxy_mismatch(mismatches: List[Dict[str, Any]], field: str, source, proxy, *, tolerance: float = 0.0):
10208
+ if source is None or proxy is None:
10209
+ return
10210
+ if isinstance(source, (int, float)) and isinstance(proxy, (int, float)):
10211
+ if abs(float(source) - float(proxy)) <= tolerance:
10212
+ return
10213
+ elif source == proxy:
10214
+ return
10215
+ mismatches.append({"field": field, "source": source, "proxy": proxy})
10216
+
10217
+
10218
+ def _check_proxy_media_compatibility(
10219
+ clip,
10220
+ proxy_path: str,
10221
+ *,
10222
+ expected_codec: Optional[str] = None,
10223
+ expected_profile: Optional[str] = None,
10224
+ ):
10225
+ source = _clip_media_signature(clip)
10226
+ probe = _probe_media_file(proxy_path)
10227
+ proxy = _proxy_media_signature(probe) if probe.get("success") else {"path": proxy_path}
10228
+ result = {
10229
+ "success": bool(probe.get("success")),
10230
+ "compatible": False,
10231
+ "source": source,
10232
+ "proxy": proxy,
10233
+ "mismatches": [],
10234
+ "warnings": [],
10235
+ }
10236
+ if not probe.get("success"):
10237
+ result["error"] = probe.get("error", "Unable to probe proxy media")
10238
+ result["code"] = probe.get("code", "FFPROBE_FAILED")
10239
+ return result
10240
+
10241
+ _add_proxy_resolution_mismatch(result, source.get("resolution"), proxy.get("resolution"))
10242
+ _add_proxy_mismatch(result["mismatches"], "fps", source.get("fps"), proxy.get("fps"), tolerance=0.01)
10243
+ _add_proxy_mismatch(result["mismatches"], "frames", source.get("frames"), proxy.get("frames"), tolerance=1)
10244
+ _add_proxy_mismatch(result["mismatches"], "sample_rate", source.get("sample_rate"), proxy.get("sample_rate"))
10245
+
10246
+ if expected_codec and not (
10247
+ _media_token_matches(proxy.get("codec"), expected_codec)
10248
+ or _media_token_matches(proxy.get("codec_long_name"), expected_codec)
10249
+ ):
10250
+ result["mismatches"].append({
10251
+ "field": "expected_codec",
10252
+ "expected": expected_codec,
10253
+ "proxy": proxy.get("codec") or proxy.get("codec_long_name"),
10254
+ })
10255
+ if expected_profile and not _media_token_matches(proxy.get("profile"), expected_profile):
10256
+ result["mismatches"].append({
10257
+ "field": "expected_profile",
10258
+ "expected": expected_profile,
10259
+ "proxy": proxy.get("profile"),
10260
+ })
10261
+
10262
+ result["compatible"] = not result["mismatches"]
10263
+ if source.get("resolution") is None:
10264
+ result["warnings"].append("Source clip resolution was not available from GetClipProperty")
10265
+ if source.get("fps") is None:
10266
+ result["warnings"].append("Source clip FPS was not available from GetClipProperty")
10267
+ if proxy.get("frames") is None:
10268
+ result["warnings"].append("Proxy frame count was not available from ffprobe")
10269
+ return result
10270
+
10271
+
10272
+ def _proxy_link_readback(clip):
10273
+ properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "")
10274
+ properties = properties if isinstance(properties, dict) else {}
10275
+ readback = {"clip_properties": properties}
10276
+ if properties_error:
10277
+ readback["properties_error"] = properties_error
10278
+ proxy_keys = [
10279
+ "Proxy",
10280
+ "Proxy Media",
10281
+ "Proxy Media Path",
10282
+ "Proxy Path",
10283
+ "Proxy Status",
10284
+ "ProxyMediaPath",
10285
+ ]
10286
+ observed = {key: properties.get(key) for key in proxy_keys if key in properties}
10287
+ if observed:
10288
+ readback["proxy_fields"] = observed
10289
+ return readback
10290
+
10291
+
10292
+ def _normalized_path_for_compare(path):
10293
+ if not path or not isinstance(path, str):
10294
+ return None
10295
+ return os.path.normcase(os.path.abspath(os.path.expanduser(path)))
10296
+
10297
+
10298
+ def _proxy_readback_matches_path(readback: Dict[str, Any], proxy_path: str):
10299
+ expected = _normalized_path_for_compare(proxy_path)
10300
+ if not expected:
10301
+ return False
10302
+ proxy_fields = readback.get("proxy_fields") if isinstance(readback, dict) else {}
10303
+ proxy_fields = proxy_fields if isinstance(proxy_fields, dict) else {}
10304
+ for value in proxy_fields.values():
10305
+ if _normalized_path_for_compare(value) == expected:
10306
+ return True
10307
+ return False
10308
+
10309
+
10310
+ def _proxy_link_journal_event(event_type: str, clip, proxy_path: str, *, success=None, verified=False, compatibility=None):
10311
+ event = {
10312
+ "type": event_type,
10313
+ "clip_id": _safe_media_pool_item_id(clip),
10314
+ "clip_name": _safe_media_pool_item_name(clip),
10315
+ "proxy_path": proxy_path,
10316
+ "success": success,
10317
+ "verified": bool(verified),
10318
+ }
10319
+ if compatibility is not None:
10320
+ event["compatible"] = bool(compatibility.get("compatible"))
10321
+ if compatibility.get("mismatches"):
10322
+ event["mismatch_fields"] = [row.get("field") for row in compatibility.get("mismatches", [])]
10323
+ return event
10324
+
10325
+
10326
+ def _verified_operation(
10327
+ name: str,
10328
+ *,
10329
+ requested: Dict[str, Any],
10330
+ preflight: Dict[str, Any],
10331
+ execution: Dict[str, Any],
10332
+ verification_status: str,
10333
+ readback=None,
10334
+ journal_event=None,
10335
+ ):
10336
+ return {
10337
+ "name": name,
10338
+ "preflight": preflight,
10339
+ "requested": requested,
10340
+ "execution": execution,
10341
+ "readback": readback,
10342
+ "verified": verification_status == "readback_verified",
10343
+ "verification_status": verification_status,
10344
+ "journal_event": journal_event,
10345
+ }
10346
+
10347
+
10348
+ def _check_proxy_media_compatibility_checked(root, p: Dict[str, Any]):
10349
+ clip = _find_clip(root, p.get("clip_id", ""))
10350
+ if not clip:
10351
+ return _err(f"Clip not found: {p.get('clip_id')}")
10352
+ proxy_path = p.get("proxy_path") or p.get("path")
10353
+ path_err = _path_error(proxy_path, must_be_file=True)
10354
+ if path_err:
10355
+ return _err(path_err)
10356
+ return _check_proxy_media_compatibility(
10357
+ clip,
10358
+ proxy_path,
10359
+ expected_codec=p.get("expected_codec") or p.get("codec"),
10360
+ expected_profile=p.get("expected_profile") or p.get("profile"),
10361
+ )
10362
+
10363
+
10364
+ def _timeline_append_readback_snapshot(proj):
10365
+ try:
10366
+ tl = proj.GetCurrentTimeline() if proj else None
10367
+ except Exception as exc:
10368
+ return {"available": False, "error": f"GetCurrentTimeline failed: {exc}"}
10369
+ if not tl:
10370
+ return {"available": False, "error": "No current timeline"}
10371
+
10372
+ timeline = {}
10373
+ try:
10374
+ timeline["name"] = tl.GetName()
10375
+ except Exception:
10376
+ timeline["name"] = None
10377
+ try:
10378
+ timeline["id"] = tl.GetUniqueId()
10379
+ except Exception:
10380
+ timeline["id"] = None
10381
+
10382
+ tracks = {}
10383
+ item_count = 0
10384
+ warnings = []
10385
+ for track_type in ("video", "audio", "subtitle"):
10386
+ track_count = _timeline_track_count(tl, track_type)
10387
+ rows = []
10388
+ for track_index in range(1, track_count + 1):
10389
+ try:
10390
+ items = tl.GetItemListInTrack(track_type, track_index) or []
10391
+ except Exception as exc:
10392
+ warnings.append(f"GetItemListInTrack({track_type}, {track_index}) failed: {exc}")
10393
+ items = []
10394
+ item_count += len(items)
10395
+ rows.append({
10396
+ "track_index": track_index,
10397
+ "item_count": len(items),
10398
+ "item_ids": _timeline_item_ids(items),
10399
+ })
10400
+ tracks[track_type] = {"track_count": track_count, "tracks": rows}
10401
+ return {
10402
+ "available": True,
10403
+ "timeline": timeline,
10404
+ "item_count": item_count,
10405
+ "tracks": tracks,
10406
+ "warnings": warnings,
10407
+ }
10408
+
10409
+
10410
+ def _compare_timeline_append_readback(before, observed, expected_count: int):
10411
+ before_count = before.get("item_count") if isinstance(before, dict) and before.get("available") else None
10412
+ after_count = observed.get("item_count") if isinstance(observed, dict) and observed.get("available") else None
10413
+ delta = after_count - before_count if before_count is not None and after_count is not None else None
10414
+ return {
10415
+ "verified": delta is not None and delta >= expected_count and expected_count > 0,
10416
+ "before_item_count": before_count,
10417
+ "after_item_count": after_count,
10418
+ "item_count_delta": delta,
10419
+ "expected_item_count_delta": expected_count,
10420
+ }
10421
+
10422
+
10423
+ def _append_to_timeline_with_verification(proj, mp, append_payload, requested: Dict[str, Any], expected_count: int):
10424
+ raw_result = {"value": None}
10425
+ before = _timeline_append_readback_snapshot(proj)
10426
+
10427
+ def mutate():
10428
+ raw_result["value"] = mp.AppendToTimeline(append_payload)
10429
+ return bool(raw_result["value"])
10430
+
10431
+ verification = verify_by_readback(
10432
+ mutate=mutate,
10433
+ observe=lambda: _timeline_append_readback_snapshot(proj),
10434
+ snapshot=lambda: before,
10435
+ compare=lambda before_snapshot, observed: _compare_timeline_append_readback(
10436
+ before_snapshot,
10437
+ observed,
10438
+ expected_count,
10439
+ ),
10440
+ intent=requested,
10441
+ label="media_pool.append_to_timeline",
10442
+ )
10443
+ return raw_result["value"], verification, before
10444
+
10445
+
10446
+ def _append_to_timeline_verified_operation(requested: Dict[str, Any], verification: Dict[str, Any], before):
10447
+ success = bool(verification.get("success_raw"))
10448
+ verified = bool(verification.get("verified"))
10449
+ verification_status = "readback_verified" if verified else ("api_failed" if not success else "api_success_unverified")
10450
+ readback = {
10451
+ "before": before,
10452
+ "after": verification.get("observed"),
10453
+ "before_item_count": verification.get("before_item_count"),
10454
+ "after_item_count": verification.get("after_item_count"),
10455
+ "item_count_delta": verification.get("item_count_delta"),
10456
+ "expected_item_count_delta": verification.get("expected_item_count_delta"),
10457
+ }
10458
+ return _verified_operation(
10459
+ "media_pool.append_to_timeline",
10460
+ requested=requested,
10461
+ preflight={
10462
+ "ok": True,
10463
+ "current_timeline_available": bool(isinstance(before, dict) and before.get("available")),
10464
+ "expected_item_count_delta": requested.get("expected_count"),
10465
+ },
10466
+ execution={"api": "MediaPool.AppendToTimeline", "attempted": True, "success": success},
10467
+ readback=readback,
10468
+ verification_status=verification_status,
10469
+ journal_event={
10470
+ "type": "timeline.appended" if verified else ("timeline.append.failed" if not success else "timeline.append.unverified"),
10471
+ "success": success,
10472
+ "verified": verified,
10473
+ "expected_count": requested.get("expected_count"),
10474
+ "item_count_delta": verification.get("item_count_delta"),
10475
+ },
10476
+ )
10477
+
10478
+
9957
10479
  def _link_proxy_checked(root, p: Dict[str, Any]):
9958
10480
  clip = _find_clip(root, p.get("clip_id", ""))
9959
10481
  if not clip:
@@ -9962,9 +10484,123 @@ def _link_proxy_checked(root, p: Dict[str, Any]):
9962
10484
  path_err = _path_error(proxy_path, must_be_file=True)
9963
10485
  if path_err:
9964
10486
  return _err(path_err)
10487
+ missing = _requires_method(clip, "LinkProxyMedia", "17.0")
10488
+ if missing:
10489
+ return missing
10490
+ check_compatibility = bool(
10491
+ p.get("check_compatibility")
10492
+ or p.get("require_compatible")
10493
+ or p.get("expected_codec")
10494
+ or p.get("expected_profile")
10495
+ or p.get("codec")
10496
+ or p.get("profile")
10497
+ )
10498
+ compatibility = None
10499
+ if check_compatibility:
10500
+ compatibility = _check_proxy_media_compatibility(
10501
+ clip,
10502
+ proxy_path,
10503
+ expected_codec=p.get("expected_codec") or p.get("codec"),
10504
+ expected_profile=p.get("expected_profile") or p.get("profile"),
10505
+ )
10506
+ requested = {
10507
+ "clip_id": p.get("clip_id"),
10508
+ "proxy_path": proxy_path,
10509
+ "dry_run": bool(p.get("dry_run")),
10510
+ "check_compatibility": check_compatibility,
10511
+ "require_compatible": bool(p.get("require_compatible")),
10512
+ "expected_codec": p.get("expected_codec") or p.get("codec"),
10513
+ "expected_profile": p.get("expected_profile") or p.get("profile"),
10514
+ }
10515
+ preflight = {
10516
+ "ok": True,
10517
+ "clip_found": True,
10518
+ "proxy_path_valid": True,
10519
+ "method_available": True,
10520
+ "compatibility_checked": compatibility is not None,
10521
+ }
10522
+ if compatibility is not None:
10523
+ preflight["compatible"] = bool(compatibility.get("compatible"))
9965
10524
  if p.get("dry_run"):
9966
- return _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path)
9967
- return {"success": bool(clip.LinkProxyMedia(proxy_path))}
10525
+ out = _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path)
10526
+ if compatibility is not None:
10527
+ out["compatibility"] = compatibility
10528
+ out["verified_operation"] = _verified_operation(
10529
+ "media_pool.link_proxy_checked",
10530
+ requested=requested,
10531
+ preflight=preflight,
10532
+ execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": False, "success": None},
10533
+ readback=None,
10534
+ verification_status="dry_run",
10535
+ journal_event=_proxy_link_journal_event(
10536
+ "proxy.link.preview",
10537
+ clip,
10538
+ proxy_path,
10539
+ success=None,
10540
+ verified=False,
10541
+ compatibility=compatibility,
10542
+ ),
10543
+ )
10544
+ return out
10545
+ if p.get("require_compatible") and compatibility is not None and not compatibility.get("compatible"):
10546
+ out = _err(
10547
+ "Proxy media is not compatible with the source clip; refusing LinkProxyMedia",
10548
+ code="PROXY_INCOMPATIBLE",
10549
+ category="invalid_input",
10550
+ remediation="Use a proxy with matching resolution, FPS, frame count, sample rate, and requested codec/profile.",
10551
+ )
10552
+ out["compatibility"] = compatibility
10553
+ out["verified_operation"] = _verified_operation(
10554
+ "media_pool.link_proxy_checked",
10555
+ requested=requested,
10556
+ preflight={**preflight, "ok": False},
10557
+ execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": False, "success": None},
10558
+ readback=None,
10559
+ verification_status="blocked",
10560
+ journal_event=_proxy_link_journal_event(
10561
+ "proxy.link.blocked",
10562
+ clip,
10563
+ proxy_path,
10564
+ success=False,
10565
+ verified=False,
10566
+ compatibility=compatibility,
10567
+ ),
10568
+ )
10569
+ return out
10570
+ verification = verify_by_readback(
10571
+ mutate=lambda: clip.LinkProxyMedia(proxy_path),
10572
+ observe=lambda: _proxy_link_readback(clip),
10573
+ compare=lambda _before, observed: {
10574
+ "verified": _proxy_readback_matches_path(observed, proxy_path),
10575
+ },
10576
+ intent={"clip_id": p.get("clip_id"), "proxy_path": proxy_path},
10577
+ label="media_pool.link_proxy_checked",
10578
+ )
10579
+ success = bool(verification.get("success_raw"))
10580
+ readback = verification.get("observed")
10581
+ verified = bool(verification.get("verified"))
10582
+ verification_status = "readback_verified" if verified else ("api_failed" if not success else "api_success_unverified")
10583
+ event_type = "proxy.linked" if success else "proxy.link.failed"
10584
+ out = {"success": success, "readback": readback}
10585
+ if compatibility is not None:
10586
+ out["compatibility"] = compatibility
10587
+ out["verified_operation"] = _verified_operation(
10588
+ "media_pool.link_proxy_checked",
10589
+ requested=requested,
10590
+ preflight=preflight,
10591
+ execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": True, "success": success},
10592
+ readback=readback,
10593
+ verification_status=verification_status,
10594
+ journal_event=_proxy_link_journal_event(
10595
+ event_type,
10596
+ clip,
10597
+ proxy_path,
10598
+ success=success,
10599
+ verified=verified,
10600
+ compatibility=compatibility,
10601
+ ),
10602
+ )
10603
+ return out
9968
10604
 
9969
10605
 
9970
10606
  def _link_full_resolution_checked(root, p: Dict[str, Any]):
@@ -12548,6 +13184,35 @@ class _SpecLiveExecutor:
12548
13184
  self._spec = spec
12549
13185
  self._proj = pm.GetCurrentProject()
12550
13186
 
13187
+ def _media_pool_bin_paths(self) -> List[str]:
13188
+ if not self._proj or not getattr(self._spec, "bins", None):
13189
+ return []
13190
+ try:
13191
+ mp = self._proj.GetMediaPool()
13192
+ root = mp.GetRootFolder() if mp else None
13193
+ except Exception:
13194
+ return []
13195
+ if root is None:
13196
+ return []
13197
+ paths: List[str] = []
13198
+
13199
+ def walk(folder, prefix: str) -> None:
13200
+ paths.append(prefix)
13201
+ try:
13202
+ subfolders = folder.GetSubFolderList() or []
13203
+ except Exception:
13204
+ subfolders = []
13205
+ for subfolder in subfolders:
13206
+ try:
13207
+ name = subfolder.GetName()
13208
+ except Exception:
13209
+ name = ""
13210
+ if name:
13211
+ walk(subfolder, f"{prefix}/{name}")
13212
+
13213
+ walk(root, "Master")
13214
+ return paths
13215
+
12551
13216
  def live_state(self) -> Dict[str, Any]:
12552
13217
  proj = self._proj
12553
13218
  projects = list(self._pm.GetProjectListInCurrentFolder() or [])
@@ -12597,6 +13262,7 @@ class _SpecLiveExecutor:
12597
13262
  "project": proj.GetName() if proj else None,
12598
13263
  "projects": projects,
12599
13264
  "settings": settings,
13265
+ "bins": self._media_pool_bin_paths(),
12600
13266
  "timelines": timelines,
12601
13267
  }
12602
13268
 
@@ -12618,6 +13284,18 @@ class _SpecLiveExecutor:
12618
13284
  except Exception:
12619
13285
  return False
12620
13286
 
13287
+ def ensure_bin(self, path: str) -> bool:
13288
+ if not self._proj:
13289
+ return False
13290
+ try:
13291
+ mp = self._proj.GetMediaPool()
13292
+ except Exception:
13293
+ return False
13294
+ if mp is None:
13295
+ return False
13296
+ _, err = _ensure_folder_path(mp, path)
13297
+ return err is None
13298
+
12621
13299
  def ensure_timeline(self, name: str, fps: Optional[float]) -> bool:
12622
13300
  if not self._proj:
12623
13301
  return False
@@ -12746,13 +13424,36 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
12746
13424
  except Exception:
12747
13425
  pass
12748
13426
  item_count = 0
13427
+ video_item_count = 0
13428
+ audio_item_count = 0
13429
+ subtitle_item_count = 0
12749
13430
  try:
12750
- vc = int(tl.GetTrackCount("video") or 0)
12751
- for ti in range(1, vc + 1):
12752
- item_count += len(tl.GetItemListInTrack("video", ti) or [])
13431
+ for track_type, key in (
13432
+ ("video", "video_item_count"),
13433
+ ("audio", "audio_item_count"),
13434
+ ("subtitle", "subtitle_item_count"),
13435
+ ):
13436
+ track_total = 0
13437
+ track_count = int(tl.GetTrackCount(track_type) or 0)
13438
+ for ti in range(1, track_count + 1):
13439
+ track_total += len(tl.GetItemListInTrack(track_type, ti) or [])
13440
+ if key == "video_item_count":
13441
+ video_item_count = track_total
13442
+ elif key == "audio_item_count":
13443
+ audio_item_count = track_total
13444
+ elif key == "subtitle_item_count":
13445
+ subtitle_item_count = track_total
13446
+ item_count += track_total
12753
13447
  except Exception:
12754
13448
  pass
12755
- timelines.append({"name": tl.GetName(), "fps": fps, "item_count": item_count})
13449
+ timelines.append({
13450
+ "name": tl.GetName(),
13451
+ "fps": fps,
13452
+ "item_count": item_count,
13453
+ "video_item_count": video_item_count,
13454
+ "audio_item_count": audio_item_count,
13455
+ "subtitle_item_count": subtitle_item_count,
13456
+ })
12756
13457
  state["timelines"] = timelines
12757
13458
  settings: Dict[str, Any] = {}
12758
13459
  try:
@@ -13818,7 +14519,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13818
14519
  metadata_field_inventory(clip_ids|selected, include_values?) -> {items, ui_group_names}
13819
14520
  safe_relink(clip_ids|selected, folder_path, dry_run?) -> {success}
13820
14521
  safe_unlink(clip_ids|selected, dry_run?) -> {success}
13821
- link_proxy_checked(clip_id, proxy_path|path, dry_run?) -> {success}
14522
+ check_proxy_media_compatibility(clip_id, proxy_path|path, expected_codec?, expected_profile?) -> {compatible, mismatches}
14523
+ link_proxy_checked(clip_id, proxy_path|path, dry_run?, check_compatibility?, require_compatible?, expected_codec?, expected_profile?) -> {success}
13822
14524
  link_full_resolution_checked(clip_id, path|full_res_media_path, dry_run?) -> {success}
13823
14525
  set_clip_marks(clip_ids|selected, mark_in, mark_out, type?, dry_run?) -> {success, results}
13824
14526
  clear_clip_marks(clip_ids|selected, type?, dry_run?) -> {success, results}
@@ -13960,7 +14662,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13960
14662
  if row_err:
13961
14663
  return row_err
13962
14664
  built.append(row)
13963
- result = mp.AppendToTimeline(built)
14665
+ requested = {
14666
+ "mode": "clip_infos",
14667
+ "clip_info_count": len(raw),
14668
+ "expected_count": len(built),
14669
+ }
14670
+ result, verification, before = _append_to_timeline_with_verification(
14671
+ proj,
14672
+ mp,
14673
+ built,
14674
+ requested,
14675
+ len(built),
14676
+ )
13964
14677
  if not result:
13965
14678
  return _err("Failed to append clip_infos to timeline")
13966
14679
  items_out = []
@@ -13969,14 +14682,38 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13969
14682
  if item_err:
13970
14683
  return item_err
13971
14684
  items_out.append(item_out)
13972
- return _ok(count=len(result), items=items_out)
14685
+ out = _ok(count=len(result), items=items_out)
14686
+ out["verified_operation"] = _append_to_timeline_verified_operation(
14687
+ requested,
14688
+ verification,
14689
+ before,
14690
+ )
14691
+ return out
13973
14692
  clip_ids = p.get("clip_ids")
13974
14693
  if not clip_ids:
13975
14694
  return _err("Provide clip_ids (simple append) or clip_infos (positioned append)")
13976
14695
  clips = [_find_clip(root, cid) for cid in clip_ids]
13977
14696
  clips = [c for c in clips if c]
13978
- result = mp.AppendToTimeline(clips)
13979
- return _ok(count=len(result) if result else 0)
14697
+ requested = {
14698
+ "mode": "clip_ids",
14699
+ "clip_ids": list(clip_ids),
14700
+ "resolved_clip_count": len(clips),
14701
+ "expected_count": len(clips),
14702
+ }
14703
+ result, verification, before = _append_to_timeline_with_verification(
14704
+ proj,
14705
+ mp,
14706
+ clips,
14707
+ requested,
14708
+ len(clips),
14709
+ )
14710
+ out = _ok(count=len(result) if result else 0)
14711
+ out["verified_operation"] = _append_to_timeline_verified_operation(
14712
+ requested,
14713
+ verification,
14714
+ before,
14715
+ )
14716
+ return out
13980
14717
  elif action == "import_media":
13981
14718
  if p.get("clip_infos") is not None:
13982
14719
  raw = p["clip_infos"]
@@ -14081,6 +14818,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
14081
14818
  return _safe_relink(mp, root, p)
14082
14819
  elif action == "safe_unlink":
14083
14820
  return _safe_unlink(mp, root, p)
14821
+ elif action == "check_proxy_media_compatibility":
14822
+ return _check_proxy_media_compatibility_checked(root, p)
14084
14823
  elif action == "link_proxy_checked":
14085
14824
  return _link_proxy_checked(root, p)
14086
14825
  elif action == "link_full_resolution_checked":
@@ -15758,6 +16497,506 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
15758
16497
  return _err(f"Unknown action: {action}")
15759
16498
 
15760
16499
 
16500
+ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List[Dict[str, Any]]:
16501
+ """Serialize video timeline items with the source mapping the planner needs."""
16502
+ rows: List[Dict[str, Any]] = []
16503
+ track_count = int(tl.GetTrackCount("video") or 0)
16504
+ indices = [int(track_index)] if track_index else range(1, track_count + 1)
16505
+ for index in indices:
16506
+ try:
16507
+ items = tl.GetItemListInTrack("video", index) or []
16508
+ except Exception:
16509
+ continue
16510
+ for item in items:
16511
+ try:
16512
+ row: Dict[str, Any] = {
16513
+ "track_index": index,
16514
+ "item_name": item.GetName(),
16515
+ "timeline_start_frame": _frame_int(item.GetStart()),
16516
+ "timeline_end_frame": _frame_int(item.GetEnd()),
16517
+ }
16518
+ except Exception:
16519
+ continue
16520
+ source_start = None
16521
+ if _has_method(item, "GetSourceStartFrame"):
16522
+ try:
16523
+ source_start = _frame_int(item.GetSourceStartFrame())
16524
+ except Exception:
16525
+ source_start = None
16526
+ if source_start is None and _has_method(item, "GetLeftOffset"):
16527
+ try:
16528
+ source_start = _frame_int(item.GetLeftOffset())
16529
+ except Exception:
16530
+ source_start = None
16531
+ row["source_start_frame"] = source_start or 0
16532
+ try:
16533
+ mpi = item.GetMediaPoolItem()
16534
+ if mpi is not None:
16535
+ row["media_ref"] = mpi.GetUniqueId()
16536
+ try:
16537
+ row["media_path"] = mpi.GetClipProperty("File Path")
16538
+ except Exception:
16539
+ pass
16540
+ except Exception:
16541
+ pass
16542
+ rows.append(row)
16543
+ return rows
16544
+
16545
+
16546
+ def _edit_engine_timeline_fps(tl) -> float:
16547
+ try:
16548
+ fps = float(tl.GetSetting("timelineFrameRate") or 0)
16549
+ return fps if fps > 0 else 24.0
16550
+ except Exception:
16551
+ return 24.0
16552
+
16553
+
16554
+ def _edit_engine_capture(tl) -> Dict[str, Any]:
16555
+ """Duration/clip-count readback for execute results."""
16556
+ out: Dict[str, Any] = {}
16557
+ try:
16558
+ out["duration_seconds"] = _brain_edits.capture_metric(_brain_edits.METRIC_DURATION_SECONDS, tl)
16559
+ out["clip_count"] = _brain_edits.capture_metric(_brain_edits.METRIC_CLIP_COUNT, tl)
16560
+ except Exception:
16561
+ pass
16562
+ return out
16563
+
16564
+
16565
+ @mcp.tool()
16566
+ @_destructive_op("edit_engine")
16567
+ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
16568
+ """Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
16569
+
16570
+ Every loop is plan → confirm → execute. plan_* actions are dry-run by
16571
+ construction: they query the DB-canonical analysis store and return a
16572
+ per-decision rationale plus a stored plan_id. execute_* actions require a
16573
+ confirm_token, run under the version-on-mutate hook (the timeline is
16574
+ archived first), and return before/after metrics as readback.
16575
+
16576
+ Actions:
16577
+ - plan_selects(min_select_potential?, max_duration_seconds?, timeline_name?,
16578
+ max_shots?, handle_seconds?, analysis_root?) — rank shots by deep-tier
16579
+ select potential (clip-level fallback), story-spine order. Additive.
16580
+ - execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
16581
+ from the plan's clip in/out ranges. Nothing existing is touched.
16582
+ - plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
16583
+ handle_seconds?) — dead-air lifts from transcript-gap evidence for the
16584
+ current (or named) timeline.
16585
+ - execute_tighten(plan_id, confirm_token?) — duplicates the timeline and
16586
+ applies the lifts (ripple) to the DUPLICATE, never the original.
16587
+ - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
16588
+ — alternates for one timeline item via the similarity index.
16589
+ - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
16590
+ item in place (lift + positioned append, same slot) on the current
16591
+ timeline (version-archived first).
16592
+ - list_plans(limit?) / get_plan(plan_id)
16593
+ """
16594
+ p = params or {}
16595
+
16596
+ def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
16597
+ # An explicit analysis_root always wins — the evidence DB the caller
16598
+ # analyzed into may differ from the provider's derived root (e.g. a
16599
+ # headless analysis keyed by project name only).
16600
+ analysis_root = p.get("analysis_root") or p.get("analysisRoot")
16601
+ explicit_root = str(analysis_root) if analysis_root and os.path.isdir(str(analysis_root)) else None
16602
+ vctx = _destructive_versioning_provider()
16603
+ if vctx is not None:
16604
+ r, proj, project_root, _name = vctx
16605
+ return r, proj, explicit_root or project_root, None
16606
+ if not need_resolve and explicit_root:
16607
+ return None, None, explicit_root, None
16608
+ return None, None, None, _err(
16609
+ "No project context — open a Resolve project (or pass analysis_root for plan_selects)."
16610
+ )
16611
+
16612
+ if action == "list_plans":
16613
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
16614
+ if err:
16615
+ return err
16616
+ return _edit_engine_mod.list_plans(project_root, limit=int(p.get("limit") or 20))
16617
+
16618
+ if action == "get_plan":
16619
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
16620
+ if err:
16621
+ return err
16622
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16623
+ if not plan:
16624
+ return _err("plan not found")
16625
+ if plan.get("_corrupt"):
16626
+ return _err("plan file failed its fingerprint check — re-plan")
16627
+ return {"success": True, "plan": plan}
16628
+
16629
+ if action == "plan_selects":
16630
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
16631
+ if err:
16632
+ return err
16633
+ return _edit_engine_mod.plan_selects(
16634
+ project_root,
16635
+ timeline_name=p.get("timeline_name") or p.get("timelineName"),
16636
+ max_duration_seconds=p.get("max_duration_seconds") or p.get("maxDurationSeconds"),
16637
+ min_select_potential=str(p.get("min_select_potential") or p.get("minSelectPotential") or "medium"),
16638
+ handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
16639
+ max_shots=int(p.get("max_shots") or p.get("maxShots") or 60),
16640
+ )
16641
+
16642
+ if action == "plan_tighten":
16643
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16644
+ if err:
16645
+ return err
16646
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
16647
+ if not tl:
16648
+ return _err("Timeline not found")
16649
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
16650
+ return _edit_engine_mod.plan_tighten(
16651
+ project_root,
16652
+ items=items,
16653
+ timeline_name=tl.GetName(),
16654
+ timeline_fps=_edit_engine_timeline_fps(tl),
16655
+ target_ratio=p.get("target_ratio") or p.get("targetRatio"),
16656
+ min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
16657
+ handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
16658
+ )
16659
+
16660
+ if action == "plan_swap":
16661
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16662
+ if err:
16663
+ return err
16664
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
16665
+ if not tl:
16666
+ return _err("Timeline not found")
16667
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
16668
+ target = None
16669
+ wanted_start = p.get("timeline_start_frame") if p.get("timeline_start_frame") is not None else p.get("timelineStartFrame")
16670
+ wanted_name = p.get("item_name") or p.get("itemName")
16671
+ for row in items:
16672
+ if wanted_start is not None and int(row["timeline_start_frame"]) == int(wanted_start):
16673
+ target = row
16674
+ break
16675
+ if wanted_name and row.get("item_name") == wanted_name:
16676
+ target = row
16677
+ break
16678
+ if target is None:
16679
+ return _err(
16680
+ "Target item not found — pass timeline_start_frame or item_name "
16681
+ f"(items: {[{'name': r.get('item_name'), 'start': r['timeline_start_frame']} for r in items[:20]]})"
16682
+ )
16683
+ return _edit_engine_mod.plan_swap(
16684
+ project_root,
16685
+ item=target,
16686
+ timeline_name=tl.GetName(),
16687
+ timeline_fps=_edit_engine_timeline_fps(tl),
16688
+ kind=str(p.get("kind") or "visual"),
16689
+ limit=int(p.get("limit") or 5),
16690
+ )
16691
+
16692
+ if action == "execute_selects":
16693
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16694
+ if err:
16695
+ return err
16696
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16697
+ if not plan or plan.get("_corrupt"):
16698
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
16699
+ if plan.get("kind") != "selects":
16700
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
16701
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16702
+ return _issue_confirm_token(
16703
+ action="edit_engine.execute_selects", params=p,
16704
+ preview={
16705
+ "operation": "edit_engine.execute_selects",
16706
+ "warning": "Creates a NEW selects timeline; no existing timeline is modified.",
16707
+ "timeline_name": plan.get("timeline_name"),
16708
+ "decision_count": len(plan.get("decisions") or []),
16709
+ "estimated_duration_seconds": plan.get("estimated_duration_seconds"),
16710
+ },
16711
+ )
16712
+ blocked = _consume_confirm_token(action="edit_engine.execute_selects", params=p)
16713
+ if blocked:
16714
+ return blocked
16715
+ mp = proj.GetMediaPool()
16716
+ if not mp:
16717
+ return _err("No media pool")
16718
+ root_folder = mp.GetRootFolder()
16719
+ name = str(plan.get("timeline_name") or "Selects")
16720
+ if _find_timeline_by_name(proj, name)[0]:
16721
+ name = f"{name} {time.strftime('%H%M%S')}"
16722
+ tl = mp.CreateEmptyTimeline(name)
16723
+ if not tl:
16724
+ return _err("Failed to create selects timeline")
16725
+ try:
16726
+ proj.SetCurrentTimeline(tl)
16727
+ except Exception:
16728
+ pass
16729
+ timeline_start = _timeline_start_frame(tl)
16730
+ built = []
16731
+ build_errors = []
16732
+ record_cursor = 0
16733
+ for i, ci in enumerate(plan.get("clip_infos") or []):
16734
+ append_ci = dict(ci)
16735
+ # Sequential assembly: each select lands after the previous one.
16736
+ append_ci.setdefault("record_frame", record_cursor)
16737
+ append_ci.setdefault("track_index", 1)
16738
+ row, row_err = _build_append_clip_info_dict(root_folder, append_ci, i, timeline_start)
16739
+ if row_err:
16740
+ build_errors.append({"index": i, "error": row_err.get("error")})
16741
+ continue
16742
+ built.append(row)
16743
+ record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
16744
+ appended = mp.AppendToTimeline(built) if built else None
16745
+ readback = _edit_engine_capture(tl)
16746
+ run_id = _analysis_runs.current_run_id()
16747
+ try:
16748
+ _brain_edits.log_brain_edit(
16749
+ project_root=project_root,
16750
+ analysis_run_id=run_id or "edit-engine",
16751
+ edit_type="edit_engine.selects_result",
16752
+ tool_name="edit_engine",
16753
+ action_name="execute_selects",
16754
+ timeline_after=tl.GetName(),
16755
+ target_metric=_brain_edits.METRIC_CLIP_COUNT,
16756
+ metric_direction="target_value",
16757
+ after_value=readback.get("clip_count"),
16758
+ rationale=plan.get("summary"),
16759
+ params={"plan_id": plan.get("plan_id")},
16760
+ result_summary={"appended": len(built), "build_errors": build_errors},
16761
+ )
16762
+ except Exception:
16763
+ pass
16764
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16765
+ "timeline_name": tl.GetName(),
16766
+ "appended": len(built),
16767
+ "build_errors": build_errors,
16768
+ **readback,
16769
+ })
16770
+ return {
16771
+ "success": bool(appended),
16772
+ "timeline_name": tl.GetName(),
16773
+ "appended": len(built),
16774
+ "build_errors": build_errors,
16775
+ "readback": readback,
16776
+ "plan_id": plan.get("plan_id"),
16777
+ }
16778
+
16779
+ if action == "execute_tighten":
16780
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16781
+ if err:
16782
+ return err
16783
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16784
+ if not plan or plan.get("_corrupt"):
16785
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
16786
+ if plan.get("kind") != "tighten":
16787
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
16788
+ lifts = plan.get("lifts") or []
16789
+ keep_ranges = plan.get("keep_ranges") or []
16790
+ if not keep_ranges:
16791
+ return _err("plan has no keep_ranges — re-plan with this version")
16792
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16793
+ return _issue_confirm_token(
16794
+ action="edit_engine.execute_tighten", params=p,
16795
+ preview={
16796
+ "operation": "edit_engine.execute_tighten",
16797
+ "warning": (
16798
+ "Assembles a tightened VARIANT timeline from the plan's keep "
16799
+ "ranges; the original timeline is not modified."
16800
+ ),
16801
+ "timeline_name": plan.get("timeline_name"),
16802
+ "lift_count": len(lifts),
16803
+ "keep_range_count": len(keep_ranges),
16804
+ "estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
16805
+ },
16806
+ )
16807
+ blocked = _consume_confirm_token(action="edit_engine.execute_tighten", params=p)
16808
+ if blocked:
16809
+ return blocked
16810
+ source_tl, _src_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
16811
+ if not source_tl:
16812
+ return _err(f"Timeline '{plan.get('timeline_name')}' not found")
16813
+ before = _edit_engine_capture(source_tl)
16814
+ variant_name = f"{plan.get('timeline_name')} — tightened {time.strftime('%H%M%S')}"
16815
+ variant = _timeline_create_variant_from_ranges(proj, source_tl, {
16816
+ "ranges": keep_ranges,
16817
+ "name": variant_name,
16818
+ })
16819
+ if not variant.get("success"):
16820
+ return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
16821
+ new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
16822
+ after = _edit_engine_capture(new_tl) if new_tl else {}
16823
+ run_id = _analysis_runs.current_run_id()
16824
+ try:
16825
+ _brain_edits.log_brain_edit(
16826
+ project_root=project_root,
16827
+ analysis_run_id=run_id or "edit-engine",
16828
+ edit_type="edit_engine.tighten_result",
16829
+ tool_name="edit_engine",
16830
+ action_name="execute_tighten",
16831
+ timeline_before=plan.get("timeline_name"),
16832
+ timeline_after=variant.get("name") or variant_name,
16833
+ target_metric=_brain_edits.METRIC_DURATION_SECONDS,
16834
+ metric_direction="decrease",
16835
+ before_value=before.get("duration_seconds"),
16836
+ after_value=after.get("duration_seconds"),
16837
+ rationale=plan.get("summary"),
16838
+ params={"plan_id": plan.get("plan_id")},
16839
+ result_summary={"keep_ranges": len(keep_ranges), "lifts": len(lifts)},
16840
+ )
16841
+ except Exception:
16842
+ pass
16843
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16844
+ "variant_timeline": variant.get("name") or variant_name,
16845
+ "keep_ranges": len(keep_ranges),
16846
+ "lifts": len(lifts),
16847
+ "before": before,
16848
+ "after": after,
16849
+ })
16850
+ return {
16851
+ "success": True,
16852
+ "original_timeline": plan.get("timeline_name"),
16853
+ "variant_timeline": variant.get("name") or variant_name,
16854
+ "lifts_applied": len(lifts),
16855
+ "keep_ranges": len(keep_ranges),
16856
+ "lift_rationales": [
16857
+ {"lift": [l["timeline_start_frame"], l["timeline_end_frame"]], "rationale": l.get("rationale")}
16858
+ for l in lifts
16859
+ ],
16860
+ "readback": {
16861
+ "before": before,
16862
+ "after": after,
16863
+ "removed_seconds": (
16864
+ round(before["duration_seconds"] - after["duration_seconds"], 2)
16865
+ if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
16866
+ else None
16867
+ ),
16868
+ },
16869
+ "plan_id": plan.get("plan_id"),
16870
+ }
16871
+
16872
+ if action == "execute_swap":
16873
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16874
+ if err:
16875
+ return err
16876
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16877
+ if not plan or plan.get("_corrupt"):
16878
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
16879
+ if plan.get("kind") != "swap":
16880
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
16881
+ alternates = plan.get("alternates") or []
16882
+ try:
16883
+ alt_index = int(p.get("alternate_index") if p.get("alternate_index") is not None else p.get("alternateIndex"))
16884
+ except (TypeError, ValueError):
16885
+ return _err(f"execute_swap requires alternate_index (0-{len(alternates) - 1})")
16886
+ if not (0 <= alt_index < len(alternates)):
16887
+ return _err(f"alternate_index out of range (0-{len(alternates) - 1})")
16888
+ alternate = alternates[alt_index]
16889
+ item_block = plan.get("item") or {}
16890
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16891
+ return _issue_confirm_token(
16892
+ action="edit_engine.execute_swap", params=p,
16893
+ preview={
16894
+ "operation": "edit_engine.execute_swap",
16895
+ "warning": (
16896
+ "Replaces one timeline item in place (lift + positioned append). "
16897
+ "A timeline version is archived first."
16898
+ ),
16899
+ "timeline_name": plan.get("timeline_name"),
16900
+ "slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
16901
+ "replacement": {
16902
+ "clip_name": alternate.get("clip_name"),
16903
+ "shot_index": alternate.get("shot_index"),
16904
+ "score": alternate.get("score"),
16905
+ },
16906
+ },
16907
+ )
16908
+ blocked = _consume_confirm_token(action="edit_engine.execute_swap", params=p)
16909
+ if blocked:
16910
+ return blocked
16911
+ tl, _tl_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
16912
+ if not tl:
16913
+ return _err(f"Timeline '{plan.get('timeline_name')}' not found")
16914
+ try:
16915
+ proj.SetCurrentTimeline(tl)
16916
+ except Exception:
16917
+ pass
16918
+ before = _edit_engine_capture(tl)
16919
+ lift = _timeline_lift_range_impl(tl, {
16920
+ "start_frame": item_block.get("timeline_start_frame"),
16921
+ "end_frame": item_block.get("timeline_end_frame"),
16922
+ "allow_partial_item_delete": True,
16923
+ "ripple": False,
16924
+ })
16925
+ if not lift.get("success"):
16926
+ return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
16927
+ mp = proj.GetMediaPool()
16928
+ root_folder = mp.GetRootFolder()
16929
+ timeline_start = _timeline_start_frame(tl)
16930
+ frame_range = alternate.get("source_frame_range") or [0, 0]
16931
+ ci = {
16932
+ "clip_id": alternate.get("resolve_clip_id"),
16933
+ "start_frame": int(frame_range[0]),
16934
+ "end_frame": int(frame_range[1]),
16935
+ # Item starts come from item.GetStart() — absolute timeline frames.
16936
+ "record_frame": int(item_block.get("timeline_start_frame")),
16937
+ "record_frame_mode": "absolute",
16938
+ "track_index": int(item_block.get("track_index") or 1),
16939
+ }
16940
+ row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
16941
+ if row_err:
16942
+ return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
16943
+ appended = mp.AppendToTimeline([row])
16944
+ after = _edit_engine_capture(tl)
16945
+ run_id = _analysis_runs.current_run_id()
16946
+ try:
16947
+ _brain_edits.log_brain_edit(
16948
+ project_root=project_root,
16949
+ analysis_run_id=run_id or "edit-engine",
16950
+ edit_type="edit_engine.swap_result",
16951
+ tool_name="edit_engine",
16952
+ action_name="execute_swap",
16953
+ timeline_after=tl.GetName(),
16954
+ target_metric=_brain_edits.METRIC_CLIP_COUNT,
16955
+ metric_direction="target_value",
16956
+ before_value=before.get("clip_count"),
16957
+ after_value=after.get("clip_count"),
16958
+ rationale=(
16959
+ f"Swapped slot {item_block.get('timeline_start_frame')}-"
16960
+ f"{item_block.get('timeline_end_frame')} with "
16961
+ f"{alternate.get('clip_name')} shot {alternate.get('shot_index')}: "
16962
+ f"{alternate.get('rationale')}"
16963
+ ),
16964
+ params={"plan_id": plan.get("plan_id"), "alternate_index": alt_index},
16965
+ result_summary={"appended": bool(appended)},
16966
+ )
16967
+ except Exception:
16968
+ pass
16969
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16970
+ "alternate_index": alt_index,
16971
+ "replacement": alternate.get("clip_name"),
16972
+ "before": before,
16973
+ "after": after,
16974
+ })
16975
+ return {
16976
+ "success": bool(appended),
16977
+ "timeline_name": tl.GetName(),
16978
+ "replaced_slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
16979
+ "replacement": {
16980
+ "clip_name": alternate.get("clip_name"),
16981
+ "shot_index": alternate.get("shot_index"),
16982
+ "score": alternate.get("score"),
16983
+ },
16984
+ "readback": {"before": before, "after": after},
16985
+ "plan_id": plan.get("plan_id"),
16986
+ }
16987
+
16988
+ return _unknown(action, [
16989
+ "plan_selects",
16990
+ "execute_selects",
16991
+ "plan_tighten",
16992
+ "execute_tighten",
16993
+ "plan_swap",
16994
+ "execute_swap",
16995
+ "list_plans",
16996
+ "get_plan",
16997
+ ])
16998
+
16999
+
15761
17000
  @mcp.tool()
15762
17001
  @_destructive_op("timeline")
15763
17002
  def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -15825,6 +17064,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15825
17064
  apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?) -> {success}
15826
17065
  # example: action_help(name='<action_name>')
15827
17066
  thumbnail_contact_sheet(frames?|max_samples?, analysis_root?) -> {path, samples}
17067
+ frames are relative to the timeline start (frame 0 = first frame), like marker frameIds.
15828
17068
  marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance}
15829
17069
  edit_kernel_capabilities() -> {supported, partially_supported, unsupported}
15830
17070
  probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count}
@@ -16431,6 +17671,12 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
16431
17671
  def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
16432
17672
  """Markers and playhead operations on the current timeline.
16433
17673
 
17674
+ Marker frames are RELATIVE to the timeline start: frame 0 is the first
17675
+ frame of the timeline, even when the timeline starts at 01:00:00:00.
17676
+ Timecode params are absolute timeline timecode as shown in the Resolve UI
17677
+ (timecodes before the start timecode are treated as elapsed time) and are
17678
+ converted to relative frames automatically.
17679
+
16434
17680
  Actions:
16435
17681
  add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame}
16436
17682
  If frame/timecode is omitted, add uses the current playhead timecode.