davinci-resolve-mcp 2.98.8 → 2.99.1

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.1"
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 {}
@@ -5599,8 +6039,15 @@ def _timeline_bulk_set_item_properties(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5599
6039
  results.append({"index": index, "success": False, "error": f"timeline item not found: {item_id}"})
5600
6040
  continue
5601
6041
  properties = _merge_property_groups(op)
5602
- if not properties:
5603
- results.append({"index": index, "success": False, "error": "op requires properties, transform, crop, composite, audio, or direct property keys"})
6042
+ # clip_color and enabled are not SetProperty keys, so they never reach
6043
+ # `properties`. Requiring a non-empty `properties` made a colour-only op
6044
+ # — the shape triage actually sends, `{"timeline_item_id": id,
6045
+ # "clip_color": "Apricot"}` — bail here, which left the clip_color and
6046
+ # enabled branches below unreachable on exactly the ops that need them.
6047
+ wants_color = "clip_color" in op
6048
+ wants_enabled = "enabled" in op
6049
+ if not properties and not wants_color and not wants_enabled:
6050
+ results.append({"index": index, "success": False, "error": "op requires properties, transform, crop, composite, audio, clip_color, enabled, or direct property keys"})
5604
6051
  continue
5605
6052
  item_result = {
5606
6053
  "index": index,
@@ -5610,6 +6057,10 @@ def _timeline_bulk_set_item_properties(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5610
6057
  }
5611
6058
  if dry_run:
5612
6059
  item_result.update({"success": True, "would_set": properties})
6060
+ if wants_color:
6061
+ item_result["would_set_clip_color"] = op["clip_color"]
6062
+ if wants_enabled:
6063
+ item_result["would_set_enabled"] = bool(op["enabled"])
5613
6064
  results.append(item_result)
5614
6065
  continue
5615
6066
  for key, value in properties.items():
@@ -5625,17 +6076,33 @@ def _timeline_bulk_set_item_properties(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5625
6076
  except Exception as exc:
5626
6077
  row["readback_error"] = str(exc)
5627
6078
  item_result["properties"][key] = row
5628
- if "clip_color" in op:
6079
+ outcomes = [bool(row.get("success")) for row in item_result["properties"].values()]
6080
+ if wants_color:
6081
+ # Routed through the checked helper rather than the bare bool: a name
6082
+ # outside the 16-name Edit-page palette is refused with False and no
6083
+ # reason, and a generator or title takes the call, returns True and
6084
+ # drops the colour (issue #124). The detail only appears on failure,
6085
+ # so the success shape stays the plain bool callers already read.
5629
6086
  try:
5630
- item_result["clip_color"] = bool(item.SetClipColor(op["clip_color"]))
6087
+ outcome = _set_clip_color_checked(item, op["clip_color"], kind="timeline item")
5631
6088
  except Exception as exc:
5632
- item_result["clip_color"] = {"success": False, "error": str(exc)}
5633
- if "enabled" in op:
6089
+ outcome = {"success": False, "error": str(exc)}
6090
+ applied = bool(outcome.get("success"))
6091
+ item_result["clip_color"] = applied
6092
+ if not applied:
6093
+ item_result["clip_color_detail"] = outcome
6094
+ outcomes.append(applied)
6095
+ if wants_enabled:
5634
6096
  try:
5635
- item_result["enabled"] = bool(item.SetClipEnabled(bool(op["enabled"])))
6097
+ enabled_ok = bool(item.SetClipEnabled(bool(op["enabled"])))
6098
+ item_result["enabled"] = enabled_ok
5636
6099
  except Exception as exc:
6100
+ enabled_ok = False
5637
6101
  item_result["enabled"] = {"success": False, "error": str(exc)}
5638
- item_result["success"] = all(row.get("success") for row in item_result["properties"].values())
6102
+ outcomes.append(enabled_ok)
6103
+ # all([]) is True, so a colour-only op used to be able to report success
6104
+ # no matter what SetClipColor did. Every branch that ran now votes.
6105
+ item_result["success"] = all(outcomes)
5639
6106
  results.append(item_result)
5640
6107
  return {"success": all(row.get("success") for row in results), "results": results, "op_count": len(ops)}
5641
6108
 
@@ -5672,6 +6139,13 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5672
6139
  out["success"] = not missing and not out.get("source_error")
5673
6140
  out["would_apply_cdl"] = cdl is not None
5674
6141
  out["would_copy_grade"] = source_item is not None
6142
+ if cdl is not None:
6143
+ node_index = out["cdl"]["validation"]["cdl"]["NodeIndex"]
6144
+ out["node_preflight"] = [
6145
+ {"timeline_item_id": _safe_timeline_item_id(item),
6146
+ **_cdl_node_preflight(item, node_index)[1]}
6147
+ for item in targets
6148
+ ]
5675
6149
  return out
5676
6150
  if missing or out.get("source_error"):
5677
6151
  out["success"] = False
@@ -5679,18 +6153,25 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5679
6153
  results = []
5680
6154
  if cdl is not None:
5681
6155
  normalized = out["cdl"]["normalized"]
6156
+ node_index = out["cdl"]["validation"]["cdl"]["NodeIndex"]
5682
6157
  for item in targets:
6158
+ row = {"timeline_item_id": _safe_timeline_item_id(item)}
6159
+ # Read the node count before SetCDL (1-based NodeIndex, README line 6):
6160
+ # a bare false on a missing node is undiagnosable after the fact.
6161
+ node_ok, preflight = _cdl_node_preflight(item, node_index)
6162
+ if not node_ok:
6163
+ row.update({"set_cdl": False, "reason": preflight.get("reason"),
6164
+ "node_preflight": preflight})
6165
+ results.append(row)
6166
+ continue
5683
6167
  try:
5684
- results.append({
5685
- "timeline_item_id": _safe_timeline_item_id(item),
5686
- "set_cdl": bool(item.SetCDL(normalized)),
5687
- })
6168
+ row["set_cdl"] = bool(item.SetCDL(normalized))
6169
+ if not row["set_cdl"]:
6170
+ row["diagnosis"] = _cdl_failure_diagnosis(item, preflight)
5688
6171
  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
- })
6172
+ row["set_cdl"] = False
6173
+ row["error"] = str(exc)
6174
+ results.append(row)
5694
6175
  out["cdl_results"] = results
5695
6176
  if source_item is not None:
5696
6177
  try:
@@ -22285,7 +22766,7 @@ _TIMELINE_ACTIONS = [
22285
22766
  "add_track", "delete_track", "get_track_sub_type", "set_track_enable",
22286
22767
  "get_track_enabled", "set_track_lock", "get_track_locked", "get_track_name",
22287
22768
  "set_track_name", "get_items", "delete_clips", "set_clips_linked", "duplicate",
22288
- "duplicate_clips", "copy_clips", "move_clips", "copy_range", "duplicate_range",
22769
+ "duplicate_clips", "copy_clips", "move_clips", "ripple_insert", "copy_range", "duplicate_range",
22289
22770
  "overwrite_range", "lift_range", "story_spine_report", "create_variant_from_ranges",
22290
22771
  "bulk_set_item_properties", "apply_look_to_items", "thumbnail_contact_sheet",
22291
22772
  "marker_thumbnail_review", "edit_kernel_capabilities", "probe_edit_kernel_item",
@@ -22368,7 +22849,24 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22368
22849
  include_linked=True duplicates linked audio and restores link state.
22369
22850
  # example: action_help(name='<action_name>')
22370
22851
  copy_clips(...) -> {results, count} — alias for duplicate_clips.
22371
- move_clips(...) -> {results, count, deleted_sources} — duplicate, then delete successfully duplicated sources.
22852
+ move_clips(...) -> {results, count, deleted_sources} — duplicate, then delete VERIFIED duplicated sources.
22853
+ Sources whose duplicate cannot be re-verified on the timeline are NOT deleted
22854
+ (AppendToTimeline can return null-id items, e.g. into an occupied span).
22855
+ NEVER use move_clips to open a gap for an insert — that is what ripple_insert is for.
22856
+ ripple_insert(clip_infos, record_frame|record_timecode, record_frame_mode?, dry_run?, confirm_token?) -> {success, plan | readback}
22857
+ Insert media-pool source ranges at a record point and shift ALL later video/audio
22858
+ items right by the inserted duration. DRY-RUN by default (returns the full plan);
22859
+ executing is DESTRUCTIVE — confirm-token gated, timeline version archived first.
22860
+ clip_infos rows: {clip_id|media_pool_item_id, start_frame, end_frame (SOURCE,
22861
+ end-exclusive), track_index?, media_type?} placed back-to-back at the insert point.
22862
+ record_frame is relative to timeline start by default (record_frame_mode
22863
+ absolute|auto accepted); record_timecode takes timeline 'HH:MM:SS:FF'. Refuses when
22864
+ the insert point cuts through an item, when shifted items lack pool media
22865
+ (titles/generators/Fusion comps), when subtitle items would need shifting, or when
22866
+ a locked track holds tail items. Shifted items are re-created from pool media with
22867
+ transform/crop/composite/retime re-applied; grades, keyframes, transitions, and
22868
+ link state on shifted items are NOT preserved (the pre-mutation archive keeps them).
22869
+ # example: action_help(name='<action_name>')
22372
22870
  copy_range/duplicate_range(start_frame, end_frame, record_frame, ...) -> {results, count}
22373
22871
  overwrite_range(start_frame, end_frame, record_frame, ...) -> {results, count}
22374
22872
  lift_range(start_frame, end_frame, allow_partial_item_delete?, ripple?) -> {success, deleted}
@@ -22382,11 +22880,17 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22382
22880
  track_type, default 1); missing tracks are added, so V2/V3 multicam angles survive.
22383
22881
  # example: action_help(name='<action_name>')
22384
22882
  bulk_set_item_properties(ops, dry_run?, readback?) -> {results, op_count}
22883
+ an op may carry clip_color and/or enabled with no other payload -- that is
22884
+ the triage shape (paint N clips in one call).
22385
22885
  # example: action_help(name='<action_name>')
22386
22886
  apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?) -> {success}
