davinci-resolve-mcp 2.98.8 → 2.99.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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.98.8"
14
+ VERSION = "2.99.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1188,6 +1188,7 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
1188
1188
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
1189
1189
  ("timeline", "delete_track"),
1190
1190
  ("timeline", "apply_cuts"),
1191
+ ("timeline", "ripple_insert"),
1191
1192
  # Catastrophic media-pool deletes (EX3): irreversibly destroy clips/folders/
1192
1193
  # timelines. Gated like delete_track; also archive-on-mutate via the registry.
1193
1194
  ("media_pool", "delete_clips"),
@@ -4483,10 +4484,20 @@ def _append_and_recover_timeline_item(
4483
4484
  else:
4484
4485
  copied_properties = _copy_duplicate_item_state(source_item, duplicate_item, copy_properties)
4485
4486
 
4487
+ # AppendToTimeline can return items with unreadable ids (notably when the
4488
+ # target span is occupied). A duplicate counts as verified only when a live
4489
+ # item or a real id was recovered — delete_sources callers must never remove
4490
+ # a source on the strength of a synthetic summary.
4491
+ duplicate_verified = duplicate_item is not None or bool(ser.get("timeline_item_id"))
4492
+ if not duplicate_verified:
4493
+ warnings.append(
4494
+ "duplicate could not be verified on the timeline (null-id item, recovery found no match)"
4495
+ )
4486
4496
  result = {
4487
4497
  "clip_id": source_timeline_item_id,
4488
4498
  "source_clip_id": source_timeline_item_id,
4489
4499
  "success": True,
4500
+ "duplicate_verified": duplicate_verified,
4490
4501
  **ser,
4491
4502
  "source": source_summary,
4492
4503
  "duplicate": duplicate_summary,
@@ -4685,7 +4696,19 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
4685
4696
  primary_result.setdefault("warnings", []).append(f"SetClipsLinked failed: {exc}")
4686
4697
 
4687
4698
  if delete_sources:
4688
- source_delete_items.extend(original_link_items if include_types else [item])
4699
+ # Only queue sources for deletion when every duplicate (primary and
4700
+ # linked) was verified live on the timeline. Synthetic/null-id
4701
+ # duplicates dropped 26 clips in the Portugal 2026-08-19 session.
4702
+ linked_rows = primary_result.get("linked_results") or []
4703
+ entry_verified = bool(primary_result.get("duplicate_verified")) and all(
4704
+ row.get("success") and row.get("duplicate_verified") for row in linked_rows
4705
+ )
4706
+ if entry_verified:
4707
+ source_delete_items.extend(original_link_items if include_types else [item])
4708
+ else:
4709
+ primary_result.setdefault("warnings", []).append(
4710
+ "source NOT deleted: duplicate(s) could not be verified on the timeline"
4711
+ )
4689
4712
 
4690
4713
  results.append(primary_result)
4691
4714
 
@@ -4696,7 +4719,9 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
4696
4719
  successful_source_ids = {
4697
4720
  result.get("source_clip_id")
4698
4721
  for result in results
4699
- if result.get("success") and result.get("source_clip_id")
4722
+ if result.get("success")
4723
+ and result.get("duplicate_verified")
4724
+ and result.get("source_clip_id")
4700
4725
  }
4701
4726
  delete_items = []
4702
4727
  seen_delete_ids = set()
@@ -4720,6 +4745,421 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
4720
4745
  return out
4721
4746
 
4722
4747
 
4748
+ # Timeline-item properties re-applied to shifted items after a ripple_insert
4749
+ # rebuild. Grades, keyframes, transitions, Fusion comps, and link state are NOT
4750
+ # in this list because the scripting API cannot read them off a live item in a
4751
+ # form that can be written back (the pre-mutation archive preserves them).
4752
+ _RIPPLE_RESTORE_PROPERTY_KEYS = (
4753
+ "Pan", "Tilt", "ZoomX", "ZoomY", "ZoomGang", "RotationAngle",
4754
+ "AnchorPointX", "AnchorPointY", "Pitch", "Yaw", "FlipX", "FlipY",
4755
+ "CropLeft", "CropRight", "CropTop", "CropBottom", "CropSoftness", "CropRetain",
4756
+ "CompositeMode", "Opacity", "Distortion", "Scaling", "ResizeFilter",
4757
+ "RetimeProcess", "MotionEstimation",
4758
+ )
4759
+
4760
+
4761
+ def _ripple_item_row(item, track_type, track_index):
4762
+ start = _frame_int(item.GetStart())
4763
+ end = _frame_int(item.GetEnd())
4764
+ duration = None
4765
+ if _has_method(item, "GetDuration"):
4766
+ try:
4767
+ duration = _frame_int(item.GetDuration())
4768
+ except Exception:
4769
+ duration = None
4770
+ if duration is None and start is not None and end is not None:
4771
+ duration = end - start
4772
+ try:
4773
+ name = item.GetName()
4774
+ except Exception:
4775
+ name = None
4776
+ return {
4777
+ "item": item,
4778
+ "clip_id": _safe_timeline_item_id(item),
4779
+ "name": name,
4780
+ "track_type": track_type,
4781
+ "track_index": track_index,
4782
+ "start": start,
4783
+ "end": end,
4784
+ "duration": duration,
4785
+ }
4786
+
4787
+
4788
+ def _ripple_public_row(row):
4789
+ return {k: row[k] for k in ("clip_id", "name", "track_type", "track_index", "start", "end", "duration")}
4790
+
4791
+
4792
+ def _timeline_ripple_insert_impl(proj, tl, p: Dict[str, Any], *, resolve=None) -> Dict[str, Any]:
4793
+ """Insert clip_infos at a record frame and shift all later items right.
4794
+
4795
+ There is no ripple-insert primitive in the scripting API, and shifting items
4796
+ by duplicate-then-delete corrupts the timeline when the shift is smaller
4797
+ than an item (AppendToTimeline into an occupied span returns null-id items;
4798
+ the Portugal 2026-08-19 session lost 26 clips that way). This action instead
4799
+ plans a rebuild: capture every later item's pool media + source trim, delete
4800
+ the tail (verified, non-ripple), re-append it shifted, then place the
4801
+ inserts into the opened gap. Tail is re-appended BEFORE the inserts so the
4802
+ worst mid-failure state is the original content with a gap, never lost tail.
4803
+ DRY-RUN by default; executing is confirm-token gated and the destructive
4804
+ hook archives the timeline first.
4805
+ """
4806
+ clip_infos = p.get("clip_infos") or p.get("clipInfos")
4807
+ if not isinstance(clip_infos, list) or not clip_infos:
4808
+ return _err(
4809
+ "ripple_insert requires clip_infos: non-empty list of "
4810
+ "{clip_id|media_pool_item_id, start_frame, end_frame, track_index?, media_type?}",
4811
+ code="INVALID_CLIP_INFOS",
4812
+ category="invalid_input",
4813
+ remediation="Pass the media-pool source ranges to insert; SOURCE frames, end-exclusive.",
4814
+ )
4815
+ mp = proj.GetMediaPool()
4816
+ if not mp:
4817
+ return _err("Failed to get MediaPool")
4818
+ root = mp.GetRootFolder()
4819
+ tl_start = _frame_int(tl.GetStartFrame()) or 0
4820
+
4821
+ # Insertion point: record_timecode (timeline TC, absolute) beats record_frame
4822
+ # (relative to timeline start by default, like every other server wrapper).
4823
+ record_timecode = p.get("record_timecode", p.get("recordTimecode"))
4824
+ if record_timecode is not None:
4825
+ insert_frame, err = _timeline_timecode_to_frame_id(tl, record_timecode)
4826
+ if err:
4827
+ return err
4828
+ else:
4829
+ rf_raw = p.get("record_frame", p.get("recordFrame"))
4830
+ if rf_raw is None:
4831
+ return _err(
4832
+ "ripple_insert requires record_frame (int) or record_timecode ('HH:MM:SS:FF')",
4833
+ code="MISSING_RECORD_POINT",
4834
+ category="invalid_input",
4835
+ remediation="Pass record_timecode from the viewer/playhead, or record_frame "
4836
+ "relative to timeline start (record_frame_mode='absolute' for raw frames).",
4837
+ )
4838
+ synthetic = {
4839
+ "recordFrame": rf_raw,
4840
+ "recordFrameMode": p.get("record_frame_mode", p.get("recordFrameMode", "relative")),
4841
+ }
4842
+ insert_frame, err = _normalize_record_frame(synthetic, 0, tl_start)
4843
+ if err:
4844
+ return err
4845
+
4846
+ # Build insert clipInfos back-to-back from the insert point (per-track cursors).
4847
+ built_inserts = []
4848
+ insert_summaries = []
4849
+ cursor_by_track: Dict[Any, int] = {}
4850
+ for idx, raw_ci in enumerate(clip_infos):
4851
+ if not isinstance(raw_ci, dict):
4852
+ return _err(f"clip_infos[{idx}] must be an object")
4853
+ ci = dict(raw_ci)
4854
+ ci.pop("record_frame", None)
4855
+ ci.pop("recordFrame", None)
4856
+ track_index = ci.get("trackIndex", ci.get("track_index", 1))
4857
+ media_type = ci.get("mediaType", ci.get("media_type", 1))
4858
+ ci["track_index"] = track_index
4859
+ ci["media_type"] = media_type
4860
+ key = (int(media_type), int(track_index))
4861
+ record = cursor_by_track.get(key, insert_frame)
4862
+ ci["record_frame"] = record
4863
+ ci["record_frame_mode"] = "absolute"
4864
+ info, ierr = _build_append_clip_info_dict(root, ci, idx, tl_start)
4865
+ if ierr:
4866
+ return ierr
4867
+ duration = int(info["endFrame"]) - int(info["startFrame"])
4868
+ if duration <= 0:
4869
+ return _err(f"clip_infos[{idx}] has non-positive duration (end_frame is end-exclusive)")
4870
+ cursor_by_track[key] = record + duration
4871
+ built_inserts.append(info)
4872
+ insert_summaries.append({
4873
+ "clip_id": raw_ci.get("clip_id") or raw_ci.get("media_pool_item_id"),
4874
+ "record_frame": record,
4875
+ "duration": duration,
4876
+ "track_index": int(track_index),
4877
+ "media_type": int(media_type),
4878
+ })
4879
+ shift = max(cursor - insert_frame for cursor in cursor_by_track.values())
4880
+
4881
+ # Scan the timeline: head stays, tail shifts, anything else blocks the plan.
4882
+ straddlers: List[Dict[str, Any]] = []
4883
+ tail_rows: List[Dict[str, Any]] = []
4884
+ blockers: List[Dict[str, Any]] = []
4885
+ locked_tracks: List[str] = []
4886
+ head_rows: List[Dict[str, Any]] = []
4887
+ for track_type in ("video", "audio"):
4888
+ for track_index in range(1, _timeline_track_count(tl, track_type) + 1):
4889
+ track_rows = [
4890
+ _ripple_item_row(item, track_type, track_index)
4891
+ for item in (tl.GetItemListInTrack(track_type, track_index) or [])
4892
+ ]
4893
+ track_has_tail = False
4894
+ for row in track_rows:
4895
+ if row["start"] is None or row["end"] is None:
4896
+ blockers.append({**_ripple_public_row(row), "reason": "unreadable start/end"})
4897
+ continue
4898
+ if row["end"] <= insert_frame:
4899
+ head_rows.append(row)
4900
+ elif row["start"] < insert_frame:
4901
+ straddlers.append(_ripple_public_row(row))
4902
+ else:
4903
+ tail_rows.append(row)
4904
+ track_has_tail = True
4905
+ if track_has_tail:
4906
+ try:
4907
+ if bool(tl.GetIsTrackLocked(track_type, track_index)):
4908
+ locked_tracks.append(f"{track_type}:{track_index}")
4909
+ except Exception:
4910
+ pass
4911
+ subtitle_blockers = 0
4912
+ for track_index in range(1, _timeline_track_count(tl, "subtitle") + 1):
4913
+ for item in (tl.GetItemListInTrack("subtitle", track_index) or []):
4914
+ item_start = _frame_int(item.GetStart())
4915
+ item_end = _frame_int(item.GetEnd())
4916
+ # A subtitle that STRADDLES the insert point is as much a blocker as
4917
+ # one after it: video/audio straddlers already refuse the plan, and
4918
+ # leaving a straddling subtitle in place silently desyncs it against
4919
+ # the shifted picture.
4920
+ if item_start is not None and item_start >= insert_frame:
4921
+ subtitle_blockers += 1
4922
+ elif item_end is not None and item_end > insert_frame:
4923
+ subtitle_blockers += 1
4924
+
4925
+ # Capture rebuild info + restorable properties for every tail item BEFORE
4926
+ # anything mutates (the live item objects die at delete time).
4927
+ tail_rows.sort(key=lambda r: (r["track_type"], r["track_index"], r["start"]))
4928
+ linked_tail_ids: List[str] = []
4929
+ for row in tail_rows:
4930
+ media_type = _timeline_media_type(row["track_type"])
4931
+ info, ierr = _append_clip_info_from_timeline_item(
4932
+ row["item"],
4933
+ row["track_index"],
4934
+ record_frame=row["start"] + shift,
4935
+ media_type=media_type,
4936
+ )
4937
+ if ierr:
4938
+ blockers.append({**_ripple_public_row(row), "reason": ierr.get("error", str(ierr))})
4939
+ continue
4940
+ row["info"] = info
4941
+ try:
4942
+ full_props = row["item"].GetProperty() or {}
4943
+ except Exception:
4944
+ full_props = {}
4945
+ row["props"] = {k: full_props[k] for k in _RIPPLE_RESTORE_PROPERTY_KEYS if k in full_props}
4946
+ try:
4947
+ if row["item"].GetLinkedItems():
4948
+ linked_tail_ids.append(row["clip_id"])
4949
+ except Exception:
4950
+ pass
4951
+
4952
+ # Every track shifts by the SAME amount (the longest inserted run), so any
4953
+ # track whose inserts are shorter than that — or which gets no insert at all
4954
+ # — is left with a gap at the insert point. That is ordinary ripple-insert
4955
+ # semantics, but it has to be reported: the readback below only checks the
4956
+ # positions it placed, so it cannot see the hole, and an unqualified
4957
+ # success over a timeline with black/silence in it is the wrong answer.
4958
+ gap_by_track: Dict[str, int] = {}
4959
+ for track_type in ("video", "audio"):
4960
+ media_type = _timeline_media_type(track_type)
4961
+ for track_index in range(1, _timeline_track_count(tl, track_type) + 1):
4962
+ cursor = cursor_by_track.get((int(media_type), int(track_index)))
4963
+ filled = (cursor - insert_frame) if cursor is not None else 0
4964
+ if filled < shift:
4965
+ gap_by_track[f"{track_type}:{track_index}"] = shift - filled
4966
+
4967
+ warnings = [
4968
+ "shifted items are re-created from pool media: grades, keyframes, transitions, "
4969
+ "Fusion comps, and link state on them are NOT preserved (the pre-mutation archive keeps them)",
4970
+ ]
4971
+ if gap_by_track:
4972
+ warnings.append(
4973
+ "every track shifts by the longest inserted run (%d frames); these tracks are "
4974
+ "left with a gap at the insert point: %s"
4975
+ % (shift, ", ".join(f"{track}={frames}f" for track, frames in sorted(gap_by_track.items())))
4976
+ )
4977
+ if linked_tail_ids:
4978
+ warnings.append(f"{len(linked_tail_ids)} shifted item(s) had linked items; links will not survive the shift")
4979
+ plan = {
4980
+ "insert_frame_absolute": insert_frame,
4981
+ "insert_frame_relative": insert_frame - tl_start,
4982
+ "shift_frames": shift,
4983
+ "inserts": insert_summaries,
4984
+ "tail_item_count": len(tail_rows),
4985
+ "head_item_count": len(head_rows),
4986
+ "tail_items": [_ripple_public_row(row) for row in tail_rows],
4987
+ "straddlers": straddlers,
4988
+ "blockers": blockers,
4989
+ "subtitle_items_after_insert_point": subtitle_blockers,
4990
+ "locked_tracks_with_tail": locked_tracks,
4991
+ "gap_frames_by_track": gap_by_track,
4992
+ "warnings": warnings,
4993
+ }
4994
+ feasible = not (straddlers or blockers or subtitle_blockers or locked_tracks)
4995
+ if not feasible:
4996
+ reasons = []
4997
+ if straddlers:
4998
+ reasons.append(f"insert point cuts through {len(straddlers)} item(s) — choose an item boundary")
4999
+ if blockers:
5000
+ reasons.append(f"{len(blockers)} later item(s) cannot be rebuilt (no pool media: titles/generators/Fusion comps)")
5001
+ if subtitle_blockers:
5002
+ reasons.append(f"{subtitle_blockers} subtitle item(s) after the insert point cannot be shifted via the API")
5003
+ if locked_tracks:
5004
+ reasons.append(f"locked track(s) hold items that must shift: {', '.join(locked_tracks)}")
5005
+ plan["infeasible_reasons"] = reasons
5006
+ if bool(p.get("dry_run", True)):
5007
+ return {"success": feasible, "dry_run": True, "plan": plan}
5008
+ if not feasible:
5009
+ return {
5010
+ "success": False,
5011
+ "dry_run": False,
5012
+ "plan": plan,
5013
+ "error": {
5014
+ "code": "RIPPLE_PLAN_BLOCKED",
5015
+ "category": "invalid_input",
5016
+ "retryable": False,
5017
+ "message": "; ".join(plan["infeasible_reasons"]),
5018
+ "remediation": "Re-run with dry_run=true, resolve the listed blockers, then execute.",
5019
+ },
5020
+ }
5021
+
5022
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
5023
+ return _issue_confirm_token(
5024
+ action="timeline.ripple_insert",
5025
+ params=p,
5026
+ preview={
5027
+ "operation": "timeline.ripple_insert",
5028
+ "warning": "Deletes and re-appends every later item shifted right; grades/keyframes/"
5029
+ "transitions/links on shifted items are not preserved (archive keeps them).",
5030
+ "insert_frame_absolute": insert_frame,
5031
+ "shift_frames": shift,
5032
+ "inserted_clips": len(built_inserts),
5033
+ "tail_items_shifted": len(tail_rows),
5034
+ },
5035
+ )
5036
+ blocked = _consume_confirm_token(action="timeline.ripple_insert", params=p)
5037
+ if blocked:
5038
+ return blocked
5039
+
5040
+ # Execute: delete tail -> re-append tail shifted -> place inserts into the gap.
5041
+ # Hold the Edit page once for the whole rebuild: the delete's own guard
5042
+ # nests harmlessly inside, and the appends/restores run without a page
5043
+ # flip per call.
5044
+ tail_items = [row["item"] for row in tail_rows]
5045
+ with _edit_page_for_timeline_edits(resolve):
5046
+ if tail_items and not _timeline_delete_clips_verified(tl, tail_items, False, resolve=resolve):
5047
+ return _err(
5048
+ "ripple_insert aborted before any change: tail items could not be removed "
5049
+ "for re-placement (delete readback still finds them)",
5050
+ code="RIPPLE_DELETE_FAILED",
5051
+ category="resolve_api",
5052
+ remediation="Check track locks and retry; the timeline is unchanged.",
5053
+ )
5054
+ failures: List[Dict[str, Any]] = []
5055
+ for row in tail_rows:
5056
+ try:
5057
+ out_items = mp.AppendToTimeline([row["info"]])
5058
+ except Exception as exc:
5059
+ out_items = None
5060
+ row["append_error"] = str(exc)
5061
+ if not out_items:
5062
+ failures.append({**_ripple_public_row(row),
5063
+ "stage": "tail_reappend",
5064
+ "expected_start": row["start"] + shift,
5065
+ "error": row.get("append_error", "AppendToTimeline returned no item")})
5066
+ for idx, info in enumerate(built_inserts):
5067
+ try:
5068
+ out_items = mp.AppendToTimeline([info])
5069
+ except Exception as exc:
5070
+ out_items = None
5071
+ insert_summaries[idx]["append_error"] = str(exc)
5072
+ if not out_items:
5073
+ failures.append({**insert_summaries[idx], "stage": "insert"})
5074
+
5075
+ # Restore captured transform/crop/composite/retime properties on shifted items.
5076
+ restored = 0
5077
+ restore_failures = 0
5078
+ by_track: Dict[Any, Dict[int, Any]] = {}
5079
+ for track_type in ("video", "audio"):
5080
+ for track_index in range(1, _timeline_track_count(tl, track_type) + 1):
5081
+ slot = by_track.setdefault((track_type, track_index), {})
5082
+ for item in (tl.GetItemListInTrack(track_type, track_index) or []):
5083
+ item_start = _frame_int(item.GetStart())
5084
+ if item_start is not None:
5085
+ slot[item_start] = item
5086
+ for row in tail_rows:
5087
+ if not row.get("props"):
5088
+ continue
5089
+ new_item = by_track.get((row["track_type"], row["track_index"]), {}).get(row["start"] + shift)
5090
+ if new_item is None:
5091
+ restore_failures += 1
5092
+ continue
5093
+ applied_any = False
5094
+ for key, value in row["props"].items():
5095
+ if value is None:
5096
+ continue
5097
+ # Fresh appends carry default values for most keys — only write the
5098
+ # ones that actually differ (some keys reject their own defaults).
5099
+ try:
5100
+ if new_item.GetProperty(key) == value:
5101
+ continue
5102
+ except Exception:
5103
+ pass
5104
+ try:
5105
+ if new_item.SetProperty(key, value):
5106
+ applied_any = True
5107
+ else:
5108
+ restore_failures += 1
5109
+ except Exception:
5110
+ restore_failures += 1
5111
+ if applied_any:
5112
+ restored += 1
5113
+
5114
+ # Full readback: every expected (start, duration) must exist on its track.
5115
+ expected: Dict[Any, List[Dict[str, Any]]] = {}
5116
+ for row in head_rows:
5117
+ expected.setdefault((row["track_type"], row["track_index"]), []).append(
5118
+ {"start": row["start"], "duration": row["duration"], "role": "head"})
5119
+ for row in tail_rows:
5120
+ expected.setdefault((row["track_type"], row["track_index"]), []).append(
5121
+ {"start": row["start"] + shift, "duration": row["duration"], "role": "shifted"})
5122
+ for summary in insert_summaries:
5123
+ media_type = summary["media_type"]
5124
+ track_type = "video" if media_type == 1 else "audio"
5125
+ expected.setdefault((track_type, summary["track_index"]), []).append(
5126
+ {"start": summary["record_frame"], "duration": summary["duration"], "role": "insert"})
5127
+ missing: List[Dict[str, Any]] = []
5128
+ after_counts: Dict[str, int] = {}
5129
+ for (track_type, track_index), rows in expected.items():
5130
+ live = {}
5131
+ for item in (tl.GetItemListInTrack(track_type, track_index) or []):
5132
+ row = _ripple_item_row(item, track_type, track_index)
5133
+ live[row["start"]] = row["duration"]
5134
+ after_counts[f"{track_type}:{track_index}"] = len(
5135
+ tl.GetItemListInTrack(track_type, track_index) or [])
5136
+ for exp in rows:
5137
+ if live.get(exp["start"]) != exp["duration"]:
5138
+ missing.append({"track": f"{track_type}:{track_index}", **exp,
5139
+ "found_duration": live.get(exp["start"])})
5140
+ success = not failures and not missing
5141
+ result = {
5142
+ "success": success,
5143
+ "dry_run": False,
5144
+ "insert_frame_absolute": insert_frame,
5145
+ "shift_frames": shift,
5146
+ "inserted_clips": len(built_inserts),
5147
+ "tail_items_shifted": len(tail_rows),
5148
+ "properties_restored_items": restored,
5149
+ "property_restore_failures": restore_failures,
5150
+ "readback": {"after_counts": after_counts, "missing": missing},
5151
+ "gap_frames_by_track": gap_by_track,
5152
+ "warnings": warnings,
5153
+ }
5154
+ if failures:
5155
+ result["failures"] = failures
5156
+ result["remediation"] = (
5157
+ "Some items failed to re-append; the pre-mutation archive holds the full original "
5158
+ "timeline — inspect timeline_versioning list_versions / rollback_to_version."
5159
+ )
5160
+ return result
5161
+
5162
+
4723
5163
  def _range_frames_from_params(tl, p: Dict[str, Any]):
4724
5164
  if p.get("use_mark_in_out", p.get("useMarkInOut", False)):
4725
5165
  mark = tl.GetMarkInOut() or {}
@@ -5672,6 +6112,13 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5672
6112
  out["success"] = not missing and not out.get("source_error")
5673
6113
  out["would_apply_cdl"] = cdl is not None
5674
6114
  out["would_copy_grade"] = source_item is not None
6115
+ if cdl is not None:
6116
+ node_index = out["cdl"]["validation"]["cdl"]["NodeIndex"]
6117
+ out["node_preflight"] = [
6118
+ {"timeline_item_id": _safe_timeline_item_id(item),
6119
+ **_cdl_node_preflight(item, node_index)[1]}
6120
+ for item in targets
6121
+ ]
5675
6122
  return out
5676
6123
  if missing or out.get("source_error"):
5677
6124
  out["success"] = False
@@ -5679,18 +6126,25 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5679
6126
  results = []
5680
6127
  if cdl is not None:
5681
6128
  normalized = out["cdl"]["normalized"]
6129
+ node_index = out["cdl"]["validation"]["cdl"]["NodeIndex"]
5682
6130
  for item in targets:
6131
+ row = {"timeline_item_id": _safe_timeline_item_id(item)}
6132
+ # Read the node count before SetCDL (1-based NodeIndex, README line 6):
6133
+ # a bare false on a missing node is undiagnosable after the fact.
6134
+ node_ok, preflight = _cdl_node_preflight(item, node_index)
6135
+ if not node_ok:
6136
+ row.update({"set_cdl": False, "reason": preflight.get("reason"),
6137
+ "node_preflight": preflight})
6138
+ results.append(row)
6139
+ continue
5683
6140
  try:
5684
- results.append({
5685
- "timeline_item_id": _safe_timeline_item_id(item),
5686
- "set_cdl": bool(item.SetCDL(normalized)),
5687
- })
6141
+ row["set_cdl"] = bool(item.SetCDL(normalized))
6142
+ if not row["set_cdl"]:
6143
+ row["diagnosis"] = _cdl_failure_diagnosis(item, preflight)
5688
6144
  except Exception as exc:
5689
- results.append({
5690
- "timeline_item_id": _safe_timeline_item_id(item),
5691
- "set_cdl": False,
5692
- "error": str(exc),
5693
- })
6145
+ row["set_cdl"] = False
6146
+ row["error"] = str(exc)
6147
+ results.append(row)
5694
6148
  out["cdl_results"] = results
5695
6149
  if source_item is not None:
5696
6150
  try:
@@ -22285,7 +22739,7 @@ _TIMELINE_ACTIONS = [
22285
22739
  "add_track", "delete_track", "get_track_sub_type", "set_track_enable",
22286
22740
  "get_track_enabled", "set_track_lock", "get_track_locked", "get_track_name",
22287
22741
  "set_track_name", "get_items", "delete_clips", "set_clips_linked", "duplicate",
22288
- "duplicate_clips", "copy_clips", "move_clips", "copy_range", "duplicate_range",
22742
+ "duplicate_clips", "copy_clips", "move_clips", "ripple_insert", "copy_range", "duplicate_range",
22289
22743
  "overwrite_range", "lift_range", "story_spine_report", "create_variant_from_ranges",
22290
22744
  "bulk_set_item_properties", "apply_look_to_items", "thumbnail_contact_sheet",
22291
22745
  "marker_thumbnail_review", "edit_kernel_capabilities", "probe_edit_kernel_item",
@@ -22368,7 +22822,24 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22368
22822
  include_linked=True duplicates linked audio and restores link state.
22369
22823
  # example: action_help(name='<action_name>')
22370
22824
  copy_clips(...) -> {results, count} — alias for duplicate_clips.
22371
- move_clips(...) -> {results, count, deleted_sources} — duplicate, then delete successfully duplicated sources.
22825
+ move_clips(...) -> {results, count, deleted_sources} — duplicate, then delete VERIFIED duplicated sources.
22826
+ Sources whose duplicate cannot be re-verified on the timeline are NOT deleted
22827
+ (AppendToTimeline can return null-id items, e.g. into an occupied span).
22828
+ NEVER use move_clips to open a gap for an insert — that is what ripple_insert is for.
22829
+ ripple_insert(clip_infos, record_frame|record_timecode, record_frame_mode?, dry_run?, confirm_token?) -> {success, plan | readback}
22830
+ Insert media-pool source ranges at a record point and shift ALL later video/audio
22831
+ items right by the inserted duration. DRY-RUN by default (returns the full plan);
22832
+ executing is DESTRUCTIVE — confirm-token gated, timeline version archived first.
22833
+ clip_infos rows: {clip_id|media_pool_item_id, start_frame, end_frame (SOURCE,
22834
+ end-exclusive), track_index?, media_type?} placed back-to-back at the insert point.
22835
+ record_frame is relative to timeline start by default (record_frame_mode
22836
+ absolute|auto accepted); record_timecode takes timeline 'HH:MM:SS:FF'. Refuses when
22837
+ the insert point cuts through an item, when shifted items lack pool media
22838
+ (titles/generators/Fusion comps), when subtitle items would need shifting, or when
22839
+ a locked track holds tail items. Shifted items are re-created from pool media with
22840
+ transform/crop/composite/retime re-applied; grades, keyframes, transitions, and
22841
+ link state on shifted items are NOT preserved (the pre-mutation archive keeps them).
22842
+ # example: action_help(name='<action_name>')
22372
22843
  copy_range/duplicate_range(start_frame, end_frame, record_frame, ...) -> {results, count}
22373
22844
  overwrite_range(start_frame, end_frame, record_frame, ...) -> {results, count}
22374
22845
  lift_range(start_frame, end_frame, allow_partial_item_delete?, ripple?) -> {success, deleted}
@@ -22387,6 +22858,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22387
22858
  # example: action_help(name='<action_name>')
22388
22859
  thumbnail_contact_sheet(frames?|max_samples?, analysis_root?) -> {path, samples}
22389
22860
  frames are relative to the timeline start (frame 0 = first frame), like marker frameIds.
22861
+ NOT WYSIWYG: thumbnails are decoded from source media and exclude Fusion
22862
+ composition output (grade reflection is unreliable). Never present a
22863
+ contact sheet as proof of a Fusion/grade change — use gallery_stills
22864
+ grab_and_export or an extracted RENDERED frame for that.
22390
22865
  marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance}
22391
22866
  edit_kernel_capabilities() -> {supported, partially_supported, unsupported}
22392
22867
  probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count}
@@ -22700,6 +23175,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22700
23175
  return _timeline_duplicate_clips_impl(proj, tl, p)
22701
23176
  elif action == "move_clips":
22702
23177
  return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True, resolve=get_resolve())
