davinci-resolve-mcp 2.78.0 → 2.78.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 +34 -0
- package/README.md +1 -1
- package/docs/reference/api-limitations.md +8 -4
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +67 -35
- package/src/utils/api_truth.py +45 -22
- package/src/utils/page_lock.py +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
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.78.1
|
|
6
|
+
|
|
7
|
+
`Timeline.DeleteClips` is page-gated. Contributed by
|
|
8
|
+
[@billcarroll](https://github.com/billcarroll) in
|
|
9
|
+
[#117](https://github.com/samuelgursky/davinci-resolve-mcp/pull/117).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Every delete-capable action failed whenever the UI was left on another page.**
|
|
14
|
+
`Timeline.DeleteClips` deterministically returns False and deletes nothing off
|
|
15
|
+
the Edit page (verified: Fairlight), retries included — the readback-and-retry
|
|
16
|
+
helper from v2.71.1 handles a *flaky* False correctly, but retrying cannot clear
|
|
17
|
+
a page gate. Live verification on Studio 21.0: three identical retries against
|
|
18
|
+
132 valid, unlocked, present items all returned False with all 132 still on the
|
|
19
|
+
track; one `OpenPage("edit")` and the same call returned True and left 0. Track
|
|
20
|
+
lock and enable state were clear throughout — a page gate, not a lock.
|
|
21
|
+
`timeline` `delete_clips` / `move_clips` / `overwrite_range` / `lift_range` /
|
|
22
|
+
`apply_cuts` and `edit_engine execute_swap` now hold the Edit page for the call
|
|
23
|
+
and restore the caller's page after.
|
|
24
|
+
|
|
25
|
+
The guard is serialized through `page_lock`, alongside the existing Color-page
|
|
26
|
+
guard for thumbnail capture: Resolve has one globally-active page, so an
|
|
27
|
+
unserialized switch-work-restore races every other page-switching operation, and
|
|
28
|
+
under the threaded dispatch from v2.62.0 a concurrent thumbnail capture flipping
|
|
29
|
+
to Color mid-delete would land the delete on the wrong page — reintroducing this
|
|
30
|
+
same bug. `apply_cuts` holds the guard once around its loop rather than per cut,
|
|
31
|
+
which would otherwise cost 2N page flips for N cuts.
|
|
32
|
+
|
|
33
|
+
The `api_truth` entry is restructured into the two distinct failures now known to
|
|
34
|
+
share this call: wrong page (deterministic, mechanism identified, verified) and
|
|
35
|
+
flaky first attempt (one observation, cause not established — and the entry now
|
|
36
|
+
records that the page state at the time was not captured, so it cannot be ruled in
|
|
37
|
+
or out as the first failure in disguise).
|
|
38
|
+
|
|
5
39
|
## What's New in v2.78.0
|
|
6
40
|
|
|
7
41
|
The AAF probe reports where in the *source* an event actually lives, not where it
|
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)
|
|
@@ -374,13 +374,17 @@ values, or automation-hostile modal prompts.
|
|
|
374
374
|
- **Reference:** [issue #77](https://github.com/samuelgursky/davinci-resolve-mcp/issues/77)
|
|
375
375
|
- **Tags:** unreliable-return, silent-failure, metadata, reel-name
|
|
376
376
|
|
|
377
|
-
### Timeline.DeleteClips (flaky first attempt)
|
|
377
|
+
### Timeline.DeleteClips (requires the Edit page; flaky first attempt)
|
|
378
378
|
|
|
379
379
|
- **Object:** `Timeline`
|
|
380
380
|
- **Signature:** `([TimelineItem], ripple) -> bool`
|
|
381
|
-
- **Behavior:**
|
|
382
|
-
|
|
383
|
-
-
|
|
381
|
+
- **Behavior:** Two distinct failures share this call.
|
|
382
|
+
|
|
383
|
+
**1. Wrong page (deterministic, has a mechanism).** With the UI on the Fairlight page, DeleteClips returns False and deletes nothing, no matter how many times it is retried. Verified 2026-08-04 on Studio 21.0: three identical retries against 132 valid, unlocked, present TimelineItems all returned False with all 132 still on the track; a single `Resolve.OpenPage("edit")` followed by the same call returned True and left 0 items. Track lock and enable state were confirmed clear before and after, so this is a page gate, not a lock. This is the case the previous entry's falsification condition anticipated — a retry seen to fail repeatedly — and revisiting it found the mechanism.
|
|
384
|
+
|
|
385
|
+
**2. Flaky first attempt (one observation, no mechanism).** Independently of the page, the call has been seen once to return False with every item still present, where an identical immediate retry succeeded (Studio 21.0, cut-video edit session). Whether the UI was on the Edit page at the time was not recorded, so it cannot be ruled in or out as failure 1 in disguise. 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. One observation is still not a mechanism; if an immediate retry is ever seen to fail repeatedly while the UI is confirmed on the Edit page, this sub-entry needs revisiting.
|
|
386
|
+
- **Workaround / current handling:** Open the Edit page first (`Resolve.OpenPage("edit")`) — a caller that deletes clips from a script must not assume the user left the UI on Edit, and a Fairlight or Color session is a completely ordinary place for them to be. Then 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.
|
|
387
|
+
- **Tags:** unreliable-return, flaky, silent-failure, page-dependent, timeline, edit
|
|
384
388
|
|
|
385
389
|
### MediaPool.AppendToTimeline with mixed-fps sources (duration floor)
|
|
386
390
|
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.78.
|
|
39
|
+
VERSION = "2.78.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.78.
|
|
88
|
+
VERSION = "2.78.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.78.
|
|
14
|
+
VERSION = "2.78.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -50,6 +50,7 @@ from src.utils.contracts import validate as _validate_params
|
|
|
50
50
|
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
51
51
|
from src.utils.page_lock import (
|
|
52
52
|
color_page_for_thumbnails as _color_page_for_thumbnails,
|
|
53
|
+
edit_page_for_timeline_edits as _edit_page_for_timeline_edits,
|
|
53
54
|
open_page_serialized as _open_page_serialized,
|
|
54
55
|
page_lock as _page_lock,
|
|
55
56
|
)
|
|
@@ -3918,12 +3919,31 @@ def _timeline_items_presence(tl, items):
|
|
|
3918
3919
|
return "absent"
|
|
3919
3920
|
|
|
3920
3921
|
|
|
3921
|
-
def _timeline_delete_clips_verified(tl, items, ripple):
|
|
3922
|
-
"""Timeline.DeleteClips with readback-and-retry.
|
|
3922
|
+
def _timeline_delete_clips_verified(tl, items, ripple, *, resolve=None):
|
|
3923
|
+
"""Timeline.DeleteClips with a page guard and readback-and-retry.
|
|
3923
3924
|
|
|
3924
|
-
api_truth 'Timeline.DeleteClips (
|
|
3925
|
-
|
|
3926
|
-
|
|
3925
|
+
api_truth 'Timeline.DeleteClips (requires the Edit page; flaky first
|
|
3926
|
+
attempt)' records two distinct failures behind this one call.
|
|
3927
|
+
|
|
3928
|
+
Wrong page (deterministic): with the UI on some pages (verified:
|
|
3929
|
+
Fairlight) the call returns False and deletes nothing, retries included —
|
|
3930
|
+
retrying cannot help. When a `resolve` handle is supplied, the guard
|
|
3931
|
+
switches to the Edit page for the call and restores the caller's page
|
|
3932
|
+
after. Guard failures are swallowed: the delete attempt itself stays the
|
|
3933
|
+
source of truth. `resolve` is an explicit parameter, not an internal
|
|
3934
|
+
get_resolve(), so offline tests calling this helper directly can never
|
|
3935
|
+
touch a live Resolve UI.
|
|
3936
|
+
|
|
3937
|
+
The guard is page_lock's edit_page_for_timeline_edits, not a local
|
|
3938
|
+
OpenPage pair, because the switch has to be serialized against every other
|
|
3939
|
+
page-switching operation (thumbnail capture takes the Color page the same
|
|
3940
|
+
way). Unlocked, a concurrent switch lands this delete on some other page —
|
|
3941
|
+
reintroducing the very failure the guard prevents. Callers deleting in a
|
|
3942
|
+
loop should hold that guard around the loop; nesting it here is free.
|
|
3943
|
+
|
|
3944
|
+
Flaky first attempt: the call can return False while every item is still
|
|
3945
|
+
present, and an identical retry then succeeds. On a False, read the
|
|
3946
|
+
tracks back:
|
|
3927
3947
|
|
|
3928
3948
|
absent -> the delete landed despite the False; report success.
|
|
3929
3949
|
present -> retry the identical call once, then read back again.
|
|
@@ -3938,14 +3958,20 @@ def _timeline_delete_clips_verified(tl, items, ripple):
|
|
|
3938
3958
|
handles to already-deleted items included. That could not be made to
|
|
3939
3959
|
misbehave against a fake; it is recorded, not resolved.
|
|
3940
3960
|
"""
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3961
|
+
def _delete_with_readback():
|
|
3962
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3963
|
+
return True
|
|
3964
|
+
presence = _timeline_items_presence(tl, items)
|
|
3965
|
+
if presence != "present":
|
|
3966
|
+
return presence == "absent"
|
|
3967
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3968
|
+
return True
|
|
3969
|
+
return _timeline_items_presence(tl, items) == "absent"
|
|
3970
|
+
|
|
3971
|
+
if resolve is None:
|
|
3972
|
+
return _delete_with_readback()
|
|
3973
|
+
with _edit_page_for_timeline_edits(resolve):
|
|
3974
|
+
return _delete_with_readback()
|
|
3949
3975
|
|
|
3950
3976
|
|
|
3951
3977
|
def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")):
|
|
@@ -4110,7 +4136,7 @@ def _append_and_recover_timeline_item(
|
|
|
4110
4136
|
return result, duplicate_item, None
|
|
4111
4137
|
|
|
4112
4138
|
|
|
4113
|
-
def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_sources: bool = False):
|
|
4139
|
+
def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_sources: bool = False, resolve=None):
|
|
4114
4140
|
ids = p.get("clip_ids") or p.get("ids")
|
|
4115
4141
|
selected = bool(p.get("selected", False))
|
|
4116
4142
|
if ids is not None and not isinstance(ids, list):
|
|
@@ -4321,7 +4347,7 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
|
|
|
4321
4347
|
seen_delete_ids.add(item_id)
|
|
4322
4348
|
if delete_items:
|
|
4323
4349
|
try:
|
|
4324
|
-
out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)))
|
|
4350
|
+
out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)), resolve=resolve)
|
|
4325
4351
|
out["deleted_source_ids"] = _timeline_item_ids(delete_items)
|
|
4326
4352
|
except Exception as exc:
|
|
4327
4353
|
out["deleted_sources"] = False
|
|
@@ -4399,7 +4425,7 @@ def _collect_timeline_items_in_range(tl, p: Dict[str, Any]):
|
|
|
4399
4425
|
return start, end, items, None
|
|
4400
4426
|
|
|
4401
4427
|
|
|
4402
|
-
def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool = False):
|
|
4428
|
+
def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool = False, resolve=None):
|
|
4403
4429
|
start, end, items, err = _collect_timeline_items_in_range(tl, p)
|
|
4404
4430
|
if err:
|
|
4405
4431
|
return err
|
|
@@ -4436,7 +4462,7 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
|
|
|
4436
4462
|
if existing_start < dest_end and existing_end > dest_start:
|
|
4437
4463
|
delete_targets.append(existing)
|
|
4438
4464
|
if delete_targets:
|
|
4439
|
-
deleted = _timeline_delete_clips_verified(tl, delete_targets, False)
|
|
4465
|
+
deleted = _timeline_delete_clips_verified(tl, delete_targets, False, resolve=resolve)
|
|
4440
4466
|
|
|
4441
4467
|
results = []
|
|
4442
4468
|
for track_type, source_track, item, overlap_start, overlap_end in items:
|
|
@@ -4499,7 +4525,7 @@ def _apply_cuts_skip_reason(cut):
|
|
|
4499
4525
|
return None
|
|
4500
4526
|
|
|
4501
4527
|
|
|
4502
|
-
def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
|
|
4528
|
+
def _timeline_lift_range_impl(tl, p: Dict[str, Any], *, resolve=None):
|
|
4503
4529
|
start, end, items, err = _collect_timeline_items_in_range(tl, p)
|
|
4504
4530
|
if err:
|
|
4505
4531
|
return err
|
|
@@ -4529,7 +4555,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
|
|
|
4529
4555
|
return {"success": True, "deleted": 0, "range": {"start": start, "end": end}}
|
|
4530
4556
|
deleted_ids = _timeline_item_ids(delete_items)
|
|
4531
4557
|
return {
|
|
4532
|
-
"success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False))),
|
|
4558
|
+
"success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)), resolve=resolve),
|
|
4533
4559
|
"deleted": len(delete_items),
|
|
4534
4560
|
"deleted_ids": deleted_ids,
|
|
4535
4561
|
"range": {"start": start, "end": end},
|
|
@@ -20499,7 +20525,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20499
20525
|
"track_indices": [target_track],
|
|
20500
20526
|
"allow_partial_item_delete": True,
|
|
20501
20527
|
"ripple": False,
|
|
20502
|
-
})
|
|
20528
|
+
}, resolve=_r)
|
|
20503
20529
|
if not lift.get("success"):
|
|
20504
20530
|
return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
|
|
20505
20531
|
audio_lift = None
|
|
@@ -20511,7 +20537,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20511
20537
|
"track_indices": linked_audio_indices,
|
|
20512
20538
|
"allow_partial_item_delete": True,
|
|
20513
20539
|
"ripple": False,
|
|
20514
|
-
})
|
|
20540
|
+
}, resolve=_r)
|
|
20515
20541
|
if not audio_lift.get("success"):
|
|
20516
20542
|
return {
|
|
20517
20543
|
"success": False,
|
|
@@ -21005,7 +21031,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21005
21031
|
blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p)
|
|
21006
21032
|
if blocked:
|
|
21007
21033
|
return blocked
|
|
21008
|
-
return {"success": _timeline_delete_clips_verified(tl, found, ripple)}
|
|
21034
|
+
return {"success": _timeline_delete_clips_verified(tl, found, ripple, resolve=get_resolve())}
|
|
21009
21035
|
elif action == "set_clips_linked":
|
|
21010
21036
|
ids_set = set(p["clip_ids"])
|
|
21011
21037
|
found = []
|
|
@@ -21023,13 +21049,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21023
21049
|
elif action == "copy_clips":
|
|
21024
21050
|
return _timeline_duplicate_clips_impl(proj, tl, p)
|
|
21025
21051
|
elif action == "move_clips":
|
|
21026
|
-
return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True)
|
|
21052
|
+
return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True, resolve=get_resolve())
|
|
21027
21053
|
elif action in {"copy_range", "duplicate_range"}:
|
|
21028
21054
|
return _timeline_copy_range_impl(proj, tl, p)
|
|
21029
21055
|
elif action == "overwrite_range":
|
|
21030
|
-
return _timeline_copy_range_impl(proj, tl, p, overwrite=True)
|
|
21056
|
+
return _timeline_copy_range_impl(proj, tl, p, overwrite=True, resolve=get_resolve())
|
|
21031
21057
|
elif action == "lift_range":
|
|
21032
|
-
return _timeline_lift_range_impl(tl, p)
|
|
21058
|
+
return _timeline_lift_range_impl(tl, p, resolve=get_resolve())
|
|
21033
21059
|
elif action == "story_spine_report":
|
|
21034
21060
|
return _timeline_story_spine_report(tl, p)
|
|
21035
21061
|
elif action == "create_variant_from_ranges":
|
|
@@ -21184,15 +21210,21 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21184
21210
|
|
|
21185
21211
|
allow_partial = bool(p.get("allow_partial_item_delete", True))
|
|
21186
21212
|
results = []
|
|
21187
|
-
|
|
21188
|
-
|
|
21189
|
-
|
|
21190
|
-
|
|
21191
|
-
|
|
21192
|
-
|
|
21193
|
-
|
|
21194
|
-
|
|
21195
|
-
|
|
21213
|
+
resolve_obj = get_resolve()
|
|
21214
|
+
# Hold the Edit page once for the whole run. The per-delete guard nests
|
|
21215
|
+
# harmlessly inside (it finds the page already on edit), but without this
|
|
21216
|
+
# each cut would switch and restore on its own: from Fairlight, N cuts
|
|
21217
|
+
# cost 2N page flips instead of 2.
|
|
21218
|
+
with _edit_page_for_timeline_edits(resolve_obj):
|
|
21219
|
+
for c in applicable:
|
|
21220
|
+
sp = c["span"]
|
|
21221
|
+
res = _timeline_lift_range_impl(tl, {
|
|
21222
|
+
"start_frame": sp["start"],
|
|
21223
|
+
"end_frame": sp["end"],
|
|
21224
|
+
"ripple": c["action"] == "ripple_delete",
|
|
21225
|
+
"allow_partial_item_delete": allow_partial,
|
|
21226
|
+
}, resolve=resolve_obj)
|
|
21227
|
+
results.append({"action": c["action"], "span": sp, "result": res})
|
|
21196
21228
|
applied = sum(1 for r in results
|
|
21197
21229
|
if isinstance(r["result"], dict) and r["result"].get("success"))
|
|
21198
21230
|
return {"success": True, "applied": applied, "total": len(applicable),
|
package/src/utils/api_truth.py
CHANGED
|
@@ -951,30 +951,53 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
951
951
|
"tags": ["timeline", "edit", "off-by-one", "readback"],
|
|
952
952
|
},
|
|
953
953
|
{
|
|
954
|
-
"symbol": "Timeline.DeleteClips (flaky first attempt)",
|
|
954
|
+
"symbol": "Timeline.DeleteClips (requires the Edit page; flaky first attempt)",
|
|
955
955
|
"object": "Timeline",
|
|
956
956
|
"signature": "([TimelineItem], ripple) -> bool",
|
|
957
|
-
"reality": "
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
961
|
-
"
|
|
962
|
-
"
|
|
963
|
-
"
|
|
964
|
-
"
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"
|
|
968
|
-
"
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
957
|
+
"reality": "Two distinct failures share this call.\n"
|
|
958
|
+
"\n"
|
|
959
|
+
" **1. Wrong page (deterministic, has a mechanism).** With "
|
|
960
|
+
"the UI on the Fairlight page, DeleteClips returns False and "
|
|
961
|
+
"deletes nothing, no matter how many times it is retried. "
|
|
962
|
+
"Verified 2026-08-04 on Studio 21.0: three identical retries "
|
|
963
|
+
"against 132 valid, unlocked, present TimelineItems all "
|
|
964
|
+
"returned False with all 132 still on the track; a single "
|
|
965
|
+
"`Resolve.OpenPage(\"edit\")` followed by the same call "
|
|
966
|
+
"returned True and left 0 items. Track lock and enable state "
|
|
967
|
+
"were confirmed clear before and after, so this is a page "
|
|
968
|
+
"gate, not a lock. This is the case the previous entry's "
|
|
969
|
+
"falsification condition anticipated — a retry seen to fail "
|
|
970
|
+
"repeatedly — and revisiting it found the mechanism.\n"
|
|
971
|
+
"\n"
|
|
972
|
+
" **2. Flaky first attempt (one observation, no "
|
|
973
|
+
"mechanism).** Independently of the page, the call has been "
|
|
974
|
+
"seen once to return False with every item still present, "
|
|
975
|
+
"where an identical immediate retry succeeded (Studio 21.0, "
|
|
976
|
+
"cut-video edit session). Whether the UI was on the Edit "
|
|
977
|
+
"page at the time was not recorded, so it cannot be ruled "
|
|
978
|
+
"in or out as failure 1 in disguise. Do NOT read this as "
|
|
979
|
+
"the ProjectManager.DeleteProject shape: that one has an "
|
|
980
|
+
"identified mechanism (the project being, or recently "
|
|
981
|
+
"having been, current) that retrying does not clear. One "
|
|
982
|
+
"observation is still not a mechanism; if an immediate "
|
|
983
|
+
"retry is ever seen to fail repeatedly while the UI is "
|
|
984
|
+
"confirmed on the Edit page, this sub-entry needs "
|
|
985
|
+
"revisiting.",
|
|
986
|
+
"recommended": "Open the Edit page first (`Resolve.OpenPage(\"edit\")`) "
|
|
987
|
+
"— a caller that deletes clips from a script must not "
|
|
988
|
+
"assume the user left the UI on Edit, and a Fairlight or "
|
|
989
|
+
"Color session is a completely ordinary place for them "
|
|
990
|
+
"to be. Then treat a False return as advisory: re-list "
|
|
991
|
+
"the track and check whether the items are actually "
|
|
992
|
+
"gone; if still present, retry the identical call once "
|
|
993
|
+
"before failing. A readback that raised, enumerated "
|
|
994
|
+
"nothing, or covered items whose unique ID cannot be "
|
|
995
|
+
"read is UNKNOWN, not gone — never report an "
|
|
996
|
+
"unverifiable delete as success, and do not spend a "
|
|
997
|
+
"second destructive call on an outcome you equally "
|
|
998
|
+
"cannot read.",
|
|
999
|
+
"tags": ["unreliable-return", "flaky", "silent-failure",
|
|
1000
|
+
"page-dependent", "timeline", "edit"],
|
|
978
1001
|
"submit": "bug",
|
|
979
1002
|
"mitigation": ["_timeline_delete_clips_verified", "_timeline_items_presence"],
|
|
980
1003
|
},
|
package/src/utils/page_lock.py
CHANGED
|
@@ -113,3 +113,53 @@ def color_page_for_thumbnails(resolve):
|
|
|
113
113
|
resolve.OpenPage(original)
|
|
114
114
|
except Exception:
|
|
115
115
|
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@contextmanager
|
|
119
|
+
def edit_page_for_timeline_edits(resolve):
|
|
120
|
+
"""Hold the Edit page for the block, restoring the user's page after.
|
|
121
|
+
|
|
122
|
+
api_truth 'Timeline.DeleteClips (requires the Edit page; flaky first
|
|
123
|
+
attempt)': off the Edit page the call returns False and deletes nothing,
|
|
124
|
+
retries included — retrying cannot clear a page gate. Yields True when
|
|
125
|
+
Resolve is on the Edit page for the block.
|
|
126
|
+
|
|
127
|
+
Same shape as color_page_for_thumbnails, and for the same reason it lives
|
|
128
|
+
here rather than at the callsite: the switch must be serialized. Resolve has
|
|
129
|
+
one globally-active page, so an unlocked switch-work-restore races every
|
|
130
|
+
other page-switching operation — and losing that race puts the delete on
|
|
131
|
+
some other page, which is exactly the failure this guard exists to prevent.
|
|
132
|
+
|
|
133
|
+
Nesting is free (page_lock is reentrant, and an inner guard finds the page
|
|
134
|
+
already on edit and switches nothing), so a caller that deletes in a LOOP
|
|
135
|
+
should hold this once around the loop. Otherwise it pays a switch and a
|
|
136
|
+
restore per iteration: from Fairlight, N cuts cost 2N page flips, each a
|
|
137
|
+
visible flash and possible cache/render work.
|
|
138
|
+
|
|
139
|
+
If the current page can't be captured (GetCurrentPage returned None or
|
|
140
|
+
raised), no switch is attempted, so a skipped restore can never strand the
|
|
141
|
+
user on the Edit page.
|
|
142
|
+
"""
|
|
143
|
+
original = None
|
|
144
|
+
try:
|
|
145
|
+
original = resolve.GetCurrentPage() if resolve else None
|
|
146
|
+
except Exception:
|
|
147
|
+
original = None
|
|
148
|
+
with page_lock():
|
|
149
|
+
on_edit = original == "edit"
|
|
150
|
+
if original and not on_edit:
|
|
151
|
+
try:
|
|
152
|
+
on_edit = bool(resolve.OpenPage("edit"))
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
try:
|
|
156
|
+
yield on_edit
|
|
157
|
+
finally:
|
|
158
|
+
# Restore only a page we actually left: `on_edit` is False here when
|
|
159
|
+
# OpenPage refused, and restoring then would flip a page the user is
|
|
160
|
+
# still on.
|
|
161
|
+
if original and original != "edit" and on_edit:
|
|
162
|
+
try:
|
|
163
|
+
resolve.OpenPage(original)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|