22387
22887
  # example: action_help(name='<action_name>')
22388
22888
  thumbnail_contact_sheet(frames?|max_samples?, analysis_root?) -> {path, samples}
22389
22889
  frames are relative to the timeline start (frame 0 = first frame), like marker frameIds.
22890
+ NOT WYSIWYG: thumbnails are decoded from source media and exclude Fusion
22891
+ composition output (grade reflection is unreliable). Never present a
22892
+ contact sheet as proof of a Fusion/grade change — use gallery_stills
22893
+ grab_and_export or an extracted RENDERED frame for that.
22390
22894
  marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance}
22391
22895
  edit_kernel_capabilities() -> {supported, partially_supported, unsupported}
22392
22896
  probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count}
@@ -22700,6 +23204,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22700
23204
  return _timeline_duplicate_clips_impl(proj, tl, p)
22701
23205
  elif action == "move_clips":
22702
23206
  return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True, resolve=get_resolve())
23207
+ elif action == "ripple_insert":
23208
+ return _timeline_ripple_insert_impl(proj, tl, p, resolve=get_resolve())
22703
23209
  elif action in {"copy_range", "duplicate_range"}:
22704
23210
  return _timeline_copy_range_impl(proj, tl, p)
22705
23211
  elif action == "overwrite_range":
