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/CHANGELOG.md +117 -0
- package/README.md +2 -2
- package/README.zh-CN.md +3 -3
- package/docs/SKILL.md +47 -5
- 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 +690 -38
- 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.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
|
-
|
|
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 {}
|
|
@@ -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
|
-
|
|
5603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6087
|
+
outcome = _set_clip_color_checked(item, op["clip_color"], kind="timeline item")
|
|
5631
6088
|
except Exception as exc:
|
|
5632
|
-
|
|
5633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5685
|
-
|
|
5686
|
-
"
|
|
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
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
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
|
|
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
|
-
|
|
24076
|
-
|
|
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
|
|
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
|
-
'
|
|
24430
|
-
'
|
|
24431
|
-
' {"timeline_item_id": "TimelineItem-def",\n'
|
|
24432
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|