23178
+ elif action == "ripple_insert":
23179
+ return _timeline_ripple_insert_impl(proj, tl, p, resolve=get_resolve())
22703
23180
  elif action in {"copy_range", "duplicate_range"}:
22704
23181
  return _timeline_copy_range_impl(proj, tl, p)
22705
23182
  elif action == "overwrite_range":
@@ -24063,6 +24540,61 @@ def _probe_color_node_graph(proj, item, p: Dict[str, Any]):
24063
24540
  return snapshot
24064
24541
 
24065
24542
 
24543
+ def _cdl_node_preflight(item, node_index):
24544
+ """Verify the SetCDL target node exists (NodeIndex is 1-based per the
24545
+ scripting README) via the current Graph API — TimelineItem.GetNumNodes is
24546
+ deprecated; the node count lives on item.GetNodeGraph()."""
24547
+ info: Dict[str, Any] = {"node_index": node_index, "num_nodes": None, "graph_available": False}
24548
+ try:
24549
+ graph = item.GetNodeGraph()
24550
+ except Exception as exc:
24551
+ info["reason"] = f"GetNodeGraph failed: {exc}"
24552
+ return False, info
24553
+ if not graph:
24554
+ info["reason"] = "item has no node graph"
24555
+ return False, info
24556
+ info["graph_available"] = True
24557
+ try:
24558
+ num_nodes = graph.GetNumNodes()
24559
+ except Exception as exc:
24560
+ info["reason"] = f"GetNumNodes failed: {exc}"
24561
+ return False, info
24562
+ info["num_nodes"] = num_nodes
24563
+ if not isinstance(num_nodes, int) or num_nodes < 1:
24564
+ info["reason"] = "node graph reports no nodes"
24565
+ return False, info
24566
+ if int(node_index) > num_nodes:
24567
+ info["reason"] = f"NodeIndex {node_index} exceeds node count {num_nodes} (NodeIndex is 1-based)"
24568
+ return False, info
24569
+ return True, info
24570
+
24571
+
24572
+ def _cdl_failure_diagnosis(item, preflight):
24573
+ """SetCDL returned False on a payload that validated and a node that exists —
24574
+ report what is knowable instead of a bare false."""
24575
+ clip_type = None
24576
+ try:
24577
+ mpi = item.GetMediaPoolItem()
24578
+ if mpi:
24579
+ clip_type = mpi.GetClipProperty("Type")
24580
+ except Exception:
24581
+ pass
24582
+ diagnosis = {
24583
+ "reason": "set_cdl_returned_false",
24584
+ "node_preflight": preflight,
24585
+ "clip_type": clip_type,
24586
+ "remediation": (
24587
+ "Resolve rejected the CDL despite a valid payload and an existing node. "
24588
+ "Common causes: still-image or generator item, a locked/read-only grade "
24589
+ "version, or the active color science ignoring CDL. Verify on the Color "
24590
+ "page and prove any resulting grade with gallery_stills grab_and_export."
24591
+ ),
24592
+ }
24593
+ if clip_type and "still" in str(clip_type).lower():
24594
+ diagnosis["reason"] = "set_cdl_rejected_on_still_item"
24595
+ return diagnosis
24596
+
24597
+
24066
24598
  def _safe_set_cdl(item, p: Dict[str, Any]):