@@ -24063,6 +24569,61 @@ def _probe_color_node_graph(proj, item, p: Dict[str, Any]):
24063
24569
  return snapshot
24064
24570
 
24065
24571
 
24572
+ def _cdl_node_preflight(item, node_index):
24573
+ """Verify the SetCDL target node exists (NodeIndex is 1-based per the
24574
+ scripting README) via the current Graph API — TimelineItem.GetNumNodes is
24575
+ deprecated; the node count lives on item.GetNodeGraph()."""
24576
+ info: Dict[str, Any] = {"node_index": node_index, "num_nodes": None, "graph_available": False}
24577
+ try:
24578
+ graph = item.GetNodeGraph()
24579
+ except Exception as exc:
24580
+ info["reason"] = f"GetNodeGraph failed: {exc}"
24581
+ return False, info
24582
+ if not graph:
24583
+ info["reason"] = "item has no node graph"
24584
+ return False, info
24585
+ info["graph_available"] = True
24586
+ try:
24587
+ num_nodes = graph.GetNumNodes()
24588
+ except Exception as exc:
24589
+ info["reason"] = f"GetNumNodes failed: {exc}"
24590
+ return False, info
24591
+ info["num_nodes"] = num_nodes
24592
+ if not isinstance(num_nodes, int) or num_nodes < 1:
24593
+ info["reason"] = "node graph reports no nodes"
24594
+ return False, info
24595
+ if int(node_index) > num_nodes:
24596
+ info["reason"] = f"NodeIndex {node_index} exceeds node count {num_nodes} (NodeIndex is 1-based)"
24597
+ return False, info
24598
+ return True, info
24599
+
24600
+
24601
+ def _cdl_failure_diagnosis(item, preflight):
24602
+ """SetCDL returned False on a payload that validated and a node that exists —
24603
+ report what is knowable instead of a bare false."""
24604
+ clip_type = None
24605
+ try:
24606
+ mpi = item.GetMediaPoolItem()
24607
+ if mpi:
24608
+ clip_type = mpi.GetClipProperty("Type")
24609
+ except Exception:
24610
+ pass
24611
+ diagnosis = {
24612
+ "reason": "set_cdl_returned_false",
24613
+ "node_preflight": preflight,
24614
+ "clip_type": clip_type,
24615
+ "remediation": (
24616
+ "Resolve rejected the CDL despite a valid payload and an existing node. "
24617
+ "Common causes: still-image or generator item, a locked/read-only grade "
24618
+ "version, or the active color science ignoring CDL. Verify on the Color "
24619
+ "page and prove any resulting grade with gallery_stills grab_and_export."
24620
+ ),
24621
+ }
24622
+ if clip_type and "still" in str(clip_type).lower():
24623
+ diagnosis["reason"] = "set_cdl_rejected_on_still_item"
24624
+ return diagnosis
24625
+
24626
+
24066
24627
  def _safe_set_cdl(item, p: Dict[str, Any]):
