davinci-resolve-mcp 2.70.4 → 2.71.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 +72 -0
- package/README.md +1 -1
- package/docs/SKILL.md +14 -1
- package/docs/reference/api-limitations.md +25 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +131 -9
- package/src/utils/api_truth.py +75 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.71.1
|
|
6
|
+
|
|
7
|
+
`Timeline.DeleteClips` can lie about whether it worked. #111 recorded four
|
|
8
|
+
behaviours from a live edit session; #114 mitigates the first of them. Both by
|
|
9
|
+
@billcarroll.
|
|
10
|
+
|
|
11
|
+
### DeleteClips readback-and-retry
|
|
12
|
+
|
|
13
|
+
`Timeline.DeleteClips` can return `False` on a first call even when every item
|
|
14
|
+
passed is a valid, present `TimelineItem`, with an identical retry succeeding.
|
|
15
|
+
`_timeline_delete_clips_verified` reads the tracks back on a `False` and retries
|
|
16
|
+
once if the items are still there. All four timeline call sites route through
|
|
17
|
+
it: the `delete_clips` action, `lift_range`, `duplicate_clips` and `copy_range`.
|
|
18
|
+
|
|
19
|
+
The readback is deliberately **tri-state**. A walk that raised, enumerated no
|
|
20
|
+
track at all, or covered items whose unique ID cannot be read is `unknown`, not
|
|
21
|
+
`absent` — so an unverifiable delete is never reported as success, and never
|
|
22
|
+
spends a second destructive call buying information it cannot read. An earlier
|
|
23
|
+
draft collapsed unknown into absent, which turned a failed delete into a
|
|
24
|
+
reported success; that is the exact silent-lie class this series exists to
|
|
25
|
+
remove, so it is worth naming.
|
|
26
|
+
|
|
27
|
+
The `ripple=True` non-idempotence of a retry is recorded in the docstring rather
|
|
28
|
+
than claimed to be solved: if the first call deleted some items and left others,
|
|
29
|
+
the retry passes the original list back in, stale handles included. It could not
|
|
30
|
+
be made to misbehave against a fake.
|
|
31
|
+
|
|
32
|
+
### Four edit-session behaviours recorded
|
|
33
|
+
|
|
34
|
+
- **`DeleteClips` flaky first attempt.** The entry states plainly that the cause
|
|
35
|
+
is **unknown**, and specifically that this is *not* the
|
|
36
|
+
`ProjectManager.DeleteProject` shape — that one has an identified mechanism
|
|
37
|
+
which retrying does not clear, whereas a single retry cleared this in the one
|
|
38
|
+
instance seen. One observation is not a mechanism.
|
|
39
|
+
- **`DeleteClips` leaves linked audio.** The API deletes exactly the items
|
|
40
|
+
passed; the UI's linked-selection behaviour does not apply, so orphaned audio
|
|
41
|
+
collides with later appends.
|
|
42
|
+
- **`AppendToTimeline` mixed-fps duration floor.** Source-to-timeline frame
|
|
43
|
+
conversion rounds down, landing a planned range one frame short.
|
|
44
|
+
- **`ImportMedia` current-folder only.** No destination parameter; imports land
|
|
45
|
+
in the current bin.
|
|
46
|
+
|
|
47
|
+
## What's New in v2.71.0
|
|
48
|
+
|
|
49
|
+
Keyed metadata getters honor a list of keys, and `delete_timelines` names the
|
|
50
|
+
parameter it wants. Reported and fixed by @billcarroll in #113, from live
|
|
51
|
+
cataloguing work.
|
|
52
|
+
|
|
53
|
+
### Keyed getters silently returned everything
|
|
54
|
+
|
|
55
|
+
Resolve's keyed getters take one string. Handed a list they ignore it and return
|
|
56
|
+
the full dict, so a caller asking for three fields silently received all of them
|
|
57
|
+
with no signal that the request had been dropped — the kind of thing that reads
|
|
58
|
+
as working until someone counts.
|
|
59
|
+
|
|
60
|
+
`get_metadata`, `get_third_party_metadata` and `get_clip_property` now share a
|
|
61
|
+
`_keyed_get` helper that subsets locally, since there is no batch getter to
|
|
62
|
+
delegate to. An empty or non-string list is a clear error rather than a silent
|
|
63
|
+
superset.
|
|
64
|
+
|
|
65
|
+
Missing keys deliberately report differently through the two forms: the list
|
|
66
|
+
form maps them to `null`, which separates "absent" from "present but empty"; the
|
|
67
|
+
string form still returns Resolve's `""`. All three actions document both the
|
|
68
|
+
list form and that divergence.
|
|
69
|
+
|
|
70
|
+
### `delete_timelines` leaked a KeyError
|
|
71
|
+
|
|
72
|
+
Called with `timeline_names`, or without `timeline_ids` at all, it raised a bare
|
|
73
|
+
`KeyError('timeline_ids')`. It now returns a proper error naming the expected
|
|
74
|
+
parameter, and when `timeline_names` was passed it says explicitly that
|
|
75
|
+
timelines are matched by unique ID rather than by name.
|
|
76
|
+
|
|
5
77
|
## What's New in v2.70.4
|
|
6
78
|
|
|
7
79
|
Three silent-failure fixes from community reports, plus the Windows bridge
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -1307,7 +1307,12 @@ Key actions:
|
|
|
1307
1307
|
instead of walking tracks by hand. Filters may be passed inline or as a
|
|
1308
1308
|
`filters` dict; a mistyped filter name is rejected rather than silently
|
|
1309
1309
|
matching everything. Returns `{clips, match_count, total_clips}`.
|
|
1310
|
-
- `delete_clips(clip_ids, ripple?)` — IDs are unique IDs from `get_items
|
|
1310
|
+
- `delete_clips(clip_ids, ripple?)` — IDs are unique IDs from `get_items`.
|
|
1311
|
+
Two verified quirks (see `api_truth`): the call can return `success: false`
|
|
1312
|
+
on the first attempt with valid IDs — re-list and retry once before failing;
|
|
1313
|
+
and deleting a video item does NOT delete its linked audio — pass the linked
|
|
1314
|
+
audio item IDs explicitly, then `detect_gaps_overlaps` across both track
|
|
1315
|
+
types.
|
|
1311
1316
|
- `duplicate_clips(clip_ids?, selected?, target_track_index?, track_offset?, placement?, record_frame?, record_frame_offset?, copy_properties?, include_linked?)` —
|
|
1312
1317
|
duplicate existing video timeline items by re-appending the same Media Pool
|
|
1313
1318
|
item with the same source trim; `selected=True` uses Resolve's selected/current
|
|
@@ -1723,6 +1728,14 @@ media_pool(action="append_to_timeline", params={"clip_infos": [
|
|
|
1723
1728
|
]})
|
|
1724
1729
|
```
|
|
1725
1730
|
|
|
1731
|
+
Mixed-fps caution: `start_frame`/`end_frame` are SOURCE frames, and a source
|
|
1732
|
+
whose fps differs from the timeline's rounds DOWN on conversion — a 24.0 or
|
|
1733
|
+
29.97 clip appended into a 23.976 timeline can land one frame short of its
|
|
1734
|
+
slot. Plan durations in timeline frames, extend `end_frame` by a source frame
|
|
1735
|
+
when the floor misses, and finish with `detect_gaps_overlaps` (see
|
|
1736
|
+
`api_truth`). `import_media` always lands in the CURRENT bin — call
|
|
1737
|
+
`set_current_folder` first; there is no destination parameter.
|
|
1738
|
+
|
|
1726
1739
|
### 4. Inspect and annotate timeline items
|
|
1727
1740
|
|
|
1728
1741
|
```
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.0
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 23 missing capabilities, 22 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -192,6 +192,14 @@ equivalent, blocking full automation.
|
|
|
192
192
|
- **Workaround / current handling:** Delete and recreate the folder with the desired name, or rename in the Resolve UI.
|
|
193
193
|
- **Tags:** missing-method, media-pool, folder
|
|
194
194
|
|
|
195
|
+
### MediaPool.ImportMedia (current-folder destination only)
|
|
196
|
+
|
|
197
|
+
- **Object:** `MediaPool`
|
|
198
|
+
- **Signature:** `([paths] | [clipInfos]) -> [MediaPoolItem]`
|
|
199
|
+
- **Behavior:** Imports always land in the CURRENT media pool folder; the call has no destination-folder parameter, and passing an unrecognized one to the MCP tool is silently ignored.
|
|
200
|
+
- **Workaround / current handling:** SetCurrentFolder to the target bin first (media_pool set_current_folder), import, then restore the previous current folder if it matters.
|
|
201
|
+
- **Tags:** media-pool, import
|
|
202
|
+
|
|
195
203
|
### Project.SetCurrentRenderFormatAndCodec
|
|
196
204
|
|
|
197
205
|
- **Object:** `Project`
|
|
@@ -327,6 +335,22 @@ values, or automation-hostile modal prompts.
|
|
|
327
335
|
- **Reference:** [issue #77](https://github.com/samuelgursky/davinci-resolve-mcp/issues/77)
|
|
328
336
|
- **Tags:** unreliable-return, silent-failure, metadata, reel-name
|
|
329
337
|
|
|
338
|
+
### Timeline.DeleteClips (flaky first attempt)
|
|
339
|
+
|
|
340
|
+
- **Object:** `Timeline`
|
|
341
|
+
- **Signature:** `([TimelineItem], ripple) -> bool`
|
|
342
|
+
- **Behavior:** Can return False on the first call even when every item in the list is a valid, present TimelineItem; an identical immediate retry succeeds. Observed once, on Studio 21.0 during a cut-video edit session (items confirmed still present after the False, deleted cleanly on retry). Cause unknown — do NOT read this as the ProjectManager.DeleteProject shape: that one has an identified mechanism (the project being, or recently having been, current) that retrying does not clear, whereas a single retry cleared this in the one instance seen. One observation is not a mechanism; if a retry is ever seen to fail repeatedly here, this entry needs revisiting.
|
|
343
|
+
- **Workaround / current handling:** Treat a False return as advisory: re-list the track and check whether the items are actually gone; if still present, retry the identical call once before failing. A readback that raised, enumerated nothing, or covered items whose unique ID cannot be read is UNKNOWN, not gone — never report an unverifiable delete as success, and do not spend a second destructive call on an outcome you equally cannot read.
|
|
344
|
+
- **Tags:** unreliable-return, flaky, timeline, edit
|
|
345
|
+
|
|
346
|
+
### MediaPool.AppendToTimeline with mixed-fps sources (duration floor)
|
|
347
|
+
|
|
348
|
+
- **Object:** `MediaPool`
|
|
349
|
+
- **Signature:** `([{mediaPoolItem, startFrame, endFrame, recordFrame, ...}]) -> [TimelineItem]`
|
|
350
|
+
- **Behavior:** start/endFrame are in SOURCE frames. When the source fps differs from the timeline fps (e.g. 24.0 or 29.97 source in a 23.976 timeline), Resolve converts the source range to timeline frames by flooring — so a range planned to fill an exact record slot lands one frame short, leaving a 1-frame gap before the next clip.
|
|
351
|
+
- **Workaround / current handling:** Plan durations in timeline frames (floor(src_frames * timeline_fps / source_fps)); if the floored duration misses the slot, extend endFrame by a source frame and re-check. Always finish with detect_gaps_overlaps.
|
|
352
|
+
- **Tags:** timeline, edit, off-by-one, mixed-fps
|
|
353
|
+
|
|
330
354
|
### Graph.SetLUT (master-LUT-dir-only resolution)
|
|
331
355
|
|
|
332
356
|
- **Object:** `Graph`
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.71.1"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.71.1"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.71.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -3795,6 +3795,81 @@ def _timeline_item_ids(items):
|
|
|
3795
3795
|
return ids
|
|
3796
3796
|
|
|
3797
3797
|
|
|
3798
|
+
def _timeline_items_presence(tl, items):
|
|
3799
|
+
"""Are these timeline items still on the timeline? present/absent/unknown.
|
|
3800
|
+
|
|
3801
|
+
'absent' is a positive finding: every item was identifiable and a
|
|
3802
|
+
completed track walk did not see any of them. A walk that raised, that
|
|
3803
|
+
could not enumerate a single track, or items whose unique ID cannot be
|
|
3804
|
+
read all yield 'unknown' — the readback saw nothing, which is not the
|
|
3805
|
+
same as nothing being there. Callers must never treat 'unknown' as
|
|
3806
|
+
verified-gone.
|
|
3807
|
+
"""
|
|
3808
|
+
target_ids = []
|
|
3809
|
+
unreadable_item = False
|
|
3810
|
+
for item in items:
|
|
3811
|
+
item_id = _safe_timeline_item_id(item)
|
|
3812
|
+
if item_id:
|
|
3813
|
+
target_ids.append(item_id)
|
|
3814
|
+
else:
|
|
3815
|
+
unreadable_item = True
|
|
3816
|
+
target_ids = set(target_ids)
|
|
3817
|
+
|
|
3818
|
+
tracks_walked = 0
|
|
3819
|
+
walk_failed = False
|
|
3820
|
+
for track_type in ("video", "audio", "subtitle"):
|
|
3821
|
+
try:
|
|
3822
|
+
track_count = int(tl.GetTrackCount(track_type) or 0)
|
|
3823
|
+
except Exception:
|
|
3824
|
+
walk_failed = True
|
|
3825
|
+
continue
|
|
3826
|
+
for index in range(1, track_count + 1):
|
|
3827
|
+
try:
|
|
3828
|
+
track_items = tl.GetItemListInTrack(track_type, index) or []
|
|
3829
|
+
except Exception:
|
|
3830
|
+
walk_failed = True
|
|
3831
|
+
continue
|
|
3832
|
+
tracks_walked += 1
|
|
3833
|
+
for track_item in track_items:
|
|
3834
|
+
# A sighting is definitive even if another track failed.
|
|
3835
|
+
if _safe_timeline_item_id(track_item) in target_ids:
|
|
3836
|
+
return "present"
|
|
3837
|
+
|
|
3838
|
+
if walk_failed or tracks_walked == 0 or unreadable_item or not target_ids:
|
|
3839
|
+
return "unknown"
|
|
3840
|
+
return "absent"
|
|
3841
|
+
|
|
3842
|
+
|
|
3843
|
+
def _timeline_delete_clips_verified(tl, items, ripple):
|
|
3844
|
+
"""Timeline.DeleteClips with readback-and-retry.
|
|
3845
|
+
|
|
3846
|
+
api_truth 'Timeline.DeleteClips (flaky first attempt)': the call can
|
|
3847
|
+
return False while every item is still present, and an identical retry
|
|
3848
|
+
then succeeds. On a False, read the tracks back:
|
|
3849
|
+
|
|
3850
|
+
absent -> the delete landed despite the False; report success.
|
|
3851
|
+
present -> retry the identical call once, then read back again.
|
|
3852
|
+
unknown -> report failure and do NOT retry. An unverifiable delete must
|
|
3853
|
+
not be claimed as success, and a retry whose outcome we
|
|
3854
|
+
equally cannot read is a second destructive call bought with
|
|
3855
|
+
no information.
|
|
3856
|
+
|
|
3857
|
+
ripple=True caveat: a retry is not idempotent in principle. If the first
|
|
3858
|
+
call deleted some items and left others, the readback reports 'present'
|
|
3859
|
+
for the survivors and the retry passes the original list back in — stale
|
|
3860
|
+
handles to already-deleted items included. That could not be made to
|
|
3861
|
+
misbehave against a fake; it is recorded, not resolved.
|
|
3862
|
+
"""
|
|
3863
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3864
|
+
return True
|
|
3865
|
+
presence = _timeline_items_presence(tl, items)
|
|
3866
|
+
if presence != "present":
|
|
3867
|
+
return presence == "absent"
|
|
3868
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3869
|
+
return True
|
|
3870
|
+
return _timeline_items_presence(tl, items) == "absent"
|
|
3871
|
+
|
|
3872
|
+
|
|
3798
3873
|
def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")):
|
|
3799
3874
|
ids_set = {str(item_id) for item_id in ids if item_id is not None}
|
|
3800
3875
|
found = []
|
|
@@ -4168,7 +4243,7 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
|
|
|
4168
4243
|
seen_delete_ids.add(item_id)
|
|
4169
4244
|
if delete_items:
|
|
4170
4245
|
try:
|
|
4171
|
-
out["deleted_sources"] =
|
|
4246
|
+
out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)))
|
|
4172
4247
|
out["deleted_source_ids"] = _timeline_item_ids(delete_items)
|
|
4173
4248
|
except Exception as exc:
|
|
4174
4249
|
out["deleted_sources"] = False
|
|
@@ -4283,7 +4358,7 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
|
|
|
4283
4358
|
if existing_start < dest_end and existing_end > dest_start:
|
|
4284
4359
|
delete_targets.append(existing)
|
|
4285
4360
|
if delete_targets:
|
|
4286
|
-
deleted =
|
|
4361
|
+
deleted = _timeline_delete_clips_verified(tl, delete_targets, False)
|
|
4287
4362
|
|
|
4288
4363
|
results = []
|
|
4289
4364
|
for track_type, source_track, item, overlap_start, overlap_end in items:
|
|
@@ -4376,7 +4451,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
|
|
|
4376
4451
|
return {"success": True, "deleted": 0, "range": {"start": start, "end": end}}
|
|
4377
4452
|
deleted_ids = _timeline_item_ids(delete_items)
|
|
4378
4453
|
return {
|
|
4379
|
-
"success":
|
|
4454
|
+
"success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False))),
|
|
4380
4455
|
"deleted": len(delete_items),
|
|
4381
4456
|
"deleted_ids": deleted_ids,
|
|
4382
4457
|
"range": {"start": start, "end": end},
|
|
@@ -16626,11 +16701,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
16626
16701
|
|
|
16627
16702
|
return _run_maybe_background("media_pool.import_timeline", p, _work)
|
|
16628
16703
|
elif action == "delete_timelines":
|
|
16704
|
+
ids = p.get("timeline_ids")
|
|
16705
|
+
if not isinstance(ids, list) or not ids:
|
|
16706
|
+
hint = (" ('timeline_names' is not supported — timelines are matched"
|
|
16707
|
+
" by unique ID, e.g. from timeline.get_unique_id)"
|
|
16708
|
+
if "timeline_names" in p else "")
|
|
16709
|
+
return _err("delete_timelines requires 'timeline_ids', a non-empty"
|
|
16710
|
+
" list of timeline unique IDs" + hint)
|
|
16629
16711
|
count = proj.GetTimelineCount()
|
|
16630
16712
|
timelines = []
|
|
16631
16713
|
for i in range(1, count + 1):
|
|
16632
16714
|
tl = proj.GetTimelineByIndex(i)
|
|
16633
|
-
if tl and tl.GetUniqueId() in
|
|
16715
|
+
if tl and tl.GetUniqueId() in ids:
|
|
16634
16716
|
timelines.append(tl)
|
|
16635
16717
|
if not timelines:
|
|
16636
16718
|
return _err("No timelines found")
|
|
@@ -17006,6 +17088,22 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
17006
17088
|
# TOOL 13: media_pool_item
|
|
17007
17089
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
17008
17090
|
|
|
17091
|
+
def _keyed_get(getter, key):
|
|
17092
|
+
"""Resolve's keyed getters take one string key; passed a list they silently
|
|
17093
|
+
ignore it and return the full dict. Subset it ourselves instead.
|
|
17094
|
+
|
|
17095
|
+
Returns (value, error) — exactly one is non-None unless value is legitimately
|
|
17096
|
+
empty."""
|
|
17097
|
+
if isinstance(key, list):
|
|
17098
|
+
if not key or not all(isinstance(k, str) for k in key):
|
|
17099
|
+
return None, _err("'key' must be a string or a non-empty list of strings")
|
|
17100
|
+
full = getter("")
|
|
17101
|
+
if not isinstance(full, dict):
|
|
17102
|
+
full = {}
|
|
17103
|
+
return {k: full.get(k) for k in key}, None
|
|
17104
|
+
return getter(key), None
|
|
17105
|
+
|
|
17106
|
+
|
|
17009
17107
|
@mcp.tool()
|
|
17010
17108
|
@_guard_missing_params
|
|
17011
17109
|
def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -17014,11 +17112,26 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17014
17112
|
Actions:
|
|
17015
17113
|
get_name(clip_id) -> {name}
|
|
17016
17114
|
get_metadata(clip_id, key?) -> {metadata}
|
|
17115
|
+
— key: one string, or a list of strings to get just that subset.
|
|
17116
|
+
Missing keys: the list form maps them to null (distinguishing
|
|
17117
|
+
absent from empty); the string form passes Resolve's own answer
|
|
17118
|
+
through unchanged, which is "" or null depending on the getter
|
|
17119
|
+
and build (get_clip_property returns null on Studio 19.1.3.7).
|
|
17017
17120
|
set_metadata(clip_id, key, value) OR set_metadata(clip_id, metadata) -> {success}
|
|
17018
17121
|
get_third_party_metadata(clip_id, key?) -> {metadata}
|
|
17122
|
+
— key: one string, or a list of strings to get just that subset.
|
|
17123
|
+
Missing keys: the list form maps them to null (distinguishing
|
|
17124
|
+
absent from empty); the string form passes Resolve's own answer
|
|
17125
|
+
through unchanged, which is "" or null depending on the getter
|
|
17126
|
+
and build (get_clip_property returns null on Studio 19.1.3.7).
|
|
17019
17127
|
set_third_party_metadata(clip_id, key, value) -> {success}
|
|
17020
17128
|
get_media_id(clip_id) -> {media_id}
|
|
17021
17129
|
get_clip_property(clip_id, key?) -> {properties}
|
|
17130
|
+
— key: one string, or a list of strings to get just that subset.
|
|
17131
|
+
Missing keys: the list form maps them to null (distinguishing
|
|
17132
|
+
absent from empty); the string form passes Resolve's own answer
|
|
17133
|
+
through unchanged, which is "" or null depending on the getter
|
|
17134
|
+
and build (get_clip_property returns null on Studio 19.1.3.7).
|
|
17022
17135
|
set_clip_property(clip_id, key, value) -> {success}
|
|
17023
17136
|
get_clip_color(clip_id) -> {color}
|
|
17024
17137
|
set_clip_color(clip_id, color) -> {success}
|
|
@@ -17180,7 +17293,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17180
17293
|
if action == "get_name":
|
|
17181
17294
|
return {"name": clip.GetName()}
|
|
17182
17295
|
elif action == "get_metadata":
|
|
17183
|
-
|
|
17296
|
+
value, key_err = _keyed_get(clip.GetMetadata, p.get("key", ""))
|
|
17297
|
+
if key_err:
|
|
17298
|
+
return key_err
|
|
17299
|
+
return {"metadata": _ser(value)}
|
|
17184
17300
|
elif action == "set_metadata":
|
|
17185
17301
|
if "metadata" in p:
|
|
17186
17302
|
ok = bool(clip.SetMetadata(p["metadata"]))
|
|
@@ -17196,13 +17312,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17196
17312
|
return silent
|
|
17197
17313
|
return {"success": ok}
|
|
17198
17314
|
elif action == "get_third_party_metadata":
|
|
17199
|
-
|
|
17315
|
+
value, key_err = _keyed_get(clip.GetThirdPartyMetadata, p.get("key", ""))
|
|
17316
|
+
if key_err:
|
|
17317
|
+
return key_err
|
|
17318
|
+
return {"metadata": _ser(value)}
|
|
17200
17319
|
elif action == "set_third_party_metadata":
|
|
17201
17320
|
return {"success": bool(clip.SetThirdPartyMetadata(p["key"], p["value"]))}
|
|
17202
17321
|
elif action == "get_media_id":
|
|
17203
17322
|
return {"media_id": clip.GetMediaId()}
|
|
17204
17323
|
elif action == "get_clip_property":
|
|
17205
|
-
|
|
17324
|
+
value, key_err = _keyed_get(clip.GetClipProperty, p.get("key", ""))
|
|
17325
|
+
if key_err:
|
|
17326
|
+
return key_err
|
|
17327
|
+
return {"properties": _ser(value)}
|
|
17206
17328
|
elif action == "set_clip_property":
|
|
17207
17329
|
ok = bool(clip.SetClipProperty(p["key"], p["value"]))
|
|
17208
17330
|
if ok:
|
|
@@ -20726,7 +20848,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
20726
20848
|
blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p)
|
|
20727
20849
|
if blocked:
|
|
20728
20850
|
return blocked
|
|
20729
|
-
return {"success":
|
|
20851
|
+
return {"success": _timeline_delete_clips_verified(tl, found, ripple)}
|
|
20730
20852
|
elif action == "set_clips_linked":
|
|
20731
20853
|
ids_set = set(p["clip_ids"])
|
|
20732
20854
|
found = []
|
package/src/utils/api_truth.py
CHANGED
|
@@ -807,6 +807,81 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
807
807
|
"when mirroring keep-ranges into clipInfos.",
|
|
808
808
|
"tags": ["timeline", "edit", "off-by-one", "readback"],
|
|
809
809
|
},
|
|
810
|
+
{
|
|
811
|
+
"symbol": "Timeline.DeleteClips (flaky first attempt)",
|
|
812
|
+
"object": "Timeline",
|
|
813
|
+
"signature": "([TimelineItem], ripple) -> bool",
|
|
814
|
+
"reality": "Can return False on the first call even when every item in "
|
|
815
|
+
"the list is a valid, present TimelineItem; an identical "
|
|
816
|
+
"immediate retry succeeds. Observed once, on Studio 21.0 "
|
|
817
|
+
"during a cut-video edit session (items confirmed still "
|
|
818
|
+
"present after the False, deleted cleanly on retry). Cause "
|
|
819
|
+
"unknown — do NOT read this as the ProjectManager."
|
|
820
|
+
"DeleteProject shape: that one has an identified mechanism "
|
|
821
|
+
"(the project being, or recently having been, current) that "
|
|
822
|
+
"retrying does not clear, whereas a single retry cleared "
|
|
823
|
+
"this in the one instance seen. One observation is not a "
|
|
824
|
+
"mechanism; if a retry is ever seen to fail repeatedly here, "
|
|
825
|
+
"this entry needs revisiting.",
|
|
826
|
+
"recommended": "Treat a False return as advisory: re-list the track and "
|
|
827
|
+
"check whether the items are actually gone; if still "
|
|
828
|
+
"present, retry the identical call once before failing. "
|
|
829
|
+
"A readback that raised, enumerated nothing, or covered "
|
|
830
|
+
"items whose unique ID cannot be read is UNKNOWN, not "
|
|
831
|
+
"gone — never report an unverifiable delete as success, "
|
|
832
|
+
"and do not spend a second destructive call on an "
|
|
833
|
+
"outcome you equally cannot read.",
|
|
834
|
+
"tags": ["unreliable-return", "flaky", "timeline", "edit"],
|
|
835
|
+
"submit": "bug",
|
|
836
|
+
"mitigation": ["_timeline_delete_clips_verified", "_timeline_items_presence"],
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
"symbol": "Timeline.DeleteClips (linked audio not deleted)",
|
|
840
|
+
"object": "Timeline",
|
|
841
|
+
"signature": "([TimelineItem], ripple) -> bool",
|
|
842
|
+
"reality": "Deleting video items does NOT delete their linked audio "
|
|
843
|
+
"items — the UI's linked-selection behavior does not apply "
|
|
844
|
+
"to the API, which deletes exactly the items passed. The "
|
|
845
|
+
"orphaned audio stays on its track and collides with any "
|
|
846
|
+
"later append into the same record range.",
|
|
847
|
+
"recommended": "When deleting a video item that has linked audio, list "
|
|
848
|
+
"the audio track(s) (timeline get_items, track_type "
|
|
849
|
+
"'audio'), find the overlapping linked items, and pass "
|
|
850
|
+
"their IDs in the same delete. Verify with "
|
|
851
|
+
"detect_gaps_overlaps across both track types.",
|
|
852
|
+
"tags": ["timeline", "edit", "audio", "silent-failure"],
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
"symbol": "MediaPool.AppendToTimeline with mixed-fps sources (duration floor)",
|
|
856
|
+
"object": "MediaPool",
|
|
857
|
+
"signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, ...}]) -> [TimelineItem]",
|
|
858
|
+
"reality": "start/endFrame are in SOURCE frames. When the source fps "
|
|
859
|
+
"differs from the timeline fps (e.g. 24.0 or 29.97 source in "
|
|
860
|
+
"a 23.976 timeline), Resolve converts the source range to "
|
|
861
|
+
"timeline frames by flooring — so a range planned to fill an "
|
|
862
|
+
"exact record slot lands one frame short, leaving a 1-frame "
|
|
863
|
+
"gap before the next clip.",
|
|
864
|
+
"recommended": "Plan durations in timeline frames "
|
|
865
|
+
"(floor(src_frames * timeline_fps / source_fps)); if the "
|
|
866
|
+
"floored duration misses the slot, extend endFrame by a "
|
|
867
|
+
"source frame and re-check. Always finish with "
|
|
868
|
+
"detect_gaps_overlaps.",
|
|
869
|
+
"tags": ["timeline", "edit", "off-by-one", "mixed-fps"],
|
|
870
|
+
"submit": "bug",
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
"symbol": "MediaPool.ImportMedia (current-folder destination only)",
|
|
874
|
+
"object": "MediaPool",
|
|
875
|
+
"signature": "([paths] | [clipInfos]) -> [MediaPoolItem]",
|
|
876
|
+
"reality": "Imports always land in the CURRENT media pool folder; the "
|
|
877
|
+
"call has no destination-folder parameter, and passing an "
|
|
878
|
+
"unrecognized one to the MCP tool is silently ignored.",
|
|
879
|
+
"recommended": "SetCurrentFolder to the target bin first (media_pool "
|
|
880
|
+
"set_current_folder), import, then restore the previous "
|
|
881
|
+
"current folder if it matters.",
|
|
882
|
+
"tags": ["media-pool", "import"],
|
|
883
|
+
"submit": "missing",
|
|
884
|
+
},
|
|
810
885
|
{
|
|
811
886
|
"symbol": "Graph.SetLUT (master-LUT-dir-only resolution)",
|
|
812
887
|
"object": "Graph",
|