24067
24599
  validation, err = _validate_cdl_payload(p.get("cdl"))
24068
24600
  if err:
@@ -24070,13 +24602,27 @@ def _safe_set_cdl(item, p: Dict[str, Any]):
24070
24602
  if not validation["valid"]:
24071
24603
  return {"success": False, "validation": validation}
24072
24604
  normalized = _normalize_cdl(validation["cdl"])
24605
+ node_ok, preflight = _cdl_node_preflight(item, validation["cdl"]["NodeIndex"])
24073
24606
  if p.get("dry_run"):
24074
- return _ok(validation=validation, normalized=normalized)
24075
- return {
24076
- "success": bool(item.SetCDL(normalized)),
24607
+ return _ok(validation=validation, normalized=normalized, node_preflight=preflight)
24608
+ if not node_ok:
24609
+ return {
24610
+ "success": False,
24611
+ "validation": validation,
24612
+ "normalized": normalized,
24613
+ "node_preflight": preflight,
24614
+ "reason": preflight.get("reason"),
24615
+ }
24616
+ success = bool(item.SetCDL(normalized))
24617
+ out = {
24618
+ "success": success,
24077
24619
  "validation": validation,
24078
24620
  "normalized": normalized,
24621
+ "node_preflight": preflight,
24079
24622
  }
24623
+ if not success:
24624
+ out["diagnosis"] = _cdl_failure_diagnosis(item, preflight)
24625
+ return out
24080
24626
 
24081
24627
 
24082
24628
  def _timeline_items_for_grade_copy(tl, target_ids):
@@ -25017,6 +25563,48 @@ def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]:
25017
25563
  ],
