davinci-resolve-mcp 2.61.1 → 2.62.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/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.61.1"
14
+ VERSION = "2.62.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -85,7 +85,7 @@ from src.utils.media_analysis import (
85
85
  summarize_reports as summarize_media_analysis_reports,
86
86
  )
87
87
  from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events
88
- from src.utils import actor_identity, resolve_busy
88
+ from src.utils import actor_identity, background_jobs, resolve_busy
89
89
  from src.utils.resolve_busy import long_resolve_op
90
90
  from src.utils.media_analysis_jobs import (
91
91
  MEDIA_EXTENSIONS,
@@ -744,6 +744,13 @@ sys.path.insert(0, RESOLVE_MODULES_PATH)
744
744
  resolve = None
745
745
  dvr_script = None
746
746
  _resolve_lock = threading.RLock()
747
+ # Serializes synchronous tool bodies once they run off the event loop (see
748
+ # _install_threaded_tool_dispatch): the Resolve scripting bridge executes one
749
+ # call at a time, so two sync tool bodies must never enter it concurrently. No
750
+ # body re-acquires it, so a plain Lock (not RLock) states the invariant. The
751
+ # async media_analysis tool is not wrapped and does not take this lock; it is
752
+ # assumed not to run concurrently with another tool (true for a serial client).
753
+ _bridge_lock = threading.Lock()
747
754
 
748
755
  # On Windows the fusionscript native bridge DLL must be locatable before the
749
756
  # Python import machinery attempts to load it. Setting PYTHONHOME, prepending
@@ -1214,6 +1221,26 @@ def _ok(**kw):
1214
1221
  return {"success": True, **kw}
1215
1222
 
1216
1223
 
1224
+ def _run_maybe_background(label: str, params: Dict[str, Any], fn):
1225
+ """Run a long Resolve operation synchronously, or off-thread when requested.
1226
+
1227
+ When params carries a truthy `background` (alias `async_job`), start a polled
1228
+ job and return its id at once; otherwise run fn inside long_resolve_op and
1229
+ return its result, preserving the synchronous contract.
1230
+ """
1231
+ if params.get("background") or params.get("async_job"):
1232
+ job_id = background_jobs.start_job(label, fn)
1233
+ return {
1234
+ "success": True,
1235
+ "job_id": job_id,
1236
+ "status": "running",
1237
+ "label": label,
1238
+ "note": "poll with resolve_control(action='job_status', params={'job_id': ...})",
1239
+ }
1240
+ with long_resolve_op(label):
1241
+ return fn()
1242
+
1243
+
1217
1244
  def _record_action_outcome(scope_key: Optional[str], action_name: str,
1218
1245
  response: Dict[str, Any]) -> Dict[str, Any]:
1219
1246
  """E2 — record the (scope, action) outcome with the failure tracker and
@@ -2418,11 +2445,13 @@ def _build_append_clip_info_dict(
2418
2445
  ci: Dict[str, Any],
2419
2446
  index: int,
2420
2447
  timeline_start_frame: Optional[int] = None,
2448
+ pack: bool = False,
2421
2449
  ):
2422
2450
  """Build one MediaPool.AppendToTimeline clipInfo map (Python API uses camelCase keys).
2423
2451
 
2424
2452
  See docs/reference/resolve_scripting_api.txt: mediaPoolItem, startFrame, endFrame,
2425
- optional mediaType, trackIndex, recordFrame.
2453
+ optional mediaType, trackIndex, recordFrame. With pack=True, recordFrame is omitted
2454
+ so Resolve appends each clip contiguously at the end of its track.
2426
2455
  """
2427
2456
  if not isinstance(ci, dict):
2428
2457
  return None, _err(f"clip_infos[{index}] must be an object")
@@ -2439,14 +2468,15 @@ def _build_append_clip_info_dict(
2439
2468
  f"clip_infos[{index}] requires start_frame/startFrame and end_frame/endFrame "
2440
2469
  "(source range on the MediaPoolItem)"
2441
2470
  )
2442
- rf = ci.get("recordFrame", ci.get("record_frame"))
2443
- if rf is None:
2444
- return None, _err(
2445
- f"clip_infos[{index}] requires record_frame/recordFrame (timeline record frame)"
2446
- )
2447
- rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame)
2448
- if rf_err:
2449
- return None, rf_err
2471
+ if not pack:
2472
+ rf = ci.get("recordFrame", ci.get("record_frame"))
2473
+ if rf is None:
2474
+ return None, _err(
2475
+ f"clip_infos[{index}] requires record_frame/recordFrame (timeline record frame)"
2476
+ )
2477
+ rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame)
2478
+ if rf_err:
2479
+ return None, rf_err
2450
2480
  ti = ci.get("trackIndex", ci.get("track_index"))
2451
2481
  if ti is None:
2452
2482
  return None, _err(
@@ -2456,9 +2486,10 @@ def _build_append_clip_info_dict(
2456
2486
  "mediaPoolItem": mp_item,
2457
2487
  "startFrame": sf,
2458
2488
  "endFrame": ef,
2459
- "recordFrame": rf,
2460
2489
  "trackIndex": ti,
2461
2490
  }
2491
+ if not pack:
2492
+ out["recordFrame"] = rf
2462
2493
  mt = ci.get("mediaType", ci.get("media_type"))
2463
2494
  if mt is not None:
2464
2495
  out["mediaType"] = mt
@@ -3472,6 +3503,27 @@ def _timeline_media_type(track_type: str):
3472
3503
  return None
3473
3504
 
3474
3505
 
3506
+ def _track_selector(p: Dict[str, Any]):
3507
+ """(track_type, track_index, err) from a params dict.
3508
+
3509
+ Accepts the 1-based track index as either ``index`` or ``track_index``. err
3510
+ is a validation message (None on success) so a missing or malformed selector
3511
+ returns a structured error.
3512
+ """
3513
+ normalized = dict(p)
3514
+ if normalized.get("index") is None and normalized.get("track_index") is not None:
3515
+ normalized["index"] = normalized["track_index"]
3516
+ err, clean = _validate_params(normalized, {
3517
+ "track_type": {"type": str, "required": True, "enum": ["video", "audio", "subtitle"]},
3518
+ "index": {"type": int, "required": True, "min": 1},
3519
+ })
3520
+ if err:
3521
+ if err.startswith("'index' is required"):
3522
+ err = "requires a 1-based track index ('index' or 'track_index')"
3523
+ return None, None, err
3524
+ return clean["track_type"], clean["index"], None
3525
+
3526
+
3475
3527
  def _timeline_track_count(tl, track_type: str):
3476
3528
  try:
3477
3529
  return int(tl.GetTrackCount(track_type) or 0)
@@ -4025,6 +4077,20 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
4025
4077
  return out
4026
4078
 
4027
4079
 
4080
+ def _apply_cuts_skip_reason(cut):
4081
+ """Why a CutList entry is not applicable by apply_cuts, or None if it is."""
4082
+ if not isinstance(cut, dict):
4083
+ return "not an object"
4084
+ if cut.get("action") not in ("lift", "ripple_delete"):
4085
+ return f"action {cut.get('action')!r} is not lift or ripple_delete"
4086
+ span = cut.get("span")
4087
+ if not isinstance(span, dict):
4088
+ return "missing span"
4089
+ if span.get("start") is None or span.get("end") is None:
4090
+ return "span missing start/end"
4091
+ return None
4092
+
4093
+
4028
4094
  def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
4029
4095
  start, end, items, err = _collect_timeline_items_in_range(tl, p)
4030
4096
  if err:
@@ -4837,6 +4903,43 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
4837
4903
  return out
4838
4904
 
4839
4905
 
4906
+ def _variant_item_placement(item) -> Dict[str, Any]:
4907
+ """Report an appended item's placed frame positions in both frame spaces.
4908
+ record_* are TIMELINE frames (GetStart/GetEnd/GetDuration); source_start is
4909
+ a SOURCE frame."""
4910
+ def _read(method):
4911
+ fn = getattr(item, method, None)
4912
+ if not callable(fn):
4913
+ return None
4914
+ try:
4915
+ return _frame_int(fn())
4916
+ except Exception:
4917
+ return None
4918
+ record_start = _read("GetStart")
4919
+ record_end = _read("GetEnd")
4920
+ duration = _read("GetDuration")
4921
+ if duration is None and record_start is not None and record_end is not None:
4922
+ duration = record_end - record_start
4923
+ return {
4924
+ "record_start": record_start,
4925
+ "record_end": record_end,
4926
+ "duration": duration,
4927
+ "source_start": _timeline_item_source_start(item),
4928
+ }
4929
+
4930
+
4931
+ def _variant_audio_summary(built):
4932
+ """Video/audio range counts for an assembled variant, warning when it carries
4933
+ no audio. create_variant_from_ranges places exactly the ranges given, so a
4934
+ video-only range list yields a silent timeline."""
4935
+ video = sum(1 for row in built if row.get("media_type") == 1)
4936
+ audio = sum(1 for row in built if row.get("media_type") == 2)
4937
+ summary = {"video_ranges": video, "audio_ranges": audio}
4938
+ if audio == 0:
4939
+ summary["warning"] = "video-only (no audio): add ranges with track_type='audio' to carry sound"
4940
+ return summary
4941
+
4942
+
4840
4943
  def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) -> Dict[str, Any]:
4841
4944
  ranges = p.get("ranges") or p.get("clip_infos")
4842
4945
  if not isinstance(ranges, list) or not ranges:
@@ -4854,6 +4957,10 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4854
4957
  start_frame = int(source_tl.GetStartFrame())
4855
4958
  except Exception:
4856
4959
  start_frame = 0
4960
+ # pack=True butts clips together at the end of each track (record_frame is
4961
+ # ignored); Resolve packs by actual placed duration, so the result is gap-free
4962
+ # even when source and timeline frame rates differ.
4963
+ pack = bool(p.get("pack", False))
4857
4964
  built = []
4858
4965
  cursor_by_track: Dict[Tuple[int, int], int] = {}
4859
4966
  max_tracks = {"video": 1, "audio": 1}
@@ -4877,9 +4984,12 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4877
4984
  return _err(f"ranges[{index}] requires valid start_frame/end_frame")
4878
4985
  record_frame = _frame_int(row.get("record_frame", row.get("recordFrame")))
4879
4986
  key = (int(media_type), track_index)
4880
- if record_frame is None:
4881
- record_frame = cursor_by_track.get(key, start_frame)
4882
- cursor_by_track[key] = record_frame + (end - start)
4987
+ if pack:
4988
+ record_frame = None # Resolve packs at the end of the track
4989
+ else:
4990
+ if record_frame is None:
4991
+ record_frame = cursor_by_track.get(key, start_frame)
4992
+ cursor_by_track[key] = record_frame + (end - start)
4883
4993
  built.append({
4884
4994
  "clip_id": row.get("clip_id") or row.get("media_pool_item_id"),
4885
4995
  "start_frame": start,
@@ -4890,6 +5000,14 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4890
5000
  "_source_row": row,
4891
5001
  "_index": index,
4892
5002
  })
5003
+ # Resolve every clip (media-pool id + frame range) before choosing dry-run
5004
+ # vs commit, so a dry run fails on the same id/frame errors as the commit.
5005
+ append_infos = []
5006
+ for row in built:
5007
+ clip_info, clip_err = _build_append_clip_info_dict(root, row, row["_index"], pack=pack)
5008
+ if clip_err:
5009
+ return clip_err
5010
+ append_infos.append(clip_info)
4893
5011
  if p.get("dry_run", False):
4894
5012
  return {
4895
5013
  "success": True,
@@ -4900,6 +5018,7 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4900
5018
  for row in built
4901
5019
  ],
4902
5020
  "markers": p.get("markers") or [],
5021
+ "audio": _variant_audio_summary(built),
4903
5022
  "would_create_timeline": True,
4904
5023
  }
4905
5024
  new_tl = mp.CreateEmptyTimeline(name)
@@ -4915,21 +5034,23 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4915
5034
  while int(new_tl.GetTrackCount(track_type) or 0) < needed:
4916
5035
  if not new_tl.AddTrack(track_type):
4917
5036
  break
4918
- append_infos = []
4919
- for row in built:
4920
- clip_info, clip_err = _build_append_clip_info_dict(root, row, row["_index"])
4921
- if clip_err:
4922
- return clip_err
4923
- append_infos.append(clip_info)
4924
5037
  appended = mp.AppendToTimeline(append_infos)
4925
5038
  if not appended:
4926
5039
  return _err("AppendToTimeline returned no items for variant")
4927
5040
  items_out = []
5041
+ placement_mismatches = 0
4928
5042
  for index, item in enumerate(appended):
4929
5043
  item_out, item_err = _serialize_appended_timeline_item(item, index, allow_empty_timeline_item_id=True)
4930
5044
  if item_err:
4931
5045
  return item_err
4932
- item_out["range"] = {key: value for key, value in built[index].items() if not key.startswith("_")}
5046
+ requested = {key: value for key, value in built[index].items() if not key.startswith("_")}
5047
+ item_out["range"] = requested # the requested range (still carries media_type/track_index)
5048
+ placed = _variant_item_placement(item) # the frame positions Resolve placed it at
5049
+ item_out["placed"] = placed
5050
+ requested_duration = requested["end_frame"] - requested["start_frame"]
5051
+ if placed["duration"] is not None and placed["duration"] != requested_duration:
5052
+ item_out["duration_delta"] = placed["duration"] - requested_duration
5053
+ placement_mismatches += 1
4933
5054
  transform = built[index]["_source_row"].get("transform")
4934
5055
  if isinstance(transform, dict):
4935
5056
  item_out["transform"] = {}
@@ -4958,6 +5079,8 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
4958
5079
  "name": new_tl.GetName(),
4959
5080
  "id": new_tl.GetUniqueId(),
4960
5081
  "items": items_out,
5082
+ "placement_mismatches": placement_mismatches,
5083
+ "audio": _variant_audio_summary(built),
4961
5084
  "markers": marker_results,
4962
5085
  "look": look_result,
4963
5086
  "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(_timeline_conform_snapshot(new_tl, {}), {}),
@@ -5313,38 +5436,41 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
5313
5436
  spec = _timeline_export_spec(p, get_resolve())
5314
5437
  if p.get("dry_run"):
5315
5438
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
5316
- with long_resolve_op("timeline.export_timeline_checked"):
5439
+
5440
+ def _work():
5317
5441
  success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
5318
- files = []
5319
- primary_file = path
5320
- if success and os.path.isdir(path):
5321
- for dirpath, _, filenames in os.walk(path):
5322
- for filename in filenames:
5323
- file_path = os.path.join(dirpath, filename)
5324
- files.append({"path": file_path, "size": os.path.getsize(file_path)})
5325
- preferred_exts = (".fcpxml", ".xml", ".edl", ".drt", ".aaf", ".otio")
5326
- for ext in preferred_exts:
5327
- match = next((row["path"] for row in files if row["path"].lower().endswith(ext)), None)
5328
- if match:
5329
- primary_file = match
5330
- break
5331
- size = 0
5332
- if success and os.path.exists(path):
5333
- if os.path.isdir(path):
5334
- size = sum(row["size"] for row in files)
5335
- else:
5336
- size = os.path.getsize(path)
5337
- return {
5338
- "success": success,
5339
- "path": path,
5340
- "primary_file": primary_file,
5341
- "is_directory": bool(success and os.path.isdir(path)),
5342
- "files": files,
5343
- "size": size,
5344
- "format": spec["requested"],
5345
- "export_type": spec["export_type_name"],
5346
- "export_subtype": spec["export_subtype_name"],
5347
- }
5442
+ files = []
5443
+ primary_file = path
5444
+ if success and os.path.isdir(path):
5445
+ for dirpath, _, filenames in os.walk(path):
5446
+ for filename in filenames:
5447
+ file_path = os.path.join(dirpath, filename)
5448
+ files.append({"path": file_path, "size": os.path.getsize(file_path)})
5449
+ preferred_exts = (".fcpxml", ".xml", ".edl", ".drt", ".aaf", ".otio")
5450
+ for ext in preferred_exts:
5451
+ match = next((row["path"] for row in files if row["path"].lower().endswith(ext)), None)
5452
+ if match:
5453
+ primary_file = match
5454
+ break
5455
+ size = 0
5456
+ if success and os.path.exists(path):
5457
+ if os.path.isdir(path):
5458
+ size = sum(row["size"] for row in files)
5459
+ else:
5460
+ size = os.path.getsize(path)
5461
+ return {
5462
+ "success": success,
5463
+ "path": path,
5464
+ "primary_file": primary_file,
5465
+ "is_directory": bool(success and os.path.isdir(path)),
5466
+ "files": files,
5467
+ "size": size,
5468
+ "format": spec["requested"],
5469
+ "export_type": spec["export_type_name"],
5470
+ "export_subtype": spec["export_subtype_name"],
5471
+ }
5472
+
5473
+ return _run_maybe_background("timeline.export_timeline_checked", p, _work)
5348
5474
 
5349
5475
 
5350
5476
  def _timeline_media_coverage(tl) -> Dict[str, Any]:
@@ -5533,74 +5659,76 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5533
5659
  out["note"] = binary_relink_note
5534
5660
  return out
5535
5661
 
5536
- before_ids = set()
5537
- for index in range(1, int(proj.GetTimelineCount() or 0) + 1):
5538
- tl_existing = proj.GetTimelineByIndex(index)
5539
- if tl_existing:
5540
- before_ids.add(str(tl_existing.GetUniqueId()))
5541
- with long_resolve_op("timeline.import_timeline_checked"):
5662
+ def _work():
5663
+ before_ids = set()
5664
+ for index in range(1, int(proj.GetTimelineCount() or 0) + 1):
5665
+ tl_existing = proj.GetTimelineByIndex(index)
5666
+ if tl_existing:
5667
+ before_ids.add(str(tl_existing.GetUniqueId()))
5542
5668
  imported = mp.ImportTimelineFromFile(import_path, options)
5543
5669
 
5544
- # Resolve's API returns None even when it created an (offline) timeline. Detect
5545
- # the newly-created timeline via the before/after id diff so we never report a
5546
- # false "Failed to import" for what is really a partial success.
5547
- api_returned_object = bool(imported)
5548
- if not imported:
5549
- for index in range(1, int(proj.GetTimelineCount() or 0) + 1):
5550
- t = proj.GetTimelineByIndex(index)
5551
- if t and str(t.GetUniqueId()) not in before_ids:
5552
- imported = t
5553
- if not imported:
5554
- if is_binary:
5555
- remediation = (
5556
- f"Resolve created no timeline from this {ext}. Verify it exports/opens in "
5557
- "Resolve directly, or convert to FCP7 XML / FCPXML upstream and import that."
5558
- )
5559
- elif sanitize:
5560
- remediation = None
5561
- else:
5562
- remediation = (
5563
- "This XML may reference missing media or contain generator clips, which "
5564
- "abort the scripting-API import. Retry with sanitize_media=True."
5670
+ # Resolve's API returns None even when it created an (offline) timeline. Detect
5671
+ # the newly-created timeline via the before/after id diff so we never report a
5672
+ # false "Failed to import" for what is really a partial success.
5673
+ api_returned_object = bool(imported)
5674
+ if not imported:
5675
+ for index in range(1, int(proj.GetTimelineCount() or 0) + 1):
5676
+ t = proj.GetTimelineByIndex(index)
5677
+ if t and str(t.GetUniqueId()) not in before_ids:
5678
+ imported = t
5679
+ if not imported:
5680
+ if is_binary:
5681
+ remediation = (
5682
+ f"Resolve created no timeline from this {ext}. Verify it exports/opens in "
5683
+ "Resolve directly, or convert to FCP7 XML / FCPXML upstream and import that."
5684
+ )
5685
+ elif sanitize:
5686
+ remediation = None
5687
+ else:
5688
+ remediation = (
5689
+ "This XML may reference missing media or contain generator clips, which "
5690
+ "abort the scripting-API import. Retry with sanitize_media=True."
5691
+ )
5692
+ return _err(
5693
+ "Failed to import timeline (Resolve created no timeline)",
5694
+ remediation=remediation,
5565
5695
  )
5566
- return _err(
5567
- "Failed to import timeline (Resolve created no timeline)",
5568
- remediation=remediation,
5696
+
5697
+ imported_id = str(imported.GetUniqueId())
5698
+ media = _timeline_media_coverage(imported)
5699
+ # AAF fuzzy-relink parity: when search roots are given for a binary import, relink through
5700
+ # the media pool after import (the XML path-rewrite relink can't run on a binary file).
5701
+ relink_result = None
5702
+ if is_binary and search_roots:
5703
+ relink_result = _binary_post_import_relink(imported, mp, search_roots)
5704
+ if relink_result.get("after"):
5705
+ media = relink_result["after"]
5706
+ out = _ok(
5707
+ name=imported.GetName(),
5708
+ id=imported_id,
5709
+ created_new=imported_id not in before_ids,
5710
+ timeline_count=proj.GetTimelineCount(),
5711
+ api_returned_object=api_returned_object,
5712
+ media=media,
5569
5713
  )
5714
+ if sanitize_report is not None:
5715
+ out["sanitize"] = sanitize_report
5716
+ if relink_result is not None:
5717
+ out["relink"] = relink_result
5718
+ if binary_relink_note:
5719
+ out["note"] = binary_relink_note
5720
+ if media["total"] and media["offline"]:
5721
+ msg = f"{media['offline']} of {media['total']} timeline items are offline (no linked media)."
5722
+ if is_binary:
5723
+ msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
5724
+ "at the media roots — AAF media links there, not by rewriting the file.")
5725
+ elif not sanitize:
5726
+ msg += (" Retry with sanitize_media=True to drop missing-media/generator "
5727
+ "clips so the remaining media links automatically.")
5728
+ out["warning"] = msg
5729
+ return out
5570
5730
 
5571
- imported_id = str(imported.GetUniqueId())
5572
- media = _timeline_media_coverage(imported)
5573
- # AAF fuzzy-relink parity: when search roots are given for a binary import, relink through
5574
- # the media pool after import (the XML path-rewrite relink can't run on a binary file).
5575
- relink_result = None
5576
- if is_binary and search_roots:
5577
- relink_result = _binary_post_import_relink(imported, mp, search_roots)
5578
- if relink_result.get("after"):
5579
- media = relink_result["after"]
5580
- out = _ok(
5581
- name=imported.GetName(),
5582
- id=imported_id,
5583
- created_new=imported_id not in before_ids,
5584
- timeline_count=proj.GetTimelineCount(),
5585
- api_returned_object=api_returned_object,
5586
- media=media,
5587
- )
5588
- if sanitize_report is not None:
5589
- out["sanitize"] = sanitize_report
5590
- if relink_result is not None:
5591
- out["relink"] = relink_result
5592
- if binary_relink_note:
5593
- out["note"] = binary_relink_note
5594
- if media["total"] and media["offline"]:
5595
- msg = f"{media['offline']} of {media['total']} timeline items are offline (no linked media)."
5596
- if is_binary:
5597
- msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
5598
- "at the media roots — AAF media links there, not by rewriting the file.")
5599
- elif not sanitize:
5600
- msg += (" Retry with sanitize_media=True to drop missing-media/generator "
5601
- "clips so the remaining media links automatically.")
5602
- out["warning"] = msg
5603
- return out
5731
+ return _run_maybe_background("timeline.import_timeline_checked", p, _work)
5604
5732
 
5605
5733
 
5606
5734
  # A .drp/.drt is a zip of SeqContainer*.xml entries. Two on-disk naming conventions,
@@ -5703,11 +5831,13 @@ def _import_from_drp(proj, mp, p: Dict[str, Any]):
5703
5831
  else:
5704
5832
  selected = list(containers)
5705
5833
 
5706
- # Per-timeline options passthrough (importSourceClips etc.) minus selection keys.
5834
+ # Per-timeline options passthrough (importSourceClips etc.) minus selection
5835
+ # keys; background is a top-level option, so each sub-step import runs
5836
+ # synchronously and the loop can verify it.
5707
5837
  base = {
5708
5838
  k: v
5709
5839
  for k, v in p.items()
5710
- if k not in ("drpPath", "drp_path", "path", "timelineNames", "timeline_names", "timelineIndexes", "timeline_indexes", "dry_run")
5840
+ if k not in ("drpPath", "drp_path", "path", "timelineNames", "timeline_names", "timelineIndexes", "timeline_indexes", "dry_run", "background", "async_job")
5711
5841
  }
5712
5842
 
5713
5843
  tmp_dir = tempfile.mkdtemp(prefix="drp_import_")
@@ -5786,6 +5916,15 @@ def _find_timeline_by_name(proj, name: Any):
5786
5916
  return None, None
5787
5917
 
5788
5918
 
5919
+ def _find_timeline_by_id(proj, timeline_id: Any):
5920
+ want = str(timeline_id or "")
5921
+ for index in range(1, int(proj.GetTimelineCount() or 0) + 1):
5922
+ tl = proj.GetTimelineByIndex(index)
5923
+ if tl and str(tl.GetUniqueId()) == want:
5924
+ return tl, index
5925
+ return None, None
5926
+
5927
+
5789
5928
  def _unique_timeline_name(proj, requested_name: Any) -> str:
5790
5929
  base = str(requested_name or "Untitled Timeline").strip() or "Untitled Timeline"
5791
5930
  existing_names = set()
@@ -5913,7 +6052,8 @@ def _probe_interchange_roundtrip(proj, mp, tl, p: Dict[str, Any]):
5913
6052
  spec = _timeline_export_spec(p, resolve)
5914
6053
  base_name = p.get("name") or f"roundtrip_{str(spec['requested']).lower()}"
5915
6054
  path = p.get("path") or os.path.join(output_dir, base_name + spec["extension"])
5916
- export_result = _export_timeline_checked(tl, {**p, "path": path, "require_temp_path": p.get("require_temp_path", True)})
6055
+ # background is a top-level option; a sub-step export must run synchronously.
6056
+ export_result = _export_timeline_checked(tl, {**p, "path": path, "require_temp_path": p.get("require_temp_path", True), "background": False, "async_job": False})
5917
6057
  if export_result.get("error") or not export_result.get("success"):
5918
6058
  return {"success": False, "stage": "export", "export": export_result}
5919
6059
  import_path = export_result.get("primary_file") or export_result.get("path") or path
@@ -6907,8 +7047,9 @@ def _subtitle_generation_probe(tl, p: Dict[str, Any]):
6907
7047
  note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
6908
7048
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
6909
7049
  return _err("CreateSubtitlesFromAudio unavailable")
6910
- with long_resolve_op("timeline.create_subtitles_from_audio"):
6911
- return _safe_create_subtitles(tl, p)
7050
+ return _run_maybe_background(
7051
+ "timeline.create_subtitles_from_audio", p, lambda: _safe_create_subtitles(tl, p)
7052
+ )
6912
7053
 
6913
7054
 
6914
7055
  def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
@@ -12667,6 +12808,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12667
12808
  facts about quirky/unreliable Resolve API behavior (no connection needed).
12668
12809
  verification_stats() -> {stats} — readback-verification tally
12669
12810
  (verified/contradicted/unverified) since server start (no connection needed).
12811
+ job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
12812
+ — poll a background job started by a long op run with background=True
12813
+ (no connection needed). status is running, done, or error.
12814
+ list_jobs() -> {jobs} — compact status of every known background job
12815
+ (no connection needed).
12670
12816
  get_page() -> {page}
12671
12817
  open_page(page) -> {success} — page: edit, cut, color, fusion, fairlight, deliver
12672
12818
  get_keyframe_mode() -> {mode}
@@ -12697,6 +12843,18 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12697
12843
  return {"stats": stats, "note": "Counts since server start. A rising "
12698
12844
  "'contradicted' count means the API reported success but a readback disagreed."}
12699
12845
 
12846
+ # Background-job polling is a registry read — no Resolve connection needed.
12847
+ if action == "job_status":
12848
+ job_id = p.get("job_id")
12849
+ if not job_id:
12850
+ return _err("job_status requires job_id", category="invalid_input")
12851
+ status = background_jobs.job_status(job_id)
12852
+ if status is None:
12853
+ return _err(f"Unknown job_id: {job_id}", category="invalid_input")
12854
+ return status
12855
+ if action == "list_jobs":
12856
+ return {"jobs": background_jobs.list_jobs()}
12857
+
12700
12858
  # Control-panel actions don't require Resolve to be running.
12701
12859
  if action == "open_control_panel":
12702
12860
  return _open_control_panel(p)
@@ -12789,7 +12947,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
12789
12947
  return missing
12790
12948
  r.DisableBackgroundTasksForCurrentResolveSession()
12791
12949
  return _ok()
12792
- return _unknown(action, ["launch","get_version","api_truth","verification_stats","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
12950
+ return _unknown(action, ["launch","get_version","api_truth","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
12793
12951
 
12794
12952
 
12795
12953
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -15651,7 +15809,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15651
15809
  setup_multicam_timeline(name, clip_ids|angles, sync_mode?, include_audio?, dry_run?) -> {success}
15652
15810
  — creates a stacked multicam prep timeline: one angle per video track, optional
15653
15811
  matching audio tracks. Native multicam clip conversion remains a Resolve UI step.
15654
- import_timeline(path, options?) -> {success, name}
15812
+ import_timeline(path, options?, background?) -> {success, name | job_id}
15655
15813
  UNSAFE. No path sandboxing. Prefer timeline.import_timeline_checked.
15656
15814
  delete_timelines(timeline_ids) -> {success}
15657
15815
  CATASTROPHIC. Deletes timelines outright.
@@ -15836,14 +15994,17 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15836
15994
  # Whitelist options to the documented keys; Resolve silently ignores
15837
15995
  # unknown keys, so a typo would be dropped with no signal (P1 #4).
15838
15996
  options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_TIMELINE_OPTION_KEYS)
15839
- with long_resolve_op("media_pool.import_timeline"):
15997
+
15998
+ def _work():
15840
15999
  tl = mp.ImportTimelineFromFile(p["path"], options)
15841
- if not tl:
15842
- return _err("Failed to import timeline")
15843
- result = _ok(name=tl.GetName())
15844
- if ignored_opts:
15845
- result["ignored_options"] = ignored_opts
15846
- return result
16000
+ if not tl:
16001
+ return _err("Failed to import timeline")
16002
+ result = _ok(name=tl.GetName())
16003
+ if ignored_opts:
16004
+ result["ignored_options"] = ignored_opts
16005
+ return result
16006
+
16007
+ return _run_maybe_background("media_pool.import_timeline", p, _work)
15847
16008
  elif action == "delete_timelines":
15848
16009
  count = proj.GetTimelineCount()
15849
16010
  timelines = []
@@ -16094,7 +16255,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16094
16255
  is_stale(path?) -> {stale}
16095
16256
  get_unique_id(path?) -> {id}
16096
16257
  export(path?, export_path) -> {success}
16097
- transcribe_audio(path?, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+
16258
+ transcribe_audio(path?, use_speaker_detection?, background?) -> {success | job_id} — use_speaker_detection is Resolve 21+; background=true returns a job_id (poll resolve_control job_status)
16098
16259
  clear_transcription(path?) -> {success}
16099
16260
  perform_audio_classification(path?) -> {success} — Resolve 21+
16100
16261
  clear_audio_classification(path?) -> {success} — Resolve 21+
@@ -16129,10 +16290,12 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16129
16290
  elif action == "transcribe_audio":
16130
16291
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
16131
16292
  if usd is None:
16132
- with long_resolve_op("folder.transcribe_audio"):
16133
- return {"success": bool(f.TranscribeAudio())}
16134
- with long_resolve_op("folder.transcribe_audio"):
16135
- return {"success": bool(f.TranscribeAudio(bool(usd)))}
16293
+ return _run_maybe_background(
16294
+ "folder.transcribe_audio", p, lambda: {"success": bool(f.TranscribeAudio())}
16295
+ )
16296
+ return _run_maybe_background(
16297
+ "folder.transcribe_audio", p, lambda: {"success": bool(f.TranscribeAudio(bool(usd)))}
16298
+ )
16136
16299
  elif action == "clear_transcription":
16137
16300
  return {"success": bool(f.ClearTranscription())}
16138
16301
  elif action == "perform_audio_classification":
@@ -16246,11 +16409,13 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16246
16409
  monitor_growing_file(clip_id) -> {success}
16247
16410
  replace_clip_preserve_sub_clip(clip_id, path) -> {success}
16248
16411
  get_unique_id(clip_id) -> {id}
16249
- transcribe_audio(clip_id, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+
16412
+ transcribe_audio(clip_id, use_speaker_detection?, background?) -> {success | job_id} — use_speaker_detection is Resolve 21+; background=true returns a job_id (poll resolve_control job_status)
16250
16413
  clear_transcription(clip_id) -> {success}
16251
16414
  get_transcription(clip_id) -> {text, truncated, status, has_transcription}
16252
16415
  Read a clip's transcription. `truncated` flags when Resolve's preview
16253
- property cut the text off (the full transcript is longer).
16416
+ property cut the text off (the full transcript is longer). Clip-level and
16417
+ separate from the timeline subtitle transcript (timeline.get_transcript)
16418
+ that propose_cuts uses.
16254
16419
  extract_frames(clip_id, timestamps, output_dir?) -> {frame_paths, output_dir, count, errors}
16255
16420
  Extract still JPEGs from the clip's source at the given timestamps (seconds)
16256
16421
  via ffmpeg. Source-safe: reads source, writes only to a scratch dir.
@@ -16466,10 +16631,12 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16466
16631
  elif action == "transcribe_audio":
16467
16632
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
16468
16633
  if usd is None:
16469
- with long_resolve_op("media_pool_item.transcribe_audio"):
16470
- return {"success": bool(clip.TranscribeAudio())}
16471
- with long_resolve_op("media_pool_item.transcribe_audio"):
16472
- return {"success": bool(clip.TranscribeAudio(bool(usd)))}
16634
+ return _run_maybe_background(
16635
+ "media_pool_item.transcribe_audio", p, lambda: {"success": bool(clip.TranscribeAudio())}
16636
+ )
16637
+ return _run_maybe_background(
16638
+ "media_pool_item.transcribe_audio", p, lambda: {"success": bool(clip.TranscribeAudio(bool(usd)))}
16639
+ )
16473
16640
  elif action == "clear_transcription":
16474
16641
  return {"success": bool(clip.ClearTranscription())}
16475
16642
  elif action == "get_transcription":
@@ -18582,6 +18749,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18582
18749
  "audio_accounting": {
18583
18750
  "planned_audio_ranges": audio_keep_ranges,
18584
18751
  "planned_video_ranges": video_keep_ranges,
18752
+ # variant_* count placed items; variant["audio"] counts requested ranges.
18585
18753
  "variant_audio_items": sum(
18586
18754
  1 for it in (variant.get("items") or [])
18587
18755
  if (it.get("range") or {}).get("media_type") == 2
@@ -18768,6 +18936,28 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18768
18936
  ])
18769
18937
 
18770
18938
 
18939
+ _TIMELINE_ACTIONS = [
18940
+ "list", "get_current", "set_current", "get_name", "set_name", "get_start_frame",
18941
+ "get_end_frame", "get_start_timecode", "set_start_timecode", "get_track_count",
18942
+ "add_track", "delete_track", "get_track_sub_type", "set_track_enable",
18943
+ "get_track_enabled", "set_track_lock", "get_track_locked", "get_track_name",
18944
+ "set_track_name", "get_items", "delete_clips", "set_clips_linked", "duplicate",
18945
+ "duplicate_clips", "copy_clips", "move_clips", "copy_range", "duplicate_range",
18946
+ "overwrite_range", "lift_range", "story_spine_report", "create_variant_from_ranges",
18947
+ "bulk_set_item_properties", "apply_look_to_items", "thumbnail_contact_sheet",
18948
+ "marker_thumbnail_review", "edit_kernel_capabilities", "probe_edit_kernel_item",
18949
+ "title_property_scan", "set_title_text", "bulk_set_title_text", "create_compound_clip",
18950
+ "create_fusion_clip", "import_into_timeline", "export", "get_setting", "set_setting",
18951
+ "insert_generator", "insert_fusion_generator", "insert_fusion_composition",
18952
+ "insert_ofx_generator", "insert_title", "insert_fusion_title", "get_unique_id",
18953
+ "get_node_graph", "get_media_pool_item", "get_transcript", "propose_cuts", "apply_cuts",
18954
+ "get_mark_in_out", "set_mark_in_out", "clear_mark_in_out", "convert_to_stereo",
18955
+ "get_items_in_track", "get_voice_isolation_state", "set_voice_isolation_state",
18956
+ "extract_source_frame_ranges", "audio_mix_capability_report", "clip_where", "action_help",
18957
+ *_TIMELINE_CONFORM_KERNEL_ACTIONS, *_TIMELINE_AUDIO_KERNEL_ACTIONS,
18958
+ ]
18959
+
18960
+
18771
18961
  @mcp.tool()
18772
18962
  @_destructive_op("timeline")
18773
18963
  def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -18776,12 +18966,19 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18776
18966
  PREFER probe_* / *_capabilities / *_boundary_report / *_checked / dry_run variants
18777
18967
  where they exist. Raw mutators are kept for advanced callers but bypass guardrails.
18778
18968
  DESTRUCTIVE actions auto-archive the current timeline (C6); pass strict=true for
18779
- refuse-on-archive-failure behavior on the catastrophic ops.
18969
+ refuse-on-archive-failure behavior on the catastrophic ops. To thread several
18970
+ destructive calls into ONE archived predecessor instead of N, wrap them in
18971
+ timeline_versioning begin_run/end_run.
18972
+
18973
+ Frame numbers are TIMELINE/record frames (position on the timeline) unless an action
18974
+ says SOURCE. Source frames are positions within a media-pool clip's own media:
18975
+ create_variant_from_ranges takes SOURCE start_frame/end_frame; extract_source_frame_ranges
18976
+ and source_range_report return SOURCE ranges.
18780
18977
 
18781
18978
  Actions:
18782
18979
  list() -> {timelines}
18783
18980
  get_current() -> {name, id, start_frame, end_frame, start_timecode}
18784
- set_current(index) -> {success} — 1-based index
18981
+ set_current(index|id|name) -> {success} — id/name are stable across archives; index is 1-based
18785
18982
  get_name() -> {name}
18786
18983
  set_name(name) -> {success}
18787
18984
  get_start_frame() -> {frame}
@@ -18802,7 +18999,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18802
18999
  get_track_locked(track_type, index) -> {locked}
18803
19000
  get_track_name(track_type, index) -> {name}
18804
19001
  set_track_name(track_type, index, name) -> {success}
18805
- get_items(track_type, index) -> {items}
19002
+ get_items(track_type, index|track_index) -> {items} — track_type: video|audio|subtitle
18806
19003
  clip_where(filters|track_type?, track_index?, name_contains?, duration_lt?, duration_gt?) -> {clips, match_count, total_clips}
18807
19004
  Find clips on the current timeline matching named filters (AND). Pass filters
18808
19005
  inline or as a `filters` dict. Live filters: track_type, track_index,
@@ -18826,7 +19023,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18826
19023
  copy_range/duplicate_range(start_frame, end_frame, record_frame, ...) -> {results, count}
18827
19024
  overwrite_range(start_frame, end_frame, record_frame, ...) -> {results, count}
18828
19025
  lift_range(start_frame, end_frame, allow_partial_item_delete?, ripple?) -> {success, deleted}
18829
- DESTRUCTIVE. Removes items in a record-frame range. ripple=True closes the gap.
19026
+ DESTRUCTIVE. Deletes timeline items whose record-frame range overlaps [start,end].
19027
+ ripple=True closes the gap left by those deleted items (shifts later clips left). It
19028
+ does NOT close a pre-existing empty gap: if no item overlaps the range, deleted=0 and
19029
+ nothing moves. (frames here are TIMELINE/record frames.)
18830
19030
  story_spine_report() -> {beats, track_summaries, source_ranges, audio_spine}
18831
19031
  create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?) -> {success, id, items}
18832
19032
  # example: action_help(name='<action_name>')
@@ -18849,7 +19049,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18849
19049
  create_fusion_clip(clip_ids) -> {success}
18850
19050
  import_into_timeline(path, options?) -> {success}
18851
19051
  UNSAFE. No path sandboxing. Prefer import_timeline_checked.
18852
- export(path, type, subtype?) -> {success} — type: AAF, EDL, FCPXML, etc.
19052
+ export(path, type, subtype?, background?) -> {success | job_id} — type: AAF, EDL, FCPXML, etc.
18853
19053
  UNSAFE. No path sandboxing. Prefer export_timeline_checked.
18854
19054
  get_setting(name?) -> {settings}
18855
19055
  set_setting(name, value) -> {success}
@@ -18863,11 +19063,16 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18863
19063
  get_node_graph() -> {available}
18864
19064
  get_media_pool_item() -> {name, id}
18865
19065
  get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues}
18866
- Read the timeline's subtitle track(s) as transcript text.
19066
+ Read the timeline's subtitle track(s) as transcript text. This is the
19067
+ TIMELINE subtitle transcript that propose_cuts consumes; it is separate
19068
+ from clip-level transcription (produced by transcribe_audio on a
19069
+ folder/clip, read via media_pool_item get_transcription), which does not
19070
+ drive timeline cuts.
18867
19071
  propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note}
18868
19072
  DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses,
18869
19073
  repeated lines) from the subtitle transcript. Proposes only; applies nothing.
18870
- apply_cuts(cuts, dry_run?, confirm_token?, allow_partial_item_delete?) -> {applied, total, results}
19074
+ apply_cuts(cuts, dry_run?, confirm_token?, allow_partial_item_delete?) -> {applied, total, skipped, results}
19075
+ skipped lists cuts that were not applicable, each with a reason.
18871
19076
  Apply a CutList (from propose_cuts) as lift/ripple deletes. DRY-RUN by
18872
19077
  default; applying is DESTRUCTIVE — confirm-token gated and a timeline
18873
19078
  version is archived first. Cuts apply latest-first.
@@ -18875,7 +19080,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18875
19080
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
18876
19081
  clear_mark_in_out(type?) -> {success}
18877
19082
  convert_to_stereo() -> {success}
18878
- get_items_in_track(track_type, track_index) -> {items}
19083
+ get_items_in_track(track_type, track_index|index) -> {items} — full serialization of each item
18879
19084
  get_voice_isolation_state(track_index) -> {isEnabled, amount}
18880
19085
  set_voice_isolation_state(track_index, state) -> {success}
18881
19086
  extract_source_frame_ranges(handles?, gap_max?, skip_extensions?) -> {timeline_name, frame_ranges, occurrences, ...}
@@ -18887,8 +19092,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18887
19092
  probe_timeline_structure(track_types?, include_markers?, include_clip_properties?) -> {tracks, markers}
18888
19093
  detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
18889
19094
  source_range_report(handles?, merge?) -> {ranges, occurrences}
18890
- export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?) -> {success, path, size}
18891
- import_timeline_checked(path, options?, timeline_name?, import_source_clips?, sanitize_media?, require_temp_path?, dry_run?) -> {success, name, id, media, sanitize?, warning?}
19095
+ export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
19096
+ import_timeline_checked(path, options?, timeline_name?, import_source_clips?, sanitize_media?, require_temp_path?, dry_run?, background?) -> {success, name, id, media, sanitize?, warning? | job_id}
18892
19097
  Imports an FCP7 xmeml / FCPXML / AAF timeline. AAF (.aaf) is read natively by
18893
19098
  Resolve — the XML sanitize pass is SKIPPED for it (media relinks via the media
18894
19099
  pool, not by rewriting the file); a `note` says so. For AAF, passing
@@ -18957,6 +19162,16 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18957
19162
  timelines.append({"name": tl.GetName(), "id": tl.GetUniqueId(), "index": i})
18958
19163
  return {"timelines": timelines}
18959
19164
  elif action == "set_current":
19165
+ # id/name take precedence over index.
19166
+ selector_id = p.get("id") or p.get("timeline_id")
19167
+ selector_name = p.get("name")
19168
+ if selector_id or selector_name:
19169
+ tl = _find_timeline_by_id(proj, selector_id)[0] if selector_id else None
19170
+ if tl is None and selector_name:
19171
+ tl = _find_timeline_by_name(proj, selector_name)[0]
19172
+ if tl is None:
19173
+ return _err(f"No timeline matching {selector_id or selector_name!r}")
19174
+ return {"success": bool(proj.SetCurrentTimeline(tl))}
18960
19175
  err, clean = _validate_params(p, {"index": {"type": int, "required": True, "min": 1}})
18961
19176
  if err:
18962
19177
  return _err(err)
@@ -19071,7 +19286,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19071
19286
  elif action == "set_track_name":
19072
19287
  return {"success": bool(tl.SetTrackName(p["track_type"], p["index"], p["name"]))}
19073
19288
  elif action == "get_items":
19074
- items = tl.GetItemListInTrack(p["track_type"], p["index"])
19289
+ track_type, track_index, err = _track_selector(p)
19290
+ if err:
19291
+ return _err(err)
19292
+ items = tl.GetItemListInTrack(track_type, track_index)
19075
19293
  return {"items": [{"name": it.GetName(), "id": it.GetUniqueId(), "start": it.GetStart(), "end": it.GetEnd(), "duration": it.GetDuration()} for it in (items or [])]}
19076
19294
  elif action == "delete_clips":
19077
19295
  # Find timeline items by unique IDs
@@ -19181,13 +19399,16 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19181
19399
  # friendly type/subtype names (or EXPORT_* constant names) the same way
19182
19400
  # export_timeline_checked does. This raw action keeps no path sandbox.
19183
19401
  spec = _timeline_export_spec(p, get_resolve())
19184
- with long_resolve_op("timeline.export"):
19402
+
19403
+ def _work():
19185
19404
  success = bool(tl.Export(p["path"], spec["export_type"], spec["export_subtype"]))
19186
- return {
19187
- "success": success,
19188
- "export_type": spec["export_type_name"],
19189
- "export_subtype": spec["export_subtype_name"],
19190
- }
19405
+ return {
19406
+ "success": success,
19407
+ "export_type": spec["export_type_name"],
19408
+ "export_subtype": spec["export_subtype_name"],
19409
+ }
19410
+
19411
+ return _run_maybe_background("timeline.export", p, _work)
19191
19412
  elif action == "get_setting":
19192
19413
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
19193
19414
  elif action == "set_setting":
@@ -19235,12 +19456,14 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19235
19456
  cuts = p.get("cuts")
19236
19457
  if not isinstance(cuts, list):
19237
19458
  return _err("apply_cuts requires 'cuts' (a list, e.g. from propose_cuts)")
19238
- applicable = [
19239
- c for c in cuts
19240
- if isinstance(c, dict) and c.get("action") in ("lift", "ripple_delete")
19241
- and isinstance(c.get("span"), dict)
19242
- and c["span"].get("start") is not None and c["span"].get("end") is not None
19243
- ]
19459
+ applicable = []
19460
+ skipped = []
19461
+ for index, c in enumerate(cuts):
19462
+ reason = _apply_cuts_skip_reason(c)
19463
+ if reason:
19464
+ skipped.append({"index": index, "cut": c, "reason": reason})
19465
+ else:
19466
+ applicable.append(c)
19244
19467
  applicable.sort(key=lambda c: c["span"]["start"], reverse=True)
19245
19468
  plan = [{"action": c["action"], "span": c["span"], "kind": c.get("kind")} for c in applicable]
19246
19469
 
@@ -19249,6 +19472,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19249
19472
  "dry_run": True,
19250
19473
  "would_apply": len(applicable),
19251
19474
  "plan": plan,
19475
+ "skipped": skipped,
19252
19476
  "note": "No edits made. Re-run with dry_run=false (and a confirm_token) to apply.",
19253
19477
  }
19254
19478
 
@@ -19262,6 +19486,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19262
19486
  "archived first.",
19263
19487
  "would_apply": len(applicable),
19264
19488
  "plan": plan,
19489
+ "skipped": skipped,
19265
19490
  },
19266
19491
  )
19267
19492
  blocked = _consume_confirm_token(action="timeline.apply_cuts", params=p)
@@ -19281,7 +19506,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19281
19506
  results.append({"action": c["action"], "span": sp, "result": res})
19282
19507
  applied = sum(1 for r in results
19283
19508
  if isinstance(r["result"], dict) and r["result"].get("success"))
19284
- return {"success": True, "applied": applied, "total": len(applicable), "results": results}
19509
+ return {"success": True, "applied": applied, "total": len(applicable),
19510
+ "skipped": skipped, "results": results}
19285
19511
  elif action == "get_mark_in_out":
19286
19512
  return _ser(tl.GetMarkInOut())
19287
19513
  elif action == "set_mark_in_out":
@@ -19299,7 +19525,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19299
19525
  elif action == "convert_to_stereo":
19300
19526
  return {"success": bool(tl.ConvertTimelineToStereo())}
19301
19527
  elif action == "get_items_in_track":
19302
- return {"items": _ser(tl.GetItemListInTrack(p["track_type"], p["track_index"]))}
19528
+ track_type, track_index, err = _track_selector(p)
19529
+ if err:
19530
+ return _err(err)
19531
+ return {"items": _ser(tl.GetItemListInTrack(track_type, track_index))}
19303
19532
  elif action == "get_voice_isolation_state":
19304
19533
  missing = _requires_method(tl, "GetVoiceIsolationState", "20.1")
19305
19534
  if missing:
@@ -19495,7 +19724,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
19495
19724
  if mp_err:
19496
19725
  return mp_err
19497
19726
  return _fairlight_boundary_report(proj, mp, tl, p)
19498
- return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report","clip_where","action_help",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
19727
+ return _unknown(action, _TIMELINE_ACTIONS)
19499
19728
 
19500
19729
 
19501
19730
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -19628,9 +19857,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19628
19857
  """AI and analysis operations on the current timeline.
19629
19858
 
19630
19859
  Actions:
19631
- create_subtitles(settings?) -> {success} — auto-caption from audio
19632
- detect_scene_cuts() -> {success}
19633
- analyze_dolby_vision(clip_ids?, analysis_type?) -> {success}
19860
+ create_subtitles(settings?, background?) -> {success | job_id} — auto-caption from audio.
19861
+ These are long ops: background=true returns a job_id immediately; poll
19862
+ resolve_control(action="job_status", params={"job_id": ...}).
19863
+ detect_scene_cuts(background?) -> {success | job_id}
19864
+ analyze_dolby_vision(clip_ids?, analysis_type?, background?) -> {success | job_id}
19634
19865
  grab_still() -> {success}
19635
19866
  grab_all_stills(source?) -> {count}
19636
19867
  """
@@ -19640,11 +19871,13 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19640
19871
  return err
19641
19872
 
19642
19873
  if action == "create_subtitles":
19643
- with long_resolve_op("timeline_ai.create_subtitles"):
19644
- return _safe_create_subtitles(tl, p)
19874
+ return _run_maybe_background(
19875
+ "timeline_ai.create_subtitles", p, lambda: _safe_create_subtitles(tl, p)
19876
+ )
19645
19877
  elif action == "detect_scene_cuts":
19646
- with long_resolve_op("timeline_ai.detect_scene_cuts"):
19647
- return {"success": bool(tl.DetectSceneCuts())}
19878
+ return _run_maybe_background(
19879
+ "timeline_ai.detect_scene_cuts", p, lambda: {"success": bool(tl.DetectSceneCuts())}
19880
+ )
19648
19881
  elif action == "analyze_dolby_vision":
19649
19882
  clip_ids = p.get("clip_ids", [])
19650
19883
  items = []
@@ -19655,8 +19888,10 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19655
19888
  if it.GetUniqueId() in clip_ids:
19656
19889
  items.append(it)
19657
19890
  analysis_type = p.get("analysis_type")
19658
- with long_resolve_op("timeline_ai.analyze_dolby_vision"):
19659
- return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
19891
+ return _run_maybe_background(
19892
+ "timeline_ai.analyze_dolby_vision", p,
19893
+ lambda: {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))},
19894
+ )
19660
19895
  elif action == "grab_still":
19661
19896
  still = tl.GrabStill()
19662
19897
  return _ok() if still else _err("Failed to grab still")
@@ -20628,6 +20863,18 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
20628
20863
  },
20629
20864
  },
20630
20865
  "timeline": {
20866
+ "get_items": {
20867
+ "summary": "List items on one track as a summary (name/id/start/end/duration).",
20868
+ "params": "track_type (video|audio|subtitle), index|track_index (1-based)",
20869
+ "returns": "{items: [{name, id, start, end, duration}]}",
20870
+ "example": 'timeline(action="get_items", params={"track_type": "video", "index": 1})',
20871
+ },
20872
+ "get_items_in_track": {
20873
+ "summary": "List items on one track with full per-item serialization.",
20874
+ "params": "track_type (video|audio|subtitle), track_index|index (1-based)",
20875
+ "returns": "{items}",
20876
+ "example": 'timeline(action="get_items_in_track", params={"track_type": "audio", "track_index": 1})',
20877
+ },
20631
20878
  "duplicate_clips": {
20632
20879
  "summary": "Re-place the same MediaPool media with the same source trim. Video clips only.",
20633
20880
  "params": "clip_ids?|selected?, target_track_index?|track_offset?, placement? (same_time|offset|at_playhead|track_above|after_source|next_gap), record_frame?, record_frame_offset?, copy_properties?, include_linked?",
@@ -20643,17 +20890,22 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
20643
20890
  ),
20644
20891
  },
20645
20892
  "create_variant_from_ranges": {
20646
- "summary": "Build a variant timeline from N source ranges. Source-safe; supports dry_run.",
20647
- "params": "name, ranges: [{source_clip_id|clip_id, start_frame, end_frame, record_frame?, track_type?}], markers?, cdl?, dry_run?",
20648
- "returns": "{success, id, items}",
20893
+ "summary": "Build a variant timeline from N source ranges. Video-only unless ranges include track_type='audio'. Source-safe; dry_run validates clip ids and frame ranges.",
20894
+ "params": (
20895
+ "name, ranges: [{clip_id|media_pool_item_id, start_frame, end_frame, "
20896
+ "record_frame?, track_type?}], pack?, markers?, cdl?, dry_run? — clip_id is a "
20897
+ "media-pool item id (not a timeline-item id); start_frame/end_frame are SOURCE "
20898
+ "frames, end_frame exclusive (source duration = end_frame - start_frame). "
20899
+ "pack=true butts clips together at the end of each track (gap-free, ignores record_frame)"
20900
+ ),
20901
+ "returns": "{success, id, items} — items[].placed = placed frames; items[].range = the requested range",
20649
20902
  "example": (
20650
20903
  'timeline(action="create_variant_from_ranges", params={\n'
20651
20904
  ' "name": "v02_tighter_act1",\n'
20652
20905
  ' "ranges": [\n'
20653
- ' {"source_clip_id": "TimelineItem-abc", "start_frame": 108000, "end_frame": 108120},\n'
20654
- ' {"source_clip_id": "TimelineItem-def", "start_frame": 108300, "end_frame": 108400}\n'
20906
+ ' {"clip_id": "<media-pool-item-id>", "start_frame": 1200, "end_frame": 1320},\n'
20907
+ ' {"clip_id": "<media-pool-item-id>", "start_frame": 1500, "end_frame": 1600}\n'
20655
20908
  ' ],\n'
20656
- ' "markers": [{"frame": 0, "color": "Blue", "name": "Act1 turn"}],\n'
20657
20909
  ' "dry_run": True\n'
20658
20910
  '})'
20659
20911
  ),
@@ -21074,22 +21326,46 @@ def _propose_grade(proj, p: Dict[str, Any]) -> Dict[str, Any]:
21074
21326
  return payload
21075
21327
 
21076
21328
 
21329
+ # Full valid-action list per tool, for tools that expose one. Lets action_help
21330
+ # report every action (not only the documented subset) and tell an unknown
21331
+ # action apart from a valid-but-undocumented one.
21332
+ _TOOL_ACTIONS: Dict[str, List[str]] = {
21333
+ "timeline": _TIMELINE_ACTIONS,
21334
+ }
21335
+
21336
+
21077
21337
  def _action_help(tool_name: str, p: Dict[str, Any]) -> Dict[str, Any]:
21078
21338
  """Return long-form guidance + example for a named action on tool_name."""
21079
21339
  name = (p or {}).get("name") or (p or {}).get("action_name")
21080
21340
  catalog = _ACTION_HELP.get(tool_name, {})
21341
+ valid = _TOOL_ACTIONS.get(tool_name)
21081
21342
  if not name:
21082
- return _ok(tool=tool_name, available=sorted(catalog.keys()),
21083
- note="Call action_help with params.name to retrieve guidance for a specific action.")
21343
+ payload = {
21344
+ "tool": tool_name,
21345
+ "available": sorted(catalog.keys()),
21346
+ "note": "Call action_help with params.name to retrieve guidance for a specific action.",
21347
+ }
21348
+ if valid is not None:
21349
+ payload["actions"] = sorted(valid)
21350
+ payload["note"] = ("'available' lists actions with detailed help; 'actions' lists all "
21351
+ "valid actions. Call action_help with params.name for one action.")
21352
+ return _ok(**payload)
21084
21353
  entry = catalog.get(name)
21085
- if not entry:
21354
+ if entry:
21355
+ return _ok(tool=tool_name, action=name, **entry)
21356
+ if valid is not None and name not in valid:
21086
21357
  return _err(
21087
- f"No help registered for {tool_name}.{name}.",
21088
- code="HELP_NOT_REGISTERED",
21358
+ f"Unknown action {tool_name}.{name}.",
21359
+ code="UNKNOWN_ACTION",
21089
21360
  category="invalid_input",
21090
- remediation=f"Call action_help() with no name for the list of actions with detailed help.",
21361
+ remediation=f"Valid actions: {', '.join(sorted(valid))}",
21091
21362
  )
21092
- return _ok(tool=tool_name, action=name, **entry)
21363
+ return _err(
21364
+ f"No help registered for {tool_name}.{name}.",
21365
+ code="HELP_NOT_REGISTERED",
21366
+ category="invalid_input",
21367
+ remediation="Call action_help() with no name for the list of actions with detailed help.",
21368
+ )
21093
21369
 
21094
21370
 
21095
21371
  def _grade_evidence_line(*, item_name, version_label, num_nodes, has_lut, group_name,
@@ -24975,8 +25251,58 @@ def _resource_install_guidance() -> Dict[str, Any]:
24975
25251
  # Server Startup
24976
25252
  # ═══════════════════════════════════════════════════════════════════════════════
24977
25253
 
25254
+ def _install_threaded_tool_dispatch(fastmcp) -> int:
25255
+ """Run synchronous tool bodies in a worker thread instead of inline on the
25256
+ event loop.
25257
+
25258
+ The MCP SDK invokes a sync tool function directly on the single asyncio
25259
+ event-loop thread, so a blocking Resolve call (or subprocess, or the up-to-
25260
+ 60s launch wait) freezes the whole server — including the stdio read loop —
25261
+ until it returns. Wrapping each sync tool as an async function that offloads
25262
+ to a worker thread keeps the event loop servicing the transport. Bodies are
25263
+ serialized on _bridge_lock so the single-threaded Resolve bridge is never
25264
+ entered concurrently. A body that outlives a client cancellation runs to
25265
+ completion (the bridge is never left half-mutated) still holding the lock.
25266
+
25267
+ Couples to mcp SDK private attrs (ToolManager._tools, Tool.fn /
25268
+ Tool.is_async; verified on mcp 1.27). Best-effort: if that shape changes,
25269
+ leave the tools as-is, falling back to the current inline behavior.
25270
+ """
25271
+ import functools
25272
+ import anyio
25273
+
25274
+ manager = getattr(fastmcp, "_tool_manager", None)
25275
+ tools = getattr(manager, "_tools", None)
25276
+ if not isinstance(tools, dict) or not tools:
25277
+ return 0
25278
+
25279
+ def _offloaded(fn):
25280
+ @functools.wraps(fn)
25281
+ async def run_off_thread(**kwargs):
25282
+ def call():
25283
+ with _bridge_lock:
25284
+ return fn(**kwargs)
25285
+ return await anyio.to_thread.run_sync(call)
25286
+ return run_off_thread
25287
+
25288
+ wrapped = 0
25289
+ for tool in tools.values():
25290
+ if getattr(tool, "is_async", False):
25291
+ continue
25292
+ try:
25293
+ tool.fn = _offloaded(tool.fn)
25294
+ tool.is_async = True
25295
+ except Exception as exc: # unexpected SDK shape — keep the original tool
25296
+ logger.warning(f"threaded tool dispatch skipped for {getattr(tool, 'name', '?')}: {exc}")
25297
+ continue
25298
+ wrapped += 1
25299
+ logger.info(f"Threaded tool dispatch installed for {wrapped} tools")
25300
+ return wrapped
25301
+
25302
+
24978
25303
  if __name__ == "__main__":
24979
25304
  start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
25305
+ _install_threaded_tool_dispatch(mcp)
24980
25306
 
24981
25307
  # Support --full flag to run the 341-tool granular server instead
24982
25308
  if "--full" in sys.argv:
@@ -24984,6 +25310,7 @@ if __name__ == "__main__":
24984
25310
  sys.argv = [arg for arg in sys.argv if arg != "--full"]
24985
25311
  from src.granular import mcp as granular_mcp
24986
25312
 
25313
+ _install_threaded_tool_dispatch(granular_mcp)
24987
25314
  run_fastmcp_stdio(granular_mcp)
24988
25315
  sys.exit(0)
24989
25316