24067
24628
  validation, err = _validate_cdl_payload(p.get("cdl"))
24068
24629
  if err:
@@ -24070,13 +24631,27 @@ def _safe_set_cdl(item, p: Dict[str, Any]):
24070
24631
  if not validation["valid"]:
24071
24632
  return {"success": False, "validation": validation}
24072
24633
  normalized = _normalize_cdl(validation["cdl"])
24634
+ node_ok, preflight = _cdl_node_preflight(item, validation["cdl"]["NodeIndex"])
24073
24635
  if p.get("dry_run"):
24074
- return _ok(validation=validation, normalized=normalized)
24075
- return {
24076
- "success": bool(item.SetCDL(normalized)),
24636
+ return _ok(validation=validation, normalized=normalized, node_preflight=preflight)
24637
+ if not node_ok:
24638
+ return {
24639
+ "success": False,
24640
+ "validation": validation,
24641
+ "normalized": normalized,
24642
+ "node_preflight": preflight,
24643
+ "reason": preflight.get("reason"),
24644
+ }
24645
+ success = bool(item.SetCDL(normalized))
24646
+ out = {
24647
+ "success": success,
24077
24648
  "validation": validation,
24078
24649
  "normalized": normalized,
24650
+ "node_preflight": preflight,
24079
24651
  }
24652
+ if not success:
24653
+ out["diagnosis"] = _cdl_failure_diagnosis(item, preflight)
24654
+ return out
24080
24655
 
24081
24656
 
24082
24657
  def _timeline_items_for_grade_copy(tl, target_ids):
@@ -24420,16 +24995,18 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
24420
24995
  ),
24421
24996
  },
24422
24997
  "bulk_set_item_properties": {
24423
- "summary": "Batch SetProperty/clip_color/enabled across many items.",
24424
- "params": "ops: [{timeline_item_id|clip_id, properties|transform|crop|composite|audio, clip_color?, enabled?}], dry_run?, readback?",
24425
- "returns": "{success, results, op_count}",
24998
+ "summary": "Batch SetProperty/clip_color/enabled across many items. clip_color or enabled may be the only key in an op.",
24999
+ "params": "ops: [{timeline_item_id|clip_id, properties?|transform?|crop?|composite?|audio?, clip_color?, enabled?}], dry_run?, readback?",
25000
+ "returns": "{success, results, op_count}; a failed clip_color adds results[].clip_color_detail",
24426
25001
  "example": (
24427
25002
  'timeline(action="bulk_set_item_properties", params={\n'
24428
25003
  ' "ops": [\n'
24429
- ' {"timeline_item_id": "TimelineItem-abc",\n'
24430
- ' "properties": {"ClipColor": "Teal", "ZoomX": 1.05}},\n'
24431
- ' {"timeline_item_id": "TimelineItem-def",\n'
24432
- ' "properties": {"ClipColor": "Teal"}}\n'
25004
+ ' # triage: colour only, no other payload needed\n'
25005
+ ' {"timeline_item_id": "TimelineItem-abc", "clip_color": "Apricot"},\n'
25006
+ ' {"timeline_item_id": "TimelineItem-def", "clip_color": "Chocolate"},\n'
25007
+ ' # or colour plus a transform in the same op\n'
25008
+ ' {"timeline_item_id": "TimelineItem-ghi",\n'
25009
+ ' "clip_color": "Purple", "transform": {"ZoomX": 1.05}}\n'
24433
25010
  ' ],\n'
24434
25011
  ' "dry_run": True, "readback": True\n'
24435
25012
  '})'
@@ -25017,6 +25594,48 @@ def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]:
25017
25594
  ],