25018
25564
  }
25019
25565
 
25566
+ # CreateMagicMask mode strings per the scripting README ("F", "B", "BI").
25567
+ # The granular server historically defaulted to "Forward", which Resolve
25568
+ # rejects — accept the long spellings as aliases and normalize.
25569
+ _MAGIC_MASK_MODES = {
25570
+ "F": "F", "FORWARD": "F",
25571
+ "B": "B", "BACKWARD": "B",
25572
+ "BI": "BI", "BIDIRECTION": "BI", "BIDIRECTIONAL": "BI",
25573
+ }
25574
+
25575
+
25576
+ def _magic_mask_hitl_result(*, regenerate: bool = False) -> Dict[str, Any]:
25577
+ """Magic Mask v2 isolates via operator CLICKS (manual ch. 139: strokes are
25578
+ the legacy v1 interface). The scripting API can only trigger tracking; it
25579
+ cannot place clicks, so with none present CreateMagicMask returns False.
25580
+ Return the human step instead of a bare false."""
25581
+ why = (
25582
+ "RegenerateMagicMask returned False — there is no existing Magic Mask "
25583
+ "click set on this item to regenerate."
25584
+ if regenerate else
25585
+ "CreateMagicMask returned False — the scripting API cannot place the "
25586
+ "subject clicks Magic Mask v2 requires, so no isolation exists yet."
25587
+ )
25588
+ return {
25589
+ "success": False,
25590
+ "needs_hitl": True,
25591
+ "hitl": {
25592
+ "feature": "Magic Mask v2",
25593
+ "page": "Color",
25594
+ "why": why,
25595
+ "steps": [
25596
+ "Open the Color page and select this clip",
25597
+ "Open the Magic Mask palette",
25598
+ "Click the plus eyedropper on the subject in the Viewer "
25599
+ "(shift to red minus clicks to remove areas)",
25600
+ "Press Track Forward (clicks track together; tracked frames show blue)",
25601
+ ],
25602
+ "verify": "Prove the isolation with gallery_stills grab_and_export "
25603
+ "(rendered frame) — never a media-pool thumbnail",
25604
+ },
25605
+ }
25606
+
25607
+
25020
25608
  @mcp.tool()
