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
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.104.0"
14
+ VERSION = "2.104.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -44,6 +44,7 @@ for p in [current_dir, project_dir]:
44
44
 
45
45
  # Platform-specific Resolve paths
46
46
  from src.utils.cdl import normalize_cdl_payload
47
+ from src.utils import resolve_writes as _resolve_writes
47
48
  from src.utils.mcp_stdio import run_fastmcp_stdio
48
49
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
50
  from src.utils import clip_colors as _clip_colors
@@ -6421,12 +6422,28 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
6421
6422
  new_tl = mp.CreateEmptyTimeline(name)
6422
6423
  if not new_tl:
6423
6424
  return _err(f"Failed to create timeline: {name}")
6424
- proj.SetCurrentTimeline(new_tl)
6425
+ switch_err = _set_current_timeline_checked(proj, new_tl, what="building the variant")
6426
+ if switch_err:
6427
+ return switch_err
6425
6428
  if p.get("start_timecode"):
6429
+ # A refused start timecode leaves the variant at the default hour while
6430
+ # every record frame below was computed against the requested one, so
6431
+ # the whole build lands at the wrong absolute time.
6426
6432
  try:
6427
- new_tl.SetStartTimecode(p["start_timecode"])
6428
- except Exception:
6429
- pass
6433
+ start_tc_set = bool(new_tl.SetStartTimecode(p["start_timecode"]))
6434
+ start_tc_error = None
6435
+ except Exception as exc:
6436
+ start_tc_set, start_tc_error = False, str(exc)
6437
+ if not start_tc_set:
6438
+ return _err(
6439
+ f"SetStartTimecode({p['start_timecode']!r}) was refused on '{name}'",
6440
+ code="START_TIMECODE_REFUSED", category="api_error", retryable=False,
6441
+ reason=(start_tc_error or
6442
+ "Resolve returned False. The variant would sit at the default "
6443
+ "start timecode while its record frames assume the requested one."),
6444
+ remediation="Check the timecode is valid for the timeline's frame rate, "
6445
+ "or omit start_timecode to build at the default start.",
6446
+ )
6430
6447
  for track_type, needed in max_tracks.items():
6431
6448
  while int(new_tl.GetTrackCount(track_type) or 0) < needed:
6432
6449
  if not new_tl.AddTrack(track_type):
@@ -6673,7 +6690,16 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
6673
6690
  sampled.append(sample)
6674
6691
  continue
6675
6692
  try:
6676
- tl.SetCurrentTimecode(timecode)
6693
+ # A refused playhead move means the thumbnail below is the
6694
+ # frame the playhead was ALREADY on -- a sheet of the wrong
6695
+ # frames, labelled with the frames that were asked for.
6696
+ if not tl.SetCurrentTimecode(timecode):
6697
+ sample["error"] = (
6698
+ f"Playhead would not move to {timecode}; no thumbnail was "
6699
+ "sampled here rather than sampling the wrong frame"
6700
+ )
6701
+ sampled.append(sample)
6702
+ continue
6677
6703
  thumbnail = tl.GetCurrentClipThumbnailImage()
6678
6704
  if not thumbnail:
6679
6705
  sample["error"] = (
@@ -6690,11 +6716,7 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
6690
6716
  sample["error"] = str(exc)
6691
6717
  sampled.append(sample)
6692
6718
  finally:
6693
- if original_timecode:
6694
- try:
6695
- tl.SetCurrentTimecode(original_timecode)
6696
- except Exception:
6697
- pass
6719
+ _restore_playhead(tl, original_timecode, what="the contact sheet")
6698
6720
  sheet_samples = [sample for sample in sampled if sample.get("thumbnail_rgb")]
6699
6721
  if not sheet_samples:
6700
6722
  return {"success": False, "samples": sampled, "error": "No thumbnails could be sampled"}
@@ -6982,6 +7004,41 @@ _BINARY_INTERCHANGE_EXTS = {".aaf"}
6982
7004
  _JSON_INTERCHANGE_EXTS = {".otio"}
6983
7005
 
6984
7006
 
7007
+ _FCP7_SEQUENCE_NAME_RE = re.compile(
7008
+ r"(<sequence\b[^>]*>(?:(?!<name>|</sequence>)[\s\S])*?<name>)([\s\S]*?)(</name>)"
7009
+ )
7010
+
7011
+
7012
+ def _rewrite_fcp7_sequence_name(xml_path: str, new_name: str):
7013
+ """Copy an FCP7 XML with its first <sequence><name> replaced; (path, changed, error).
7014
+
7015
+ Resolve honours the sequence name INSIDE the file, not the timelineName
7016
+ import option: when the internal name matches an existing timeline, the
7017
+ import silently hands back that existing timeline (issue #171). Rewriting
7018
+ the internal name is the only way to make the option mean what it says.
7019
+ Surgical text replacement on a temp copy — the original is never touched,
7020
+ and everything but the one name survives byte-for-byte (an XML re-serialize
7021
+ would drop the xmeml DOCTYPE).
7022
+ """
7023
+ try:
7024
+ with open(xml_path, encoding="utf-8", errors="replace") as fh:
7025
+ text = fh.read()
7026
+ except OSError as exc:
7027
+ return None, False, f"could not read {xml_path}: {exc}"
7028
+ escaped = (
7029
+ new_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
7030
+ )
7031
+ text, count = _FCP7_SEQUENCE_NAME_RE.subn(
7032
+ lambda m: m.group(1) + escaped + m.group(3), text, count=1
7033
+ )
7034
+ if not count:
7035
+ return None, False, None
7036
+ fd, out_path = tempfile.mkstemp(prefix="timeline_name_", suffix=".xml")
7037
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
7038
+ fh.write(text)
7039
+ return out_path, True, None
7040
+
7041
+
6985
7042
  def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6986
7043
  path = p.get("path")
6987
7044
  if not path:
@@ -7112,6 +7169,22 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7112
7169
  ) if k in sres
