davinci-resolve-mcp 2.61.0 → 2.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +516 -189
- package/src/utils/analysis_store.py +39 -0
- package/src/utils/background_jobs.py +108 -0
- package/src/utils/deep_vision.py +1 -30
- package/src/utils/strata.py +87 -20
- package/src/utils/strata_analyzers.py +135 -91
- package/src/utils/strata_queries.py +43 -23
- package/src/utils/strata_story.py +4 -10
- package/src/utils/timeline_brain_db.py +18 -1
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.62.0"
|
|
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
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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
|
|
4881
|
-
record_frame =
|
|
4882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5439
|
+
|
|
5440
|
+
def _work():
|
|
5317
5441
|
success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
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
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
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
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
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
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6911
|
-
|
|
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
|
-
|
|
15997
|
+
|
|
15998
|
+
def _work():
|
|
15840
15999
|
tl = mp.ImportTimelineFromFile(p["path"], options)
|
|
15841
|
-
|
|
15842
|
-
|
|
15843
|
-
|
|
15844
|
-
|
|
15845
|
-
|
|
15846
|
-
|
|
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
|
-
|
|
16133
|
-
|
|
16134
|
-
|
|
16135
|
-
|
|
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
|
-
|
|
16470
|
-
|
|
16471
|
-
|
|
16472
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
19402
|
+
|
|
19403
|
+
def _work():
|
|
19185
19404
|
success = bool(tl.Export(p["path"], spec["export_type"], spec["export_subtype"]))
|
|
19186
|
-
|
|
19187
|
-
|
|
19188
|
-
|
|
19189
|
-
|
|
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
|
-
|
|
19240
|
-
|
|
19241
|
-
|
|
19242
|
-
|
|
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),
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
19633
|
-
|
|
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
|
-
|
|
19644
|
-
|
|
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
|
-
|
|
19647
|
-
|
|
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
|
-
|
|
19659
|
-
|
|
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;
|
|
20647
|
-
"params":
|
|
20648
|
-
|
|
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
|
-
' {"
|
|
20654
|
-
' {"
|
|
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
|
-
|
|
21083
|
-
|
|
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
|
|
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"
|
|
21088
|
-
code="
|
|
21358
|
+
f"Unknown action {tool_name}.{name}.",
|
|
21359
|
+
code="UNKNOWN_ACTION",
|
|
21089
21360
|
category="invalid_input",
|
|
21090
|
-
remediation=f"
|
|
21361
|
+
remediation=f"Valid actions: {', '.join(sorted(valid))}",
|
|
21091
21362
|
)
|
|
21092
|
-
return
|
|
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
|
|