25021
25609
  @_guard_missing_params
25022
25610
  @_destructive_op("timeline_item_color")
@@ -25035,7 +25623,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25035
25623
  grade_evidence_base -> {evidence_base: str, structured: {coverage, version_snapshot, node_graph, color_group, warnings}}
25036
25624
  bulk_match_to_hero -> {hero, proposals: [{target_id, name, proposed_cdl|copy_source, warnings}], blocked: [...], confirm_token?}
25037
25625
  propose_grade -> {accepted: bool, validation, plan_id?, preview_path?, error?}
25038
- safe_set_cdl -> {success, validation, normalized}
25626
+ safe_set_cdl -> {success, validation, normalized, node_preflight, diagnosis?}
25039
25627
  safe_copy_grade -> {success, targets, missing}
25040
25628
  safe_apply_drx -> {success, path, source} # first call may return confirm_token
25041
25629
  grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards}
@@ -25068,8 +25656,10 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25068
25656
  get_fusion_cache(...) -> {enabled}
25069
25657
 
25070
25658
  Guarded mutators (PREFERRED for grade work):
25071
- safe_set_cdl(cdl, dry_run?, ...) -> {success, validation, normalized}
25659
+ safe_set_cdl(cdl, dry_run?, ...) -> {success, validation, normalized, node_preflight, diagnosis?}
25072
25660
  Validates input, supports dry_run, returns normalized CDL. Use this for primary corrections.
