davinci-resolve-mcp 2.77.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 +68 -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/resolve-advanced/server/aaf_probe.py +137 -0
- 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,74 @@
|
|
|
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
|
+
|
|
39
|
+
## What's New in v2.78.0
|
|
40
|
+
|
|
41
|
+
The AAF probe reports where in the *source* an event actually lives, not where it
|
|
42
|
+
lives in the consolidated fragment the AAF happens to reference.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **`srcIn`/`srcOut` were handle offsets, not positions in the take.** A
|
|
47
|
+
`SourceClip`'s `start` is an offset into whatever mob it references *directly*,
|
|
48
|
+
and for consolidated media that mob is a per-cut fragment carrying handles — not
|
|
49
|
+
the take. On a real turnover 774 of 878 events reported `srcIn <= 45`, and one
|
|
50
|
+
take used thirteen times reported `srcIn 40` all thirteen times; two cuts of one
|
|
51
|
+
take cannot both begin at frame 42 of it. A consumer placing those numbers
|
|
52
|
+
against camera originals lands on the right cut of the right take showing the
|
|
53
|
+
**wrong moment**, and the number fits inside the file, so no range check catches
|
|
54
|
+
it. The consumer measured 2 of 526 cuts matching its picture reference before
|
|
55
|
+
this.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **Physical source position and timecode per event** — `srcPos`, `srcTcFrame`,
|
|
60
|
+
`srcTc`, `srcTcFps`, `srcTcDrop`. The chase sums `start` down the mob chain and
|
|
61
|
+
reads the nearest mob's timecode slot, so a consumer that links camera originals
|
|
62
|
+
can place `sourceTc − fileStartTc` instead of a fragment-relative number. Emitted
|
|
63
|
+
only when actually read: a chain that adds nothing emits nothing rather than
|
|
64
|
+
restating `srcIn`, and per-sequence `sourcePositionCoverage` counters let a
|
|
65
|
+
consumer distinguish "this AAF carries none" from "this probe is too old to emit
|
|
66
|
+
it." `srcIn`/`srcOut` are unchanged — this is additive.
|
|
67
|
+
|
|
68
|
+
Verified on the fixture: 869/878 events carry `srcPos`, 876 carry source timecode,
|
|
69
|
+
and the take used thirteen times separates into thirteen distinct positions.
|
|
70
|
+
Frame-exact against an independent witness — at the frame the turnover's own
|
|
71
|
+
picture reference burned 21:19:28:21, the probe says 21:19:28:21.
|
|
72
|
+
|
|
5
73
|
## What's New in v2.77.0
|
|
6
74
|
|
|
7
75
|
Folder addressing fails loud instead of quietly answering about whichever bin the
|
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.
|
|
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
|
@@ -160,6 +160,133 @@ def _source_name(clip):
|
|
|
160
160
|
return _usable_name(clip) or "UNKNOWN"
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
# ── Physical source position + timecode (the "which frames" question) ─────────
|
|
164
|
+
#
|
|
165
|
+
# MEASURED 2026-08-05 on a consolidated Avid turnover: a SourceClip's own
|
|
166
|
+
# `start` is the offset into whatever mob it references DIRECTLY, and for
|
|
167
|
+
# consolidated media that is a per-cut fragment with ~40-frame handles — so 774
|
|
168
|
+
# of 878 events reported srcIn <= 45, and takes used several times all reported
|
|
169
|
+
# the SAME srcIn with different lengths. Two different cuts of one take cannot
|
|
170
|
+
# both begin at frame 42 of that take; those 42s were handles on separate
|
|
171
|
+
# fragments.
|
|
172
|
+
#
|
|
173
|
+
# The real position is the SUM of the `start` offsets down the mob chain, and
|
|
174
|
+
# the anchor that survives relinking to different media is the physical source
|
|
175
|
+
# TIMECODE: the referenced mob's timecode start plus that accumulated offset.
|
|
176
|
+
# A consumer that links camera originals can then place
|
|
177
|
+
# `sourceTc - fileStartTc` instead of a fragment-relative number that merely
|
|
178
|
+
# FITS inside the file.
|
|
179
|
+
#
|
|
180
|
+
# Emitted per event as srcPos / srcTcFrame / srcTc / srcTcFps / srcTcDrop, and
|
|
181
|
+
# only when actually found — a consumer must be able to tell "this AAF carries
|
|
182
|
+
# no source timecode" from "this probe is too old to emit it", which is what
|
|
183
|
+
# the per-sequence sourceTimecodeCoverage counters are for.
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _mob_timecode(mob, _cache={}):
|
|
187
|
+
"""(startFrame, rate, drop) from a mob's timecode slot, or None."""
|
|
188
|
+
try:
|
|
189
|
+
key = str(getattr(mob, "mob_id", "") or id(mob))
|
|
190
|
+
except Exception:
|
|
191
|
+
key = str(id(mob))
|
|
192
|
+
if key in _cache:
|
|
193
|
+
return _cache[key]
|
|
194
|
+
found = None
|
|
195
|
+
for slot in getattr(mob, "slots", None) or []:
|
|
196
|
+
try:
|
|
197
|
+
if _slot_media_kind(slot) != _TIMECODE_KIND:
|
|
198
|
+
continue
|
|
199
|
+
except Exception:
|
|
200
|
+
continue
|
|
201
|
+
tc = _timecode_component(getattr(slot, "segment", None))
|
|
202
|
+
if tc is None:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
start = int(getattr(tc, "start"))
|
|
206
|
+
rate = int(round(float(getattr(tc, "fps", 0) or 0)))
|
|
207
|
+
drop = bool(getattr(tc, "drop", False))
|
|
208
|
+
except Exception:
|
|
209
|
+
continue
|
|
210
|
+
if rate > 0 and start >= 0:
|
|
211
|
+
found = (start, rate, drop)
|
|
212
|
+
break
|
|
213
|
+
_cache[key] = found
|
|
214
|
+
return found
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _chase_source_position(clip):
|
|
218
|
+
"""Physical source position + timecode for a SourceClip's FIRST frame.
|
|
219
|
+
|
|
220
|
+
Walks clip -> referenced mob -> that mob's own SourceClip, accumulating each
|
|
221
|
+
hop's `start`. Returns (position, timecode_or_None) where `timecode` is
|
|
222
|
+
(tc_start, rate, drop, position_at_that_mob) for the NEAREST mob carrying a
|
|
223
|
+
timecode slot. Bounded and cycle-guarded like _source_name.
|
|
224
|
+
"""
|
|
225
|
+
current = clip
|
|
226
|
+
position = 0
|
|
227
|
+
timecode = None
|
|
228
|
+
seen = set()
|
|
229
|
+
for _ in range(_MAX_MOB_CHASE):
|
|
230
|
+
try:
|
|
231
|
+
position += int(getattr(current, "start", 0) or 0)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
try:
|
|
235
|
+
mob = getattr(current, "mob", None)
|
|
236
|
+
except Exception:
|
|
237
|
+
mob = None
|
|
238
|
+
if mob is None:
|
|
239
|
+
break
|
|
240
|
+
try:
|
|
241
|
+
key = str(getattr(mob, "mob_id", "") or id(mob))
|
|
242
|
+
except Exception:
|
|
243
|
+
key = str(id(mob))
|
|
244
|
+
if key in seen:
|
|
245
|
+
break # reference cycle — stop rather than spin
|
|
246
|
+
seen.add(key)
|
|
247
|
+
if timecode is None:
|
|
248
|
+
tc = _mob_timecode(mob)
|
|
249
|
+
if tc is not None:
|
|
250
|
+
timecode = (tc[0], tc[1], tc[2], position)
|
|
251
|
+
nxt = None
|
|
252
|
+
for slot in getattr(mob, "slots", None) or []:
|
|
253
|
+
nxt = _find_source_clip(getattr(slot, "segment", None))
|
|
254
|
+
if nxt is not None:
|
|
255
|
+
break
|
|
256
|
+
if nxt is None:
|
|
257
|
+
break
|
|
258
|
+
current = nxt
|
|
259
|
+
return position, timecode
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _source_position_fields(clip):
|
|
263
|
+
"""The srcPos/srcTc* keys for an event. Empty dict when nothing is knowable."""
|
|
264
|
+
try:
|
|
265
|
+
position, timecode = _chase_source_position(clip)
|
|
266
|
+
except Exception:
|
|
267
|
+
return {}
|
|
268
|
+
fields = {}
|
|
269
|
+
try:
|
|
270
|
+
own_start = int(getattr(clip, "start", 0) or 0)
|
|
271
|
+
except Exception:
|
|
272
|
+
own_start = 0
|
|
273
|
+
if position != own_start:
|
|
274
|
+
# Only meaningful when the chain actually went deeper than the clip's
|
|
275
|
+
# own reference — otherwise srcPos would just restate srcIn.
|
|
276
|
+
fields["srcPos"] = position
|
|
277
|
+
if timecode is not None:
|
|
278
|
+
tc_start, rate, drop, at = timecode
|
|
279
|
+
fields.update(
|
|
280
|
+
{
|
|
281
|
+
"srcTcFrame": tc_start + at,
|
|
282
|
+
"srcTc": _frames_to_timecode(tc_start + at, rate, drop),
|
|
283
|
+
"srcTcFps": rate,
|
|
284
|
+
"srcTcDrop": drop,
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
return fields
|
|
288
|
+
|
|
289
|
+
|
|
163
290
|
def _emit_source_clip(clip, *, index, track, rec, fps, transition=None):
|
|
164
291
|
"""Turn a SourceClip into a normalized event. Returns (event, length)."""
|
|
165
292
|
try:
|
|
@@ -183,6 +310,7 @@ def _emit_source_clip(clip, *, index, track, rec, fps, transition=None):
|
|
|
183
310
|
"transition": transition,
|
|
184
311
|
"fps": fps,
|
|
185
312
|
}
|
|
313
|
+
event.update(_source_position_fields(clip))
|
|
186
314
|
return event, length
|
|
187
315
|
|
|
188
316
|
|
|
@@ -884,6 +1012,15 @@ def probe(path):
|
|
|
884
1012
|
"name": str(name) if name else f"Sequence {len(sequences) + 1}",
|
|
885
1013
|
"eventCount": len(events),
|
|
886
1014
|
**_sequence_start_timecode(mob, edit_fps),
|
|
1015
|
+
# How much of this sequence carries a physical source position
|
|
1016
|
+
# / timecode. Always present, so a consumer can tell "this AAF
|
|
1017
|
+
# has none" (zeros) from "this probe is too old to emit it"
|
|
1018
|
+
# (key absent) — the same reasoning as _NO_START_TIMECODE.
|
|
1019
|
+
"sourcePositionCoverage": {
|
|
1020
|
+
"events": len(events),
|
|
1021
|
+
"withSourcePosition": sum(1 for e in events if "srcPos" in e),
|
|
1022
|
+
"withSourceTimecode": sum(1 for e in events if "srcTcFrame" in e),
|
|
1023
|
+
},
|
|
887
1024
|
# Component classes we could not model, by name+count. Empty {} means
|
|
888
1025
|
# a structurally complete read; non-empty means events are INCOMPLETE.
|
|
889
1026
|
"unhandled": dict(sorted(state["unhandled"].items())),
|
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.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.
|
|
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
|