25018
25595
  }
25019
25596
 
25597
+ # CreateMagicMask mode strings per the scripting README ("F", "B", "BI").
25598
+ # The granular server historically defaulted to "Forward", which Resolve
25599
+ # rejects — accept the long spellings as aliases and normalize.
25600
+ _MAGIC_MASK_MODES = {
25601
+ "F": "F", "FORWARD": "F",
25602
+ "B": "B", "BACKWARD": "B",
25603
+ "BI": "BI", "BIDIRECTION": "BI", "BIDIRECTIONAL": "BI",
25604
+ }
25605
+
25606
+
25607
+ def _magic_mask_hitl_result(*, regenerate: bool = False) -> Dict[str, Any]:
25608
+ """Magic Mask v2 isolates via operator CLICKS (manual ch. 139: strokes are
25609
+ the legacy v1 interface). The scripting API can only trigger tracking; it
25610
+ cannot place clicks, so with none present CreateMagicMask returns False.
25611
+ Return the human step instead of a bare false."""
25612
+ why = (
25613
+ "RegenerateMagicMask returned False — there is no existing Magic Mask "
25614
+ "click set on this item to regenerate."
25615
+ if regenerate else
25616
+ "CreateMagicMask returned False — the scripting API cannot place the "
25617
+ "subject clicks Magic Mask v2 requires, so no isolation exists yet."
25618
+ )
25619
+ return {
25620
+ "success": False,
25621
+ "needs_hitl": True,
25622
+ "hitl": {
25623
+ "feature": "Magic Mask v2",
25624
+ "page": "Color",
25625
+ "why": why,
25626
+ "steps": [
25627
+ "Open the Color page and select this clip",
25628
+ "Open the Magic Mask palette",
25629
+ "Click the plus eyedropper on the subject in the Viewer "
25630
+ "(shift to red minus clicks to remove areas)",
25631
+ "Press Track Forward (clicks track together; tracked frames show blue)",
25632
+ ],
25633
+ "verify": "Prove the isolation with gallery_stills grab_and_export "
25634
+ "(rendered frame) — never a media-pool thumbnail",
25635
+ },
25636
+ }
25637
+
25638
+
25020
25639
  @mcp.tool()
25021
25640
  @_guard_missing_params
25022
25641
  @_destructive_op("timeline_item_color")
@@ -25035,7 +25654,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25035
25654
  grade_evidence_base -> {evidence_base: str, structured: {coverage, version_snapshot, node_graph, color_group, warnings}}
25036
25655
  bulk_match_to_hero -> {hero, proposals: [{target_id, name, proposed_cdl|copy_source, warnings}], blocked: [...], confirm_token?}
25037
25656
  propose_grade -> {accepted: bool, validation, plan_id?, preview_path?, error?}
25038
- safe_set_cdl -> {success, validation, normalized}
25657
+ safe_set_cdl -> {success, validation, normalized, node_preflight, diagnosis?}
25039
25658
  safe_copy_grade -> {success, targets, missing}
25040
25659
  safe_apply_drx -> {success, path, source} # first call may return confirm_token
25041
25660
  grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards}
@@ -25068,8 +25687,10 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25068
25687
  get_fusion_cache(...) -> {enabled}
25069
25688
 
25070
25689
  Guarded mutators (PREFERRED for grade work):
25071
- safe_set_cdl(cdl, dry_run?, ...) -> {success, validation, normalized}
25690
+ safe_set_cdl(cdl, dry_run?, ...) -> {success, validation, normalized, node_preflight, diagnosis?}
25072
25691
  Validates input, supports dry_run, returns normalized CDL. Use this for primary corrections.
25692
+ Reads the node graph's GetNumNodes before SetCDL (NodeIndex is 1-BASED); a false
25693
+ SetCDL comes back with a structured diagnosis (missing node, still item, ...).
25073
25694
  # example: action_help(name='<action_name>')