25661
+ Reads the node graph's GetNumNodes before SetCDL (NodeIndex is 1-BASED); a false
25662
+ SetCDL comes back with a structured diagnosis (missing node, still item, ...).
25073
25663
  # example: action_help(name='<action_name>')
25074
25664
  safe_copy_grade(target_ids, dry_run?, ...) -> {success, targets, missing}
25075
25665
  Copies grade to N items; dry_run reports targets without mutating.
@@ -25101,8 +25691,12 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25101
25691
  set_fusion_cache(enabled, ...) -> {success}
25102
25692
  stabilize(...) -> {success}
25103
25693
  smart_reframe(...) -> {success}
25104
- create_magic_mask(mode, ...) -> {success} — mode: "F" forward, "B" backward, "BI" bidirectional
25105
- regenerate_magic_mask(...) -> {success}
25694
+ create_magic_mask(mode, ...) -> {success | needs_hitl, hitl} — mode: "F" forward, "B" backward, "BI" bidirectional
25695
+ Magic Mask v2 needs operator CLICKS on the subject; the API cannot place them.
25696
+ With no clicks present this returns needs_hitl=true plus the exact human steps
25697
+ (Color page > Magic Mask palette > click subject > Track Forward). Do not treat
25698
+ a create_magic_mask call as having isolated anything without a rendered-frame proof.
25699
+ regenerate_magic_mask(...) -> {success | needs_hitl, hitl}
25106
25700
 