7113
7170
  }
7114
7171
 
7172
+ # Resolve honours the interchange file's internal sequence name over the
7173
+ # timelineName option (issue #171): with a stale internal name that matches
7174
+ # an existing timeline, the import "succeeds" by returning the existing
7175
+ # timeline. For FCP7 XML the name is rewritable in place; for formats we
7176
+ # cannot rewrite, the created_new gate below turns the case into an error.
7177
+ requested_name = options.get("timelineName")
7178
+ sequence_name_rewritten = False
7179
+ if requested_name and not is_binary and not is_json:
7180
+ rewritten_path, sequence_name_rewritten, rewrite_err = _rewrite_fcp7_sequence_name(
7181
+ import_path, str(requested_name)
7182
+ )
7183
+ if rewrite_err:
7184
+ return _err(f"Could not rewrite the sequence name: {rewrite_err}")
7185
+ if sequence_name_rewritten:
7186
+ import_path = rewritten_path
7187
+
7115
7188
  if p.get("dry_run"):
7116
7189
  out = _ok(path=path, import_path=import_path, options=options, would_import=True)
7117
7190
  if sanitize_report is not None:
@@ -7198,14 +7271,38 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7198
7271
  relink_result = _binary_post_import_relink(imported, mp, search_roots)
7199
7272
  if relink_result.get("after"):
7200
7273
  media = relink_result["after"]
7274
+ created_new = imported_id not in before_ids
7275
+ if requested_name and not created_new:
7276
+ # The caller asked for a NEW timeline by name and got an existing
7277
+ # one back — the interchange file's internal sequence name matched
7278
+ # it and Resolve ignored the option (issue #171). success:true with
7279
+ # created_new:false read as an import; an iterating loop would
7280
+ # operate on the same timeline forever.
7281
+ return _err(
7282
+ f"Import returned the EXISTING timeline {imported.GetName()!r} "
7283
+ f"({imported_id}) instead of creating {requested_name!r}. Resolve "
7284
+ "honours the sequence name inside the file over the timelineName "
7285
+ "option, and this file's internal name matched an existing "
7286
+ "timeline.",
7287
+ category="api_error",
7288
+ remediation=(
7289
+ "Rewrite the sequence name inside the interchange file to the "
7290
+ "new name before importing (for FCP7 XML this wrapper does it "
7291
+ f"automatically; it could not for this file{' — the <sequence><name> pattern was not found' if not sequence_name_rewritten else ''}), "
7292
+ "or delete/rename the existing timeline first."
7293
+ ),
7294
+ state={"existing_name": imported.GetName(), "existing_id": imported_id},
7295
+ )
7201
7296
  out = _ok(
7202
7297
  name=imported.GetName(),
7203
7298
  id=imported_id,
7204
- created_new=imported_id not in before_ids,
7299
+ created_new=created_new,
7205
7300
  timeline_count=proj.GetTimelineCount(),
7206
7301
  api_returned_object=api_returned_object,
7207
7302
  media=media,
7208
7303
  )
7304
+ if sequence_name_rewritten:
7305
+ out["sequence_name_rewritten"] = True
7209
7306
  if sanitize_report is not None:
7210
7307
  out["sanitize"] = sanitize_report
7211
7308
  if relink_result is not None:
@@ -8781,15 +8878,28 @@ def _setup_multicam_timeline(proj, mp, p: Dict[str, Any]):
8781
8878
  new_tl = mp.CreateEmptyTimeline(plan["name"])
8782
8879
  if not new_tl:
8783
8880
  return _err(f"Failed to create multicam setup timeline: {plan['name']}")
8784
- try:
8785
- proj.SetCurrentTimeline(new_tl)
8786
- except Exception:
8787
- pass
8881
+ switch_err = _set_current_timeline_checked(proj, new_tl, what="stacking the angles")
8882
+ if switch_err:
8883
+ return switch_err
8788
8884
  if plan.get("start_timecode"):
8885
+ # Same as the variant builder: the angle stack's record frames assume
8886
+ # this start, so a refusal puts every angle at the wrong absolute time.
8789
8887
  try:
8790
- new_tl.SetStartTimecode(plan["start_timecode"])
8791
- except Exception:
8792
- pass
8888
+ start_tc_set = bool(new_tl.SetStartTimecode(plan["start_timecode"]))
8889
+ start_tc_error = None
8890
+ except Exception as exc:
8891
+ start_tc_set, start_tc_error = False, str(exc)
8892
+ if not start_tc_set:
8893
+ return _err(
8894
+ f"SetStartTimecode({plan['start_timecode']!r}) was refused on "
8895
+ f"'{plan['name']}'",
8896
+ code="START_TIMECODE_REFUSED", category="api_error", retryable=False,
8897
+ reason=(start_tc_error or
8898
+ "Resolve returned False. The angle stack would land at the "
8899
+ "wrong absolute time."),
8900
+ remediation="Check the timecode is valid for the timeline's frame rate, "
8901
+ "or omit start_timecode.",
8902
+ )
8793
8903
 
