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/CHANGELOG.md +77 -0
- package/README.md +2 -2
- package/README.zh-CN.md +3 -3
- package/docs/SKILL.md +42 -4
- package/docs/reference/api-limitations.md +28 -4
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/timeline_item.py +20 -3
- package/src/server.py +644 -23
- package/src/utils/api_truth.py +86 -3
- package/src/utils/destructive_hook.py +28 -0
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.
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
5685
|
-
|
|
5686
|
-
"
|
|
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
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
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
|
|
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
|
-
|
|
24076
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|