25107
25701
  Default: track_type="video", track_index=1, item_index=0
25108
25702
 
@@ -25213,9 +25807,21 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25213
25807
  elif action == "smart_reframe":
25214
25808
  return {"success": bool(item.SmartReframe())}
25215
25809
  elif action == "create_magic_mask":
25216
- return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
25810
+ mode = _MAGIC_MASK_MODES.get(str(p.get("mode", "F")).strip().upper())
25811
+ if not mode:
25812
+ return _err(
25813
+ "mode must be 'F' (forward), 'B' (backward), or 'BI' (bidirection)",
25814
+ code="INVALID_MAGIC_MASK_MODE",
25815
+ category="invalid_input",
25816
+ remediation="Pass one of the README mode strings: F, B, BI.",
25817
+ )
25818
+ if bool(item.CreateMagicMask(mode)):
25819
+ return {"success": True, "mode": mode}
25820
+ return _magic_mask_hitl_result()
25217
25821
  elif action == "regenerate_magic_mask":
25218
- return {"success": bool(item.RegenerateMagicMask())}
25822
+ if bool(item.RegenerateMagicMask()):
25823
+ return {"success": True}
25824
+ return _magic_mask_hitl_result(regenerate=True)
25219
25825
  return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask","action_help",*_COLOR_GRADE_KERNEL_ACTIONS])
