davinci-resolve-mcp 2.104.0 → 2.104.2

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +2 -2
  4. package/docs/guides/headless-edit-loop.md +15 -0
  5. package/install.py +33 -12
  6. package/package.json +1 -1
  7. package/resolve-advanced/server/db-patch.mjs +15 -3
  8. package/resolve-advanced/server/offline-ref-db.mjs +8 -2
  9. package/resolve-advanced/vendor/drp-format/__tests__/framerate-encoding.test.js +47 -0
  10. package/resolve-advanced/vendor/drp-format/drp-packager.js +1 -0
  11. package/resolve-advanced/vendor/drp-format/seq-container-builder.js +11 -3
  12. package/resolve-advanced/vendor/drp-format/xml-builder.js +23 -19
  13. package/scripts/doctor.py +85 -39
  14. package/src/granular/common.py +1 -1
  15. package/src/granular/media_pool.py +15 -7
  16. package/src/granular/timeline.py +10 -5
  17. package/src/granular/timeline_item.py +21 -7
  18. package/src/server.py +497 -93
  19. package/src/utils/api_truth.py +17 -5
  20. package/src/utils/app_control.py +11 -1
  21. package/src/utils/audio_fairlight_live_probe.py +8 -1
  22. package/src/utils/color_grade_live_probe.py +8 -1
  23. package/src/utils/fusion_composition_live_probe.py +8 -1
  24. package/src/utils/probe_catalogue.py +15 -3
  25. package/src/utils/project_cleanup.py +17 -3
  26. package/src/utils/render_deliver_live_probe.py +8 -1
  27. package/src/utils/resolve_bridge_client.py +10 -1
  28. package/src/utils/resolve_writes.py +79 -0
  29. package/src/utils/review_annotation_live_probe.py +8 -1
  30. package/src/utils/timeline_conform_live_probe.py +8 -1
  31. package/src/utils/timeline_kernel_live_probe.py +7 -1
  32. package/src/utils/timeline_versioning.py +15 -2