8794
8904
  audio_type = str(p.get("audio_type", p.get("audioType", "stereo")) or "stereo")
8795
8905
  video_tracks = _ensure_timeline_tracks(new_tl, "video", plan.get("max_video_track", 0))
@@ -10502,7 +10612,18 @@ def _apply_sync_event_markers(proj, detection: Dict[str, Any], p: Dict[str, Any]
10502
10612
  skipped.append({"clip_id": clip_id, "frame": marker["frame"], "reason": "Marker already exists", "custom_data": custom_data})
10503
10613
  continue
10504
10614
  if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"):
10505
- clip.DeleteMarkerByCustomData(custom_data)
10615
+ # A discarded False leaves the old marker in place, and AddMarker
10616
+ # then refuses the occupied frame -- reported as "add failed"
10617
+ # when the real failure was the delete.
10618
+ if not clip.DeleteMarkerByCustomData(custom_data):
10619
+ failed.append({
10620
+ "clip_id": clip_id, "frame": marker["frame"],
10621
+ "custom_data": custom_data,
10622
+ "reason": "replace_existing was requested but "
10623
+ "DeleteMarkerByCustomData refused; the existing "
10624
+ "marker is still there and nothing was replaced",
10625
+ })
10626
+ continue
10506
10627
  result = _add_marker(clip, marker)
10507
10628
  if result.get("success"):
10508
10629
  added.append({
@@ -10899,7 +11020,20 @@ def _apply_media_analysis_clip_markers(clip, markers: List[Dict[str, Any]], p: D
10899
11020
  skipped.append({"frame": marker.get("frame"), "name": marker.get("name"), "reason": "Marker already exists", "custom_data": custom_data})
10900
11021
  continue
10901
11022
  if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"):
10902
- clip.DeleteMarkerByCustomData(custom_data)
11023
+ # A discarded False leaves the old marker in place, and AddMarker
11024
+ # then refuses the occupied frame -- reported as "add failed"
11025
+ # when the real failure was the delete.
11026
+ if not clip.DeleteMarkerByCustomData(custom_data):
11027
+ failed.append({
11028
+ "marker": marker,
11029
+ "result": {
11030
+ "success": False,
11031
+ "reason": "replace_existing was requested but "
11032
+ "DeleteMarkerByCustomData refused; the existing "
11033
+ "marker is still there and nothing was replaced",
11034
+ },
11035
+ })
11036
+ continue
10903
11037
  result = _add_marker(clip, marker)
10904
11038
  if result.get("success"):
10905
11039
  added.append({"frame": result.get("frame"), "name": marker.get("name"), "custom_data": custom_data})
@@ -12506,11 +12640,24 @@ def _set_current_folder_temporarily(mp, target_path: Optional[str]):
12506
12640
 
12507
12641
 
12508
12642
  def _restore_current_folder(mp, previous):
12509
- if previous:
12510
- try:
12511
- mp.SetCurrentFolder(previous)
12512
- except Exception:
12513
- pass
12643
+ """Put the Media Pool's current folder back. Logged, never raised.
12644
+
12645
+ Fire-and-forget by design: this runs after the real work, the current
12646
+ folder is UI state that nothing downstream reads, and turning a failed
12647
+ teardown into a failed operation would report a successful import as a
12648
+ failure. Logged rather than swallowed, because a bin left open somewhere
12649
+ unexpected is otherwise unexplained.
12650
+ """
12651
+ if not previous:
12652
+ return
12653
+ try:
12654
+ restored = mp.SetCurrentFolder(previous)
12655
+ except Exception as exc:
12656
+ logger.debug("could not restore the current Media Pool folder: %s", exc)
12657
+ return
12658
+ if not restored:
12659
+ logger.debug("could not restore the current Media Pool folder: "
12660
+ "SetCurrentFolder returned %r", restored)
12514
12661
 
12515
12662
 
12516
12663
  def _ensure_folder_path(mp, path: str):
@@ -13812,11 +13959,7 @@ def _playhead_frame_preview(tl, p: Dict[str, Any]):
13812
13959
  width, height, raw = _box_downscale_rgb(width, height, raw, int(max_width))
13813
13960
  return Image(data=_rgb_to_png_bytes(width, height, raw), format="png")
13814
13961
  finally:
13815
- if original_tc:
13816
- try:
13817
- tl.SetCurrentTimecode(original_tc)
13818
- except Exception:
13819
- pass
13962
+ _restore_playhead(tl, original_tc, what="the thumbnail capture")
13820
13963
 
13821
13964
 
13822
13965
  def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
@@ -13929,7 +14072,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
13929
14072
  if not job:
13930
14073
  return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
13931
14074
  before = set(os.listdir(folder))
13932
- if not proj.StartRendering([job], isInteractiveMode=False):
14075
+ # Positional on purpose: the free-edition bridge proxies method calls
14076
+ # positionally, and a keyword argument dies inside _BoundMethod with
14077
+ # "unexpected keyword argument 'isInteractiveMode'" before reaching
14078
+ # Resolve (reported on 21.0.4.5 free/Windows, PR #165).
14079
+ if not proj.StartRendering([job], False):
13933
14080
  return _err("StartRendering refused the job", code="RENDER_START_FAILED", category="api_error")
13934
14081
  waited = 0.0
13935
14082
  while proj.IsRenderingInProgress() and waited < 120:
@@ -13967,11 +14114,22 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
13967
14114
  except Exception:
13968
14115
  pass
13969
14116
  if original_fc:
14117
+ # A failed restore leaves the Deliver page on the capture's format
14118
+ # and codec, which the user's next render would silently inherit.
14119
+ # This runs in a teardown path, so it is logged rather than raised.
13970
14120
  try:
13971
- proj.SetCurrentRenderFormatAndCodec(
14121
+ restored_fc = proj.SetCurrentRenderFormatAndCodec(
13972
14122
  original_fc.get("format"), original_fc.get("codec"))
13973
- except Exception:
13974
- pass
14123
+ except Exception as exc:
14124
+ restored_fc, restore_exc = False, exc
14125
+ else:
14126
+ restore_exc = None
14127
+ if not restored_fc:
14128
+ logger.warning(
14129
+ "frame capture could not restore the render format/codec to %s/%s: %s",
14130
+ original_fc.get("format"), original_fc.get("codec"),
14131
+ restore_exc or "SetCurrentRenderFormatAndCodec returned False",
14132
+ )
13975
14133
  # Best-effort, not a restore: without GetRenderSettings there is nothing
13976
14134
  # to restore FROM, so put the mark range back to the whole timeline
13977
14135
  # rather than leaving it pinned to the captured frame.
@@ -13995,11 +14153,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
13995
14153
  os.rmdir(folder)
13996
14154
  except OSError:
13997
14155
  pass
13998
- if original_tc:
13999
- try:
14000
- tl.SetCurrentTimecode(original_tc)
14001
- except Exception:
14002
- pass
14156
+ _restore_playhead(tl, original_tc, what="the render capture")
14003
14157
  if original_page and original_page != "deliver":
14004
14158
  try:
14005
14159
  _open_page_serialized(resolve, original_page)
@@ -14110,11 +14264,7 @@ def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
14110
14264
  album.DeleteStills([still])
14111
14265
  except Exception:
14112
14266
  pass
14113
- if original_tc:
14114
- try:
14115
- tl.SetCurrentTimecode(original_tc)
14116
- except Exception:
14117
- pass
14267
+ _restore_playhead(tl, original_tc, what="the still capture")
14118
14268
  try:
14119
14269
  for name in os.listdir(folder):
14120
14270
  if name.startswith(prefix):
@@ -14188,10 +14338,71 @@ def _playhead_frame_capture(p: Dict[str, Any]):
14188
14338
  return _playhead_frame_render(proj, tl, p)
14189
14339
  finally:
14190
14340
  if original_tl is not None:
14191
- try:
14192
- proj.SetCurrentTimeline(original_tl)
14193
- except Exception:
14194
- pass
14341
+ # Best-effort by necessity: this runs in a finally, so raising or
14342
+ # returning here would replace the caller's real result (or its real
14343
+ # exception) with a restore failure. It is still not silent -- a
14344
+ # failed restore leaves the editor on a different timeline, which is
14345
+ # visible immediately, and it is logged.
14346
+ restore_err = _set_current_timeline_checked(
14347
+ proj, original_tl, what="restoring the timeline after the capture")
14348
+ if restore_err:
14349
+ logger.warning(
14350
+ "frame capture could not restore the current timeline: %s",
14351
+ restore_err["error"]["message"],
14352
+ )
14353
+
14354
+
14355
+ def _restore_playhead(tl, timecode, *, what: str) -> None:
14356
+ """Put the playhead back after a capture. Logged, never raised.
14357
+
14358
+ Deliberately fire-and-forget on the CALLER's behalf: every use of this runs
14359
+ in a `finally`, so raising or returning an error would replace the caller's
14360
+ real result -- or its real exception -- with a teardown failure. What it is
14361
+ not is silent. A refused restore leaves the editor's playhead somewhere
14362
+ else, and a support log that says so is the difference between a two-minute
14363
+ diagnosis and an afternoon.
14364
+ """
14365
+ if not timecode:
14366
+ return
14367
+ try:
14368
+ moved = tl.SetCurrentTimecode(timecode)
14369
+ except Exception as exc:
14370
+ logger.warning("could not restore the playhead to %s after %s: %s",
14371
+ timecode, what, exc)
14372
+ return
14373
+ if not moved:
14374
+ logger.warning("could not restore the playhead to %s after %s: "
14375
+ "SetCurrentTimecode returned %r", timecode, what, moved)
14376
+
14377
+
14378
+ def _set_current_timeline_checked(proj, tl, *, what: str):
14379
+ """Switch the project's current timeline and PROVE it switched.
14380
+
14381
+ Returns None on success, or an error envelope naming what did not happen.
14382
+
14383
+ This is not UI polish. `AppendToTimeline`, `GetCurrentTimeline` and every
14384
+ other "current timeline" call writes to whatever the PROJECT believes is
14385
+ current, not to the handle in scope. A discarded False here sends the
14386
+ assembly to the timeline the editor was looking at while the tool reports
14387
+ the new timeline's name and id, which is the worst possible shape of
14388
+ failure: the work happened, on the wrong timeline, under a success.
14389
+
14390
+ The bare bool is not trusted on its own. A Resolve attached to no database
14391
+ answers version queries normally while every project mutation returns False
14392
+ or None, and a True that did not take has been observed there, so the
14393
+ switch is confirmed by reading the current timeline back.
14394
+ """
14395
+ ok, detail = _resolve_writes.set_current_timeline(proj, tl)
14396
+ if ok:
14397
+ return None
14398
+ return _err(
14399
+ _resolve_writes.describe_switch_failure(detail, what),
14400
+ code="TIMELINE_SWITCH_FAILED", category="api_error", retryable=False,
14401
+ reason="A discarded False here writes the work to the wrong timeline.",
14402
+ remediation="Quit and restart Resolve if it came up with no database attached "
14403
+ "(resolve_control runtime_mode reports that), then retry.",
14404
+ state=detail,
14405
+ )
14195
14406
 
14196
14407
 
14197
14408
  def _unknown(action, valid):
@@ -16271,14 +16482,31 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
16271
16482
  for i in range(1, count + 1):
16272
16483
  tl = proj.GetTimelineByIndex(i)
16273
16484
  if tl and tl.GetUniqueId() == state["current_timeline_id"]:
16274
- proj.SetCurrentTimeline(tl)
16485
+ # The one function whose whole job is putting things back
16486
+ # must not claim it did. restored[] is written only on a
16487
+ # verified switch; a failure is reported, not swallowed.
16488
+ switch_err = _set_current_timeline_checked(
16489
+ proj, tl, what="restoring the saved state")
16490
+ if switch_err:
16491
+ restored["current_timeline_error"] = switch_err["error"]["message"]
16492
+ break
16275
16493
  restored["current_timeline_id"] = state["current_timeline_id"]
16276
16494
  if state.get("current_timecode"):
16495
+ # restored[] is a claim about what was put back. Writing
16496
+ # it before checking made restore_state report a playhead
16497
+ # position it had not reached.
16277
16498
  try:
16278
- tl.SetCurrentTimecode(state["current_timecode"])
16499
+ moved = bool(tl.SetCurrentTimecode(state["current_timecode"]))
16500
+ except Exception as exc:
16501
+ moved, restored["current_timecode_error"] = False, str(exc)
16502
+ if moved:
16279
16503
  restored["current_timecode"] = state["current_timecode"]
16280
- except Exception:
16281
- pass
16504
+ else:
16505
+ restored.setdefault(
16506
+ "current_timecode_error",
16507
+ f"SetCurrentTimecode({state['current_timecode']!r}) "
16508
+ "returned False; the playhead is elsewhere",
16509
+ )
16282
16510
  break
16283
16511
  except Exception as exc:
16284
16512
  restored["timeline_error"] = str(exc)
@@ -16292,9 +16520,20 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
16292
16520
  for cid in state["selected_clip_ids"]:
16293
16521
  found, parent = _find_clip_with_parent(root, cid)
16294
16522
  if found and parent is not None:
16295
- mp.SetCurrentFolder(parent)
16296
- mp.SetSelectedClip(found)
16297
- restored["selected_clip_id"] = cid
16523
+ # Both report with a bare bool. Claiming the selection
16524
+ # was restored when SetSelectedClip refused is the same
16525
+ # lie as the timeline and timecode above.
16526
+ folder_ok = bool(mp.SetCurrentFolder(parent))
16527
+ selected_ok = bool(mp.SetSelectedClip(found))
16528
+ if selected_ok:
16529
+ restored["selected_clip_id"] = cid
16530
+ else:
16531
+ restored["selection_error"] = (
16532
+ f"SetSelectedClip({cid!r}) returned False"
16533
+ + ("" if folder_ok else
16534
+ " (SetCurrentFolder also refused, so the bin was "
16535
+ "never opened)")
16536
+ )
16298
16537
  break # SetSelectedClip is singular; pick the first
16299
16538
  except Exception as exc:
16300
16539
  restored["selection_error"] = str(exc)
@@ -17155,10 +17394,16 @@ class _SpecLiveExecutor:
17155
17394
  if tl is None:
17156
17395
  return False
17157
17396
  if fps is not None:
17397
+ # A refused rate is the silent wrong-fps timeline: everything built
17398
+ # on it is laid at a rate nobody asked for and the durations read
17399
+ # back wrong. Resolve only accepts this on an EMPTY project, so the
17400
+ # refusal is common and must not be reported as success.
17158
17401
  try:
17159
- tl.SetSetting("timelineFrameRate", str(fps))
17402
+ rate_set = bool(tl.SetSetting("timelineFrameRate", str(fps)))
17160
17403
  except Exception:
17161
- pass
17404
+ rate_set = False
17405
+ if not rate_set:
17406
+ return False
17162
17407
  return True
17163
17408
 
17164
17409
  def set_timeline_setting(self, tl_name: str, key: str, value: Any) -> bool:
@@ -18580,11 +18825,16 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18580
18825
  delete_all_jobs() -> {success}
18581
18826
  list_jobs() -> {jobs}
18582
18827
  get_job_status(job_id) -> {status}
18583
- verify_output(job_id) -> {status, output_path, output_exists, output_size_bytes, output_probe, expected_duration_seconds, duration_ratio, warnings, verified}
18828
+ verify_output(job_id, expected_frames?, expected_duration_seconds?) -> {status, output_path, output_exists, output_size_bytes, output_probe, mark_range_frames, timeline_item_extent_frames, expected_duration_seconds, duration_ratio, warnings, verified}
18584
18829
  JobStatus reports Complete even when the render produced a near-empty
18585
- stub (content before the timeline start, issue #164) — this checks the
18586
- actual output file against the job's mark range. Verify BEFORE deleting
18587
- the job; deleted jobs carry no TargetDir to check.
18830
+ stub (content before the timeline start, issue #164), and Resolve
18831
+ rewrites the job's own MarkIn/MarkOut down to the collapsed extent, so
18832
+ the job metadata lies too. This checks the actual output file, and
18833
+ cross-checks the job's timeline items (which read back their real
18834
+ positions): items before the timeline start and a mark range far
18835
+ smaller than the item extent both warn. Pass expected_frames or
18836
+ expected_duration_seconds when you know what the render should hold.
18837
+ Verify BEFORE deleting the job; deleted jobs carry no TargetDir.
18588
18838
  start(job_ids?, interactive?) -> {success}
18589
18839
  stop() -> {success}
18590
18840
  is_rendering() -> {rendering}
@@ -18675,6 +18925,71 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18675
18925
  else:
18676
18926
  warnings.append("Job carries no TargetDir/OutputFilename to verify against.")
18677
18927
  result["output_path"] = output_path
18928
+ # The job's own MarkIn/MarkOut cannot be the only truth source: when
18929
+ # timeline content sits before the timeline's start frame (issue #164)
18930
+ # Resolve rewrites the job's mark range down to the collapsed extent,
18931
+ # so the job metadata lies right along with JobStatus (measured live on
18932
+ # Studio 19.1.3.7: 96 frames of content before start -> job range of 1
18933
+ # frame, Complete at 100%, a 1-frame black stub). Cross-check the
18934
+ # job's timeline items, which DO read back their real positions, and
18935
+ # let the caller override expected duration outright.
18936
+ try:
18937
+ fps = float(str(job.get("FrameRate")))
18938
+ except (TypeError, ValueError):
18939
+ fps = None
18940
+ try:
18941
+ mark_frames = int(job.get("MarkOut")) - int(job.get("MarkIn")) + 1
18942
+ except (TypeError, ValueError):
18943
+ mark_frames = None
18944
+ result["mark_range_frames"] = mark_frames
18945
+ tl_for_job = None
18946
+ try:
18947
+ for i in range(1, int(proj.GetTimelineCount() or 0) + 1):
18948
+ cand = proj.GetTimelineByIndex(i)
18949
+ if cand and cand.GetName() == job.get("TimelineName"):
18950
+ tl_for_job = cand
18951
+ break
18952
+ except Exception:
18953
+ tl_for_job = None
18954
+ if tl_for_job is not None:
18955
+ try:
18956
+ tl_start = int(tl_for_job.GetStartFrame())
18957
+ extent_lo, extent_hi, before_start = None, None, 0
18958
+ for ti in range(1, int(tl_for_job.GetTrackCount("video") or 0) + 1):
18959
+ for it in tl_for_job.GetItemListInTrack("video", ti) or []:
18960
+ it_start, it_end = int(it.GetStart()), int(it.GetEnd())
18961
+ extent_lo = it_start if extent_lo is None else min(extent_lo, it_start)
18962
+ extent_hi = it_end if extent_hi is None else max(extent_hi, it_end)
18963
+ if it_start < tl_start:
18964
+ before_start += 1
18965
+ if before_start:
18966
+ warnings.append(
18967
+ f"{before_start} video item(s) on timeline "
18968
+ f"{job.get('TimelineName')!r} start before the timeline's "
18969
+ f"start frame {tl_start} — the render engine never visits "
18970
+ "that content (recordFrame counts from absolute frame "
18971
+ "zero, issue #164). The items read back as placed; the "
18972
+ "render is a stub."
18973
+ )
18974
+ if (extent_lo is not None and mark_frames is not None
18975
+ and mark_frames < (extent_hi - extent_lo) * 0.5):
18976
+ warnings.append(
18977
+ f"The job's mark range ({mark_frames} frame(s)) is under "
18978
+ f"half the timeline's item extent "
18979
+ f"({extent_hi - extent_lo} frames) — Resolve rewrote the "
18980
+ "range down to a collapsed extent, which is how a stub "
18981
+ "render reports a clean duration ratio."
18982
+ )
18983
+ result["timeline_item_extent_frames"] = (
18984
+ None if extent_lo is None else extent_hi - extent_lo
18985
+ )
18986
+ except Exception:
18987
+ pass
18988
+ else:
18989
+ warnings.append(
18990
+ f"Timeline {job.get('TimelineName')!r} was not found in the "
18991
+ "project, so item-extent cross-checks were skipped."
18992
+ )
18678
18993
  if output_path:
18679
18994
  exists = os.path.exists(output_path)
18680
18995
  result["output_exists"] = exists
@@ -18684,13 +18999,21 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
18684
18999
  if probe:
18685
19000
  result["output_probe"] = probe
18686
19001
  duration = probe.get("duration_seconds")
18687
- # Expected duration from the job's own mark range and rate.
18688
- try:
18689
- frames = int(job.get("MarkOut")) - int(job.get("MarkIn")) + 1
18690
- fps = float(str(job.get("FrameRate")))
18691
- expected = frames / fps if fps > 0 else None
18692
- except (TypeError, ValueError):
18693
- expected = None
19002
+ # Expected duration: caller-supplied truth wins; the job's
19003
+ # mark range is the fallback (see collapse caveat above).
19004
+ expected = None
19005
+ if p.get("expected_duration_seconds") is not None:
19006
+ try:
19007
+ expected = float(p["expected_duration_seconds"])
19008
+ except (TypeError, ValueError):
19009
+ expected = None
19010
+ elif p.get("expected_frames") is not None and fps:
19011
+ try:
19012
+ expected = int(p["expected_frames"]) / fps
19013
+ except (TypeError, ValueError):
19014
+ expected = None
19015
+ elif mark_frames is not None and fps:
19016
+ expected = mark_frames / fps
18694
19017
  result["expected_duration_seconds"] = expected
18695
19018
  if duration is not None and expected and expected > 0:
18696
19019
  result["duration_ratio"] = round(duration / expected, 4)
@@ -19087,10 +19410,10 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
19087
19410
  tl = mp.CreateEmptyTimeline(create_name)
19088
19411
  if not tl:
19089
19412
  return _err("Failed to create timeline from clip_infos")
19090
- try:
19091
- proj.SetCurrentTimeline(tl)
19092
- except Exception:
19093
- pass
19413
+ switch_err = _set_current_timeline_checked(
19414
+ proj, tl, what="appending the clip_infos")
19415
+ if switch_err:
19416
+ return switch_err
19094
19417
  timeline_start = _timeline_start_frame(tl)
19095
19418
  built = []
19096
19419
  for i, ci in enumerate(raw):
@@ -19703,11 +20026,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19703
20026
  mark_set = {"applied": False, "error": str(exc)}
19704
20027
 
19705
20028
  # Navigate the bin to the clip's folder so the clip is visible to select.
20029
+ # Not fatal on its own (SetSelectedClip may still work), but a discarded
20030
+ # False meant the caller could not tell "the bin did not move" from
20031
+ # "the bin moved and the clip is right there", which is the whole point
20032
+ # of the action.
20033
+ folder_ok: Optional[bool] = None
20034
+ folder_error: Optional[str] = None
19706
20035
  if parent is not None:
19707
20036
  try:
19708
- mp.SetCurrentFolder(parent)
19709
- except Exception:
19710
- pass # not fatal; SetSelectedClip below may still work
20037
+ folder_ok = bool(mp.SetCurrentFolder(parent))
20038
+ except Exception as exc:
20039
+ folder_ok, folder_error = False, str(exc)
20040
+ if not folder_ok:
20041
+ folder_error = folder_error or "SetCurrentFolder returned False"
19711
20042
  select_ok = bool(mp.SetSelectedClip(clip))
19712
20043
 
19713
20044
  # Bring Resolve to the foreground so the editor doesn't have to
@@ -19732,6 +20063,8 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19732
20063
  "clip_id": clip.GetUniqueId(),
19733
20064
  "clip_name": clip.GetName(),
19734
20065
  "folder_name": parent.GetName() if parent else None,
20066
+ "bin_opened": folder_ok,
20067
+ "bin_error": folder_error,
19735
20068
  "page": page,
19736
20069
  "mark_set": mark_set,
19737
20070
  "focus": focus_result,
@@ -22573,10 +22906,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22573
22906
  tl = mp.CreateEmptyTimeline(name)
22574
22907
  if not tl:
22575
22908
  return _err("Failed to create selects timeline")
22576
- try:
22577
- proj.SetCurrentTimeline(tl)
22578
- except Exception:
22579
- pass
22909
+ switch_err = _set_current_timeline_checked(
22910
+ proj, tl, what="assembling the selects")
22911
+ if switch_err:
22912
+ return switch_err
22580
22913
  timeline_start = _timeline_start_frame(tl)
22581
22914
  built = []
22582
22915
  build_errors = []
@@ -22960,10 +23293,14 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22960
23293
  tl, _tl_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
22961
23294
  if not tl:
22962
23295
  return _err(f"Timeline '{plan.get('timeline_name')}' not found")
22963
- try:
22964
- proj.SetCurrentTimeline(tl)
22965
- except Exception:
22966
- pass
23296
+ # The lift is handle-based and hits the right timeline; the append that
23297
+ # puts the replacement back is current-timeline-based. A failed switch
23298
+ # here punches a hole in the target and drops the alternate on whatever
23299
+ # the editor had open, under a reported success.
23300
+ switch_err = _set_current_timeline_checked(
23301
+ proj, tl, what="swapping the item")
23302
+ if switch_err:
23303
+ return switch_err
22967
23304
  before = _edit_engine_capture(tl)
22968
23305
  before["track_counts"] = _edit_engine_track_counts(tl)
22969
23306
  slot_start = int(item_block.get("timeline_start_frame"))
@@ -26513,9 +26850,17 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
26513
26850
  used_format = try_fmt
26514
26851
  break
26515
26852
  time.sleep(0.3)
26516
- # Clean up still from gallery
26853
+ # Clean up still from gallery. Cleanup, not the operation: the export
26854
+ # above is what the caller asked for and it has already been verified by
26855
+ # the file listing below. A leftover still is visible in the Gallery
26856
+ # rather than silent, so this is logged, not raised.
26517
26857
  if delete_after:
26518
- album.DeleteStills([still])
26858
+ try:
26859
+ if not album.DeleteStills([still]):
26860
+ logger.warning("DeleteStills returned False; a still is left in "
26861
+ "the Gallery album after the export")
26862
+ except Exception as exc:
26863
+ logger.warning("DeleteStills raised after the export: %s", exc)
26519
26864
  if not export_ok:
26520
26865
  _discard_still_staging(staging)
26521
26866
  return _err("ExportStills failed — ensure the Gallery panel is open on the Color page (Workspace > Gallery)")
@@ -26668,12 +27013,20 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
26668
27013
  # not the per-user dir that dctl install writes to. Relocate and retry.
26669
27014
  relocated = _ensure_lut_in_master(lut_path)
26670
27015
  if relocated:
27016
+ # The retry below only works if Resolve re-scanned the master
27017
+ # LUT dir. A discarded False here turns the retry into a second
27018
+ # identical failure with no explanation.
27019
+ refreshed = None
26671
27020
  try:
26672
27021
  _, proj, _lut_err = _check()
26673
27022
  if proj:
26674
- proj.RefreshLUTList()
26675
- except Exception:
26676
- pass
27023
+ refreshed = bool(proj.RefreshLUTList())
27024
+ except Exception as exc:
27025
+ logger.warning("RefreshLUTList raised before the SetLUT retry: %s", exc)
27026
+ if refreshed is False:
27027
+ logger.warning(
27028
+ "RefreshLUTList returned False; the relocated LUT may not be "
27029
+ "visible to SetLUT yet")
26677
27030
  ok = bool(g.SetLUT(node_index, relocated))
26678
27031
  if ok:
26679
27032
  return {"success": True, "resolved_lut": relocated,
@@ -26941,6 +27294,19 @@ def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26941
27294
  group.SaveSettings(backup_path)
26942
27295
  except Exception as exc:
26943
27296
  return _err(f"backup SaveSettings failed: {exc}")
27297
+ # SaveSettings reports through the Lua bridge and its return is not
27298
+ # reliable, but the file it was asked to write is. This backup is the only
27299
+ # way back from the LoadSettings below, so loading without it would be an
27300
+ # irreversible replacement of the group's graph presented as a reversible one.
27301
+ if not os.path.isfile(backup_path):
27302
+ return _err(
27303
+ f"backup SaveSettings did not create a file at {backup_path!r}",
27304
+ code="FUSION_BACKUP_NOT_WRITTEN", category="api_error", retryable=False,
27305
+ reason="LoadSettings replaces the group's graph. Without the backup on "
27306
+ "disk there is no way back, so nothing was loaded.",
27307
+ remediation="Check the backup_path directory is writable, or pass an "
27308
+ "explicit backup_path somewhere that is.",
27309
+ )
26944
27310
 
26945
27311
  undo_name = p.get("undo_name", f"MCP group_settings_load {group_name}")
26946
27312
  undo_started = False
@@ -28086,7 +28452,31 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
28086
28452
  except Exception:
28087
28453
  _already_animated = False
28088
28454
  if not _already_animated:
28455
+ # AddModifier reports through the Lua bridge, which resolves an
28456
+ # unknown attribute to None rather than raising, so the return is
28457
+ # not reliable evidence on its own. The readback below is: if the
28458
+ # input still has no connected output, the assignment on the next
28459
+ # line would set a STATIC value and _ok() would report a keyframe
28460
+ # that does not exist.
28089
28461
  tool.AddModifier(p["input_name"], p.get("modifier", "BezierSpline"))
28462
+ try:
28463
+ attached = tool[p["input_name"]].GetConnectedOutput() is not None
28464
+ except Exception:
28465
+ attached = False
28466
+ if not attached:
28467
+ return _err(
28468
+ f"Could not animate '{p['input_name']}' on "
28469
+ f"'{p['tool_name']}': no spline modifier attached.",
28470
+ code="FUSION_ADD_MODIFIER_FAILED", category="api_error",
28471
+ retryable=False,
28472
+ reason="Without a spline, assigning at a time sets a STATIC "
28473
+ "value (last write wins) and creates no keyframe. "
28474
+ "Reporting success here would claim a keyframe that "
28475
+ "does not exist.",
28476
+ remediation="Check the input accepts the modifier type "
28477
+ f"({p.get('modifier', 'BezierSpline')!r}); Point "
28478
+ "inputs take 'Path'.",
28479
+ )
28090
28480
  tool[p["input_name"]][p["time"]] = p["value"]
28091
28481
  return _ok()
28092
28482
  finally:
@@ -29060,10 +29450,24 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
29060
29450
  )
29061
29451
 
29062
29452
  # Clear prior slots so we can detect if RunScript silently did nothing.
29063
- fusion.SetData("__mcp_done__", "")
29064
- fusion.SetData("__mcp_stdout__", "")
29065
- fusion.SetData("__mcp_result__", "")
29066
- fusion.SetData("__mcp_error__", "")
29453
+ # SetData goes through the Lua bridge and returns nil whether or not it
29454
+ # took, so the return is not evidence -- but GetData is. The __mcp_done__
29455
+ # slot is the one that matters: a stale "1" left by the previous run makes
29456
+ # the poll below exit immediately and return the PREVIOUS run's stdout,
29457
+ # result and error as this run's.
29458
+ for slot in ("__mcp_done__", "__mcp_stdout__", "__mcp_result__", "__mcp_error__"):
29459
+ fusion.SetData(slot, "")
29460
+ stale = fusion.GetData("__mcp_done__")
29461
+ if stale not in ("", None):
29462
+ return _err(
29463
+ "Could not clear the Fusion completion sentinel before running.",
29464
+ code="FUSION_SENTINEL_NOT_CLEARED", category="api_error", retryable=True,
29465
+ reason=f"__mcp_done__ still reads {stale!r} after SetData. The poll would "
29466
+ "exit immediately and hand back the previous run's output as this "
29467
+ "run's.",
29468
+ remediation="Retry; if it persists, restart Resolve to clear the Fusion "
29469
+ "app's data slots.",
29470
+ )
29067
29471
 
29068
29472
  with tempfile.NamedTemporaryFile(mode='w', suffix='.lua',
29069
29473
  prefix='mcp-lua-inline-',