25220
25826
 
25221
25827
 
@@ -25364,6 +25970,12 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
25364
25970
  untouched, and folder_path itself is removed only if this call created it
25365
25971
  and left it empty. With cleanup false the files are moved up into
25366
25972
  folder_path without overwriting anything already there.
25973
+
25974
+ WYSIWYG PROOF RULE: grab_and_export (or an exported gallery still / extracted
25975
+ RENDERED frame) is the ONLY acceptable visual evidence for a Fusion or grade
25976
+ claim. Media-pool thumbnails and thumbnail contact sheets are decoded from
25977
+ source media — they do NOT show Fusion composition output, so a before/after
25978
+ built from thumbnails proves nothing.
25367
25979
  """
25368
25980
  p = _params(params)
25369
25981
  _, proj, err = _check()
@@ -26750,6 +27362,15 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26750
27362
 
26751
27363
  Use timeline_item_fusion to add/delete/import/export comps on items.
26752
27364
 
27365
+ RENDER WARNING: whether a comp created via AddFusionComp / edited here is
27366
+ honoured at render is Resolve-version-dependent — a wired
27367
+ MediaIn->Blur->MediaOut comp rendered on Studio 19.1.3.7 (2026-08-02), but
27368
+ on Studio 21.0.4 the same Blur configuration AND a Transform variant both
27369
+ delivered renders bit-identical to the no-comp baseline (2026-08-20), and
27370
+ no API selects an item's active composition (see resolve_control api_truth
27371
+ query='AddFusionComp'). Prove any Fusion effect with gallery_stills
27372
+ grab_and_export or a rendered frame, never with comp readback.
27373
+
26753
27374
  Actions:
26754
27375
  add_tool(tool_type, x?, y?, name?) -> {tool_name, tool_type}
26755
27376
  delete_tool(tool_name) -> {success}