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 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
- [![Version](https://img.shields.io/badge/version-2.77.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.78.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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:** 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.
382
- - **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.
383
- - **Tags:** unreliable-return, flaky, timeline, edit
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.77.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.77.0",
3
+ "version": "2.78.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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())),
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.77.0"
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.77.0"
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 (flaky first attempt)': the call can
3925
- return False while every item is still present, and an identical retry
3926
- then succeeds. On a False, read the tracks back:
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
- if bool(tl.DeleteClips(items, ripple)):
3942
- return True
3943
- presence = _timeline_items_presence(tl, items)
3944
- if presence != "present":
3945
- return presence == "absent"
3946
- if bool(tl.DeleteClips(items, ripple)):
3947
- return True
3948
- return _timeline_items_presence(tl, items) == "absent"
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
- for c in applicable:
21188
- sp = c["span"]
21189
- res = _timeline_lift_range_impl(tl, {
21190
- "start_frame": sp["start"],
21191
- "end_frame": sp["end"],
21192
- "ripple": c["action"] == "ripple_delete",
21193
- "allow_partial_item_delete": allow_partial,
21194
- })
21195
- results.append({"action": c["action"], "span": sp, "result": res})
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),
@@ -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": "Can return False on the first call even when every item in "
958
- "the list is a valid, present TimelineItem; an identical "
959
- "immediate retry succeeds. Observed once, on Studio 21.0 "
960
- "during a cut-video edit session (items confirmed still "
961
- "present after the False, deleted cleanly on retry). Cause "
962
- "unknown do NOT read this as the ProjectManager."
963
- "DeleteProject shape: that one has an identified mechanism "
964
- "(the project being, or recently having been, current) that "
965
- "retrying does not clear, whereas a single retry cleared "
966
- "this in the one instance seen. One observation is not a "
967
- "mechanism; if a retry is ever seen to fail repeatedly here, "
968
- "this entry needs revisiting.",
969
- "recommended": "Treat a False return as advisory: re-list the track and "
970
- "check whether the items are actually gone; if still "
971
- "present, retry the identical call once before failing. "
972
- "A readback that raised, enumerated nothing, or covered "
973
- "items whose unique ID cannot be read is UNKNOWN, not "
974
- "gone never report an unverifiable delete as success, "
975
- "and do not spend a second destructive call on an "
976
- "outcome you equally cannot read.",
977
- "tags": ["unreliable-return", "flaky", "timeline", "edit"],
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
  },
@@ -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