@@ -1875,10 +1875,20 @@ API_TRUTH: List[Dict[str, Any]] = [
1875
1875
  "(frame 0 == first frame), while recordFrame and "
1876
1876
  "TimelineItem.GetStart/GetEnd are absolute. Distinct from "
1877
1877
  "the null-id entry below, which is a recordFrame landing in "
1878
- "an OCCUPIED span rather than before the start. Verified on "
1878
+ "an OCCUPIED span rather than before the start. Worse, the "
1879
+ "render JOB's own metadata lies too: Resolve rewrites the "
1880
+ "job's MarkIn/MarkOut down to the collapsed extent (measured "
1881
+ "live on 19.1.3.7: 96 frames of content before start -> an "
1882
+ "explicit 96-frame mark range became a 1-frame job, Complete "
1883
+ "at 100%, a 1-frame black stub), so output-duration vs "
1884
+ "job-mark-range checks read clean. The only truthful "
1885
+ "readback is the timeline items themselves, which report "
1886
+ "their real GetStart/GetEnd — before the start frame. "
1887
+ "Verified on "
1879
1888
  "Studio 20.3.2.9 (v2.17.1 probe: relative record_frame 12 "
1880
- "landed at 86400 + 12 = 86412, absolute preserved 86484) and "
1881
- "independently on Studio 21.0.4.5 (issue #164).",
1889
+ "landed at 86400 + 12 = 86412, absolute preserved 86484), "
1890
+ "independently on Studio 21.0.4.5 (issue #164), and the "
1891
+ "mark-range rewrite on Studio 19.1.3.7 (2026-08-29).",
1882
1892
  "recommended": "Offset every recordFrame by timeline.GetStartFrame(). "
1883
1893
  "This server already does it: "
1884
1894
  "media_pool.append_to_timeline defaults to "
@@ -1888,8 +1898,10 @@ API_TRUTH: List[Dict[str, Any]] = [
1888
1898
  "value below the timeline start is refused outright. "
1889
1899
  "When driving the API directly, "
1890
1900
  "never treat JobStatus Complete as proof a render "
1891
- "worked — check the output file's duration, not just "
1892
- "that the job finished.",
1901
+ "worked — and do not trust the job's MarkIn/MarkOut as "
1902
+ "the expected duration either. render.verify_output "
1903
+ "(v2.104.1) cross-checks the job against its timeline's "
1904
+ "item extents and flags items before the start frame.",
1893
1905
  "tags": ["timeline", "edit", "render", "silent-failure", "media-pool"],
1894
1906
  "issue": 164,
1895
1907
  },
@@ -78,13 +78,23 @@ def quit_resolve_app(resolve_obj, force: bool = False, save_project: bool = True
78
78
  if project and save_project:
79
79
  logger.info("Saving project before quitting")
80
80
  # Try to save the project
81
+ # Only the exception was handled. SaveProject also reports a
82
+ # refusal with a bare False -- which is what a project attached
83
+ # to no database returns -- and quitting on that throws the
84
+ # session away silently.
81
85
  try:
82
- project.SaveProject()
86
+ saved = project.SaveProject()
83
87
  except Exception as e:
84
88
  logger.error(f"Failed to save project: {str(e)}")
85
89
  if not force:
86
90
  logger.error("Aborting quit due to save failure")
87
91
  return False
92
+ else:
93
+ if saved is False:
94
+ logger.error("SaveProject returned False; the project was NOT saved")
95
+ if not force:
96
+ logger.error("Aborting quit due to save failure")
97
+ return False
88
98
 
89
99
  # Attempt to quit using the API
90
100
  if hasattr(resolve_obj, 'Quit') and callable(getattr(resolve_obj, 'Quit')):
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
17
+ from .resolve_writes import set_current_timeline, describe_switch_failure
17
18
 
18
19
 
19
20
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -146,7 +147,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
146
147
  timeline = media_pool.CreateTimelineFromClips(timeline_name, [video_clip])
147
148
  if not timeline:
148
149
  raise AssertionError("Failed to create audio timeline")
149
- project.SetCurrentTimeline(timeline)
150
+ # A discarded False here would run the whole probe against whatever
151
+ # timeline was already current, and every measurement below would be a
152
+ # reading of the wrong thing reported as a reading of this one.
153
+ switched, switch_detail = set_current_timeline(project, timeline)
154
+ if not switched:
155
+ raise AssertionError(
156
+ describe_switch_failure(switch_detail, "probing the audio timeline"))
150
157
  print(f"Created timeline: {timeline_name}")
151
158
 
152
159
  video_clip_id = _clip_id(video_clip)
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
  from typing import Any, Dict, Optional
16
16
 
17
17
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
18
+ from .resolve_writes import set_current_timeline, describe_switch_failure
18
19
 
19
20
 
20
21
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -188,7 +189,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
188
189
  timeline = media_pool.CreateTimelineFromClips(timeline_name, [clip])
189
190
  if not timeline:
190
191
  raise AssertionError("Failed to create color grade timeline")
191
- project.SetCurrentTimeline(timeline)
192
+ # A discarded False here would run the whole probe against whatever
193
+ # timeline was already current, and every measurement below would be a
194
+ # reading of the wrong thing reported as a reading of this one.
195
+ switched, switch_detail = set_current_timeline(project, timeline)
196
+ if not switched:
197
+ raise AssertionError(
198
+ describe_switch_failure(switch_detail, "probing the color grade timeline"))
192
199
  media_pool.AppendToTimeline([clip])
193
200
  timeline.SetCurrentTimecode("01:00:00:01")
194
201
  server.resolve_control("open_page", {"page": "color"})
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
17
+ from .resolve_writes import set_current_timeline, describe_switch_failure
17
18
 
18
19
 
19
20
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -139,7 +140,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
139
140
  timeline = media_pool.CreateTimelineFromClips(timeline_name, [clip])
140
141
  if not timeline:
141
142
  raise AssertionError("Failed to create Fusion timeline")
142
- project.SetCurrentTimeline(timeline)
143
+ # A discarded False here would run the whole probe against whatever
144
+ # timeline was already current, and every measurement below would be a
145
+ # reading of the wrong thing reported as a reading of this one.
146
+ switched, switch_detail = set_current_timeline(project, timeline)
147
+ if not switched:
148
+ raise AssertionError(
149
+ describe_switch_failure(switch_detail, "probing the Fusion timeline"))
143
150
  timeline.SetCurrentTimecode("01:00:00:01")
144
151
  print(f"Created timeline: {timeline_name}")
145
152
 
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  from typing import Any, List
26
26
 
27
27
  from src.utils.mode_matrix import Probe
28
+ from .resolve_writes import set_current_timeline, describe_switch_failure
28
29
 
29
30
  CATALOGUE: List[Probe] = []
30
31
 
@@ -731,11 +732,22 @@ def _probe_editorial_nested(ctx):
731
732
  if (c.GetClipProperty("Type") or "") == "Timeline"]
732
733
  if not nested:
733
734
  return None
734
- ctx.project.SetCurrentTimeline(outer)
735
+ # AppendToTimeline writes to the CURRENT timeline, so a discarded False
736
+ # here would nest the clip into ctx.timeline and then count PROBE_OUTER's
737
+ # empty track -- a measurement of the wrong timeline, reported as this one's.
738
+ switched, detail = set_current_timeline(ctx.project, outer)
739
+ if not switched:
740
+ return describe_switch_failure(detail, "nesting the probe timeline")
735
741
  appended = pool.AppendToTimeline([{"mediaPoolItem": nested[0]}])
736
742
  count = len(outer.GetItemListInTrack("video", 1) or [])
737
- ctx.project.SetCurrentTimeline(ctx.timeline)
738
- return f"{bool(appended)}/{count}"
743
+ restored, restore_detail = set_current_timeline(ctx.project, ctx.timeline)
744
+ result = f"{bool(appended)}/{count}"
745
+ if not restored:
746
+ # The measurement stands; the teardown did not. Say so in the result
747
+ # rather than leaving the next probe on PROBE_OUTER without warning.
748
+ result += " (probe timeline left current: " + \
749
+ describe_switch_failure(restore_detail, "restoring the probe timeline") + ")"
750
+ return result
739
751
 
740
752
 
741
753
  @probe("editorial.set_clips_linked", "editorial", timeout=90, needs=("timeline",),
@@ -13,9 +13,12 @@ disposable-project flow gets it for free:
13
13
  instead of silently leaking.
14
14
  """
15
15
 
16
+ import logging
16
17
  import time
17
18
  from typing import Any, Dict, Optional
18
19
 
20
+ logger = logging.getLogger("davinci-resolve-mcp.project_cleanup")
21
+
19
22
 
20
23
  #: The name Resolve gives the project it opens when it has nothing else to open.
21
24
  #: It has never been saved and has no location, so it cannot be saved.
@@ -103,10 +106,21 @@ def delete_project_safely(
103
106
  # cannot be saved, so the next close or switch raises a modal no
104
107
  # script can dismiss. That is a wedged application, not a tidy-up.
105
108
  if switch_to and switch_to != name:
109
+ # A discarded False leaves the session on the unsaveable
110
+ # "Untitled Project" this comment exists to avoid, and the next
111
+ # close or switch raises a modal no script can dismiss. Logged
112
+ # loudly: the caller is mid-delete and cannot be aborted here,
113
+ # but a silent wedge is what made this hard to diagnose.
106
114
  try:
107
- pm.LoadProject(switch_to)
108
- except Exception:
109
- pass
115
+ if not pm.LoadProject(switch_to):
116
+ logger.error(
117
+ "LoadProject(%r) returned False after closing %r; the "
118
+ "session is left on an unsaveable Untitled Project and "
119
+ "the next close will raise a modal", switch_to, name,
120
+ )
121
+ except Exception as exc:
122
+ logger.error("LoadProject(%r) raised after closing %r: %s",
123
+ switch_to, name, exc)
110
124
 
111
125
  last_error = None
112
126
  for attempt in range(1 + max(0, int(retries))):
@@ -20,6 +20,7 @@ from pathlib import Path
20
20
  from typing import Any, Dict, Optional
21
21
 
22
22
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
23
+ from .resolve_writes import set_current_timeline, describe_switch_failure
23
24
 
24
25
 
25
26
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -183,7 +184,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
183
184
  timeline = media_pool.CreateTimelineFromClips(timeline_name, [clip])
184
185
  if not timeline:
185
186
  raise AssertionError("Failed to create synthetic render timeline")
186
- project.SetCurrentTimeline(timeline)
187
+ # A discarded False here would run the whole probe against whatever
188
+ # timeline was already current, and every measurement below would be a
189
+ # reading of the wrong thing reported as a reading of this one.
190
+ switched, switch_detail = set_current_timeline(project, timeline)
191
+ if not switched:
192
+ raise AssertionError(
193
+ describe_switch_failure(switch_detail, "probing the synthetic render timeline"))
187
194
  print(f"Created timeline: {timeline_name}")
188
195
 
189
196
  chosen = _choose_render_format_codec(server)
@@ -236,7 +236,16 @@ class _BoundMethod:
236
236
  self._handle = handle
237
237
  self._name = name
238
238
 
239
- def __call__(self, *args: Any) -> Any:
239
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
240
+ if kwargs:
241
+ raise TypeError(
242
+ f"{self._name}() was called with keyword argument(s) "
243
+ f"{sorted(kwargs)} through the in-app bridge, which proxies "
244
+ "Resolve calls positionally. Pass every argument positionally "
245
+ "(Resolve's own signatures accept it) — e.g. "
246
+ "StartRendering([job_ids], False) instead of "
247
+ "StartRendering([job_ids], isInteractiveMode=False)."
248
+ )
240
249
  result = self._transport.request(
241
250
  "call",
242
251
  {"target": self._handle, "method": self._name,
@@ -0,0 +1,79 @@
1
+ """Resolve mutators whose return value must not be discarded.
2
+
3
+ Every write in the DaVinci Resolve scripting API reports itself with a bare
4
+ boolean and nothing else. Dropping that boolean turns a failed write into a
5
+ successful-looking no-op, which is the only failure shape worse than a crash:
6
+ the tool reports what it meant to do, and the project does not have it.
7
+
8
+ `SetCurrentTimeline` is the sharpest case in this repo, which is why it lives
9
+ here. `AppendToTimeline`, `GetCurrentTimeline` and every other "current
10
+ timeline" call writes to whatever the PROJECT believes is current, never to the
11
+ handle in scope, so a discarded `False` sends an assembly to the timeline the
12
+ editor was looking at while the caller reports the new timeline's name and id.
13
+ """
14
+
15
+ from typing import Any, Dict, Optional, Tuple
16
+
17
+
18
+ def unique_id(obj: Any) -> Optional[str]:
19
+ """`obj.GetUniqueId()`, or None. Never raises."""
20
+ if obj is None:
21
+ return None
22
+ try:
23
+ return obj.GetUniqueId()
24
+ except Exception:
25
+ return None
26
+
27
+
28
+ def set_current_timeline(project: Any, timeline: Any) -> Tuple[bool, Dict[str, Any]]:
29
+ """Switch the project's current timeline. Returns (ok, detail).
30
+
31
+ The bare bool is not trusted on its own. A Resolve attached to no database
32
+ answers version queries normally while every project mutation returns False
33
+ or None, and a `True` that did not take has been observed there, so the
34
+ switch is confirmed by reading the current timeline back.
35
+
36
+ When the readback is unavailable (a build or transport that does not answer
37
+ `GetCurrentTimeline`) the bool is the only evidence there is, and it is
38
+ used. That is a weaker check, not a reason to refuse the work.
39
+
40
+ `detail` always carries `returned`, `wanted_timeline_id`,
41
+ `current_timeline_id` and, on an exception, `exception`.
42
+ """
43
+ detail: Dict[str, Any] = {
44
+ "returned": None,
45
+ "wanted_timeline_id": unique_id(timeline),
46
+ "current_timeline_id": None,
47
+ }
48
+ try:
49
+ detail["returned"] = project.SetCurrentTimeline(timeline)
50
+ except Exception as exc:
51
+ detail["exception"] = str(exc)
52
+ return False, detail
53
+
54
+ try:
55
+ detail["current_timeline_id"] = unique_id(project.GetCurrentTimeline())
56
+ except Exception:
57
+ detail["current_timeline_id"] = None
58
+
59
+ wanted = detail["wanted_timeline_id"]
60
+ current = detail["current_timeline_id"]
61
+ if wanted is not None and current is not None:
62
+ return wanted == current, detail
63
+ return bool(detail["returned"]), detail
64
+
65
+
66
+ def describe_switch_failure(detail: Dict[str, Any], what: str) -> str:
67
+ """One line naming what did not happen, for a log or an error envelope."""
68
+ if "exception" in detail:
69
+ return (
70
+ f"Could not switch the current timeline before {what}: "
71
+ f"{detail['exception']}. Nothing was written."
72
+ )
73
+ return (
74
+ f"Could not switch the current timeline before {what}. "
75
+ f"SetCurrentTimeline returned {detail.get('returned')!r} and the current "
76
+ f"timeline is still {detail.get('current_timeline_id')!r}, not "
77
+ f"{detail.get('wanted_timeline_id')!r}. Continuing would write to the "
78
+ "wrong timeline while reporting success."
79
+ )
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
17
+ from .resolve_writes import set_current_timeline, describe_switch_failure
17
18
 
18
19
 
19
20
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -148,7 +149,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
148
149
  timeline = media_pool.CreateTimelineFromClips(timeline_name, [clip])
149
150
  if not timeline:
150
151
  raise AssertionError("Failed to create review annotation timeline")
151
- project.SetCurrentTimeline(timeline)
152
+ # A discarded False here would run the whole probe against whatever
153
+ # timeline was already current, and every measurement below would be a
154
+ # reading of the wrong thing reported as a reading of this one.
155
+ switched, switch_detail = set_current_timeline(project, timeline)
156
+ if not switched:
157
+ raise AssertionError(
158
+ describe_switch_failure(switch_detail, "probing the review annotation timeline"))
152
159
  timeline.SetCurrentTimecode("01:00:00:01")
153
160
  print(f"Created timeline: {timeline_name}")
154
161
 
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, Optional
15
15
 
16
16
  from src.utils.timeline_kernel_probe import ProbeRecorder, render_markdown_report, utc_timestamp
17
+ from .resolve_writes import set_current_timeline, describe_switch_failure
17
18
 
18
19
 
19
20
  def _require_success(label: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -157,7 +158,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
157
158
  )
158
159
  if not timeline:
159
160
  raise AssertionError("Failed to create conform timeline")
160
- project.SetCurrentTimeline(timeline)
161
+ # A discarded False here would run the whole probe against whatever
162
+ # timeline was already current, and every measurement below would be a
163
+ # reading of the wrong thing reported as a reading of this one.
164
+ switched, switch_detail = set_current_timeline(project, timeline)
165
+ if not switched:
166
+ raise AssertionError(
167
+ describe_switch_failure(switch_detail, "probing the conform timeline"))
161
168
  media_pool.AppendToTimeline(
162
169
  [
163
170
  {"mediaPoolItem": clip_b, "startFrame": 0, "endFrame": 48, "recordFrame": 86472, "trackIndex": 1, "mediaType": 1},
@@ -947,7 +947,13 @@ def run_probe(server, output_dir: Path, keep_open: bool = False) -> Dict[str, An
947
947
  audio_item = server._find_timeline_item_by_id(timeline, audio_id)
948
948
  if not audio_item:
949
949
  raise AssertionError(f"Could not recover audio item: {audio_id}")
950
- timeline.SetClipsLinked([source_item, audio_item], True)
950
+ # Everything the probe measures below assumes the pair is linked. A
951
+ # discarded False would measure UNLINKED behaviour and record it as
952
+ # linked behaviour.
953
+ if not timeline.SetClipsLinked([source_item, audio_item], True):
954
+ raise AssertionError(
955
+ "SetClipsLinked refused the source/audio pair; every linked-item "
956
+ "measurement below would be a reading of unlinked items")
951
957
 
952
958
  source_duration = _frame_int(source_item.GetDuration())
953
959
  metadata["source"] = {
@@ -35,6 +35,7 @@ from src.utils.resolve_probe import has_method
35
35
  from typing import Any, Dict, List, Optional, Tuple
36
36
 
37
37
  from src.utils import actor_identity, timeline_brain_db
38
+ from .resolve_writes import set_current_timeline, describe_switch_failure
38
39
 
39
40
  logger = logging.getLogger("resolve-mcp.timeline-versioning")
40
41
 
@@ -166,7 +167,16 @@ def archive_current_timeline(
166
167
  # DuplicateTimeline drops the duplicate in the *current* folder (Archive),
167
168
  # but it also switches Resolve's current timeline to the duplicate. Restore
168
169
  # the working timeline as the active one so downstream mutation hits it.
169
- project.SetCurrentTimeline(tl)
170
+ # A discarded False here sends every subsequent mutation into the ARCHIVE
171
+ # copy while the caller believes it is editing the working timeline, so the
172
+ # archive fails loudly instead.
173
+ switched, switch_detail = set_current_timeline(project, tl)
174
+ if not switched:
175
+ return {"success": False,
176
+ "error": describe_switch_failure(
177
+ switch_detail, "returning to the working timeline after archiving"),
178
+ "archived_timeline_name": archived_name,
179
+ "switch": switch_detail}
170
180
 
171
181
  # Timebase snapshot — timeline_clip_usage stores ABSOLUTE record frames
172
182
  # (GetStart() includes the start-timecode offset), so readers need the
@@ -612,10 +622,13 @@ def rollback_to_version(
612
622
  if restored is None:
613
623
  return {"success": False, "error": f"DuplicateTimeline('{restored_name}') failed"}
614
624
 
615
- project.SetCurrentTimeline(restored)
625
+ switched, switch_detail = set_current_timeline(project, restored)
616
626
 
617
627
  return {
618
628
  "success": True,
629
+ "current_timeline_switched": switched,
630
+ "switch_error": (None if switched else describe_switch_failure(
631
+ switch_detail, "opening the restored timeline")),
619
632
  "rolled_back_from_version": version,
620
633
  "restored_timeline_name": restored_name,
621
634
  "archive_of_previous": archive_result,