25074
25695
  safe_copy_grade(target_ids, dry_run?, ...) -> {success, targets, missing}
25075
25696
  Copies grade to N items; dry_run reports targets without mutating.
@@ -25101,8 +25722,12 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25101
25722
  set_fusion_cache(enabled, ...) -> {success}
25102
25723
  stabilize(...) -> {success}
25103
25724
  smart_reframe(...) -> {success}
25104
- create_magic_mask(mode, ...) -> {success} — mode: "F" forward, "B" backward, "BI" bidirectional
25105
- regenerate_magic_mask(...) -> {success}
25725
+ create_magic_mask(mode, ...) -> {success | needs_hitl, hitl} — mode: "F" forward, "B" backward, "BI" bidirectional
25726
+ Magic Mask v2 needs operator CLICKS on the subject; the API cannot place them.
25727
+ With no clicks present this returns needs_hitl=true plus the exact human steps
25728
+ (Color page > Magic Mask palette > click subject > Track Forward). Do not treat
25729
+ a create_magic_mask call as having isolated anything without a rendered-frame proof.
25730
+ regenerate_magic_mask(...) -> {success | needs_hitl, hitl}
25106
25731
 
25107
25732
  Default: track_type="video", track_index=1, item_index=0
25108
25733
 
@@ -25213,9 +25838,21 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
25213
25838
  elif action == "smart_reframe":
25214
25839
  return {"success": bool(item.SmartReframe())}
25215
25840
  elif action == "create_magic_mask":
25216
- return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
25841
+ mode = _MAGIC_MASK_MODES.get(str(p.get("mode", "F")).strip().upper())
25842
+ if not mode:
25843
+ return _err(
25844
+ "mode must be 'F' (forward), 'B' (backward), or 'BI' (bidirection)",
25845
+ code="INVALID_MAGIC_MASK_MODE",
25846
+ category="invalid_input",
25847
+ remediation="Pass one of the README mode strings: F, B, BI.",
25848
+ )
25849
+ if bool(item.CreateMagicMask(mode)):
25850
+ return {"success": True, "mode": mode}
25851
+ return _magic_mask_hitl_result()
25217
25852
  elif action == "regenerate_magic_mask":
25218
- return {"success": bool(item.RegenerateMagicMask())}
25853
+ if bool(item.RegenerateMagicMask()):
25854
+ return {"success": True}
25855
+ return _magic_mask_hitl_result(regenerate=True)
25219
25856
  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
25857
 
25221
25858
 
@@ -25364,6 +26001,12 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
25364
26001
  untouched, and folder_path itself is removed only if this call created it
25365
26002
  and left it empty. With cleanup false the files are moved up into
25366
26003
  folder_path without overwriting anything already there.
26004
+
26005
+ WYSIWYG PROOF RULE: grab_and_export (or an exported gallery still / extracted
26006
+ RENDERED frame) is the ONLY acceptable visual evidence for a Fusion or grade
26007
+ claim. Media-pool thumbnails and thumbnail contact sheets are decoded from
26008
+ source media — they do NOT show Fusion composition output, so a before/after
26009
+ built from thumbnails proves nothing.
25367
26010
  """
25368
26011
  p = _params(params)
25369
26012
  _, proj, err = _check()
@@ -26750,6 +27393,15 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26750
27393
 
26751
27394
  Use timeline_item_fusion to add/delete/import/export comps on items.
26752
27395
 
27396
+ RENDER WARNING: whether a comp created via AddFusionComp / edited here is
27397
+ honoured at render is Resolve-version-dependent — a wired
27398
+ MediaIn->Blur->MediaOut comp rendered on Studio 19.1.3.7 (2026-08-02), but
27399
+ on Studio 21.0.4 the same Blur configuration AND a Transform variant both
27400
+ delivered renders bit-identical to the no-comp baseline (2026-08-20), and
27401
+ no API selects an item's active composition (see resolve_control api_truth
27402
+ query='AddFusionComp'). Prove any Fusion effect with gallery_stills
27403
+ grab_and_export or a rendered frame, never with comp readback.
27404
+
26753
27405
  Actions:
26754
27406
  add_tool(tool_type, x?, y?, name?) -> {tool_name, tool_type}
26755
27407
  delete_tool(tool_name) -> {success}