davinci-resolve-mcp 2.37.2 → 2.38.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 CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.38.0
6
+
7
+ Busy gate for long DaVinci Resolve operations — the first piece of the
8
+ concurrency design for the stdio + networked two-instance setup.
9
+
10
+ - **Added** cross-process registration for long synchronous Resolve calls
11
+ (timeline export/import, scene-cut detection, subtitle generation,
12
+ Dolby Vision analysis, folder/clip audio transcription). A tool call that
13
+ arrives while one is running now waits up to 5 seconds and then returns a
14
+ structured `RESOLVE_BUSY` error (new `busy` category, retryable, with
15
+ `state.busy_with` and `state.age_seconds`) instead of hanging silently
16
+ inside the scripting bridge. Stale registrations from crashed operations
17
+ are ignored automatically; an operation never gates its own thread.
18
+ - Design decisions recorded: single-editor/multi-client is the supported
19
+ concurrency target; confirm tokens stay per-instance; actor identity is
20
+ deferred to the governance phase.
21
+
22
+ ## What's New in v2.37.3
23
+
24
+ Deep-audit fixes, rounds two and three: agent-facing action discovery,
25
+ crash-safe user-state persistence, and resource hygiene.
26
+
27
+ - **Fixed** three tools' unknown-action error lists drifting from their real
28
+ dispatch: `timeline` omitted `clip_where` and `action_help`;
29
+ `timeline_item_color` and `graph` omitted `action_help`. Agents recovering
30
+ from a typo now see the full action set. A static AST test keeps every
31
+ tool's advertised list in both-way sync with its dispatch (and verifies
32
+ docstring-advertised actions are real).
33
+ - **Fixed** user-state files being vulnerable to truncation by a crash
34
+ mid-write. Affected files all reset to `{}` on corruption, so the next
35
+ save would silently wipe remaining user data. Writers now use atomic
36
+ temp-file + `os.replace`: `media-analysis-preferences.json` (all
37
+ analysis/caps/governance/update defaults — including the
38
+ `set_resolution_tier` and `set_caps_preset` paths, now routed through the
39
+ shared writer), `update-check.json`, the dashboard's `analysis.json`
40
+ transcription patch, and `transcript-corrections.json`.
41
+ - **Fixed** `_safe_project_delete` discarding `SaveProject()`'s result when
42
+ closing the current project before deletion; a failed save now surfaces
43
+ as a warning in the response.
44
+ - **Fixed** a file-descriptor leak: the parent kept its copy of the control
45
+ panel's log handle open after spawning the detached child.
46
+ - Audit classes verified clean: bare excepts, mutable default arguments,
47
+ `asyncio.run` inside running loops, subprocess timeouts, docstring phantom
48
+ actions, silent-success swallows in metadata/marker/archive write paths.
49
+
5
50
  ## What's New in v2.37.2
6
51
 
7
52
  Static-audit fixes — four undefined-name references that silently fell back
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.37.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.38.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-33%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.37.2"
38
+ VERSION = "2.38.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.37.2",
3
+ "version": "2.38.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
21
21
  from urllib.parse import parse_qs, unquote, urlparse
22
22
 
23
23
  from src.utils.media_analysis import (
24
+ _write_json as _atomic_write_json,
24
25
  analysis_index_status,
25
26
  analysis_root_coverage,
26
27
  build_analysis_index,
@@ -12605,9 +12606,7 @@ def regenerate_clip_transcript(
12605
12606
  if payload.get("words"):
12606
12607
  updated_report["transcription"]["words"] = payload["words"]
12607
12608
  try:
12608
- with open(artifacts["analysis_json"], "w", encoding="utf-8") as handle:
12609
- json.dump(updated_report, handle, indent=2)
12610
- handle.write("\n")
12609
+ _atomic_write_json(artifacts["analysis_json"], updated_report)
12611
12610
  except Exception as exc:
12612
12611
  return {"success": False, "error": f"transcript written but analysis.json patch failed: {exc}"}
12613
12612
  word_segment_count = sum(1 for seg in (payload.get("segments") or []) if isinstance(seg, dict) and seg.get("words"))
@@ -12779,9 +12778,7 @@ def save_clip_transcript_corrections(project_root: str, clip_id: str, body: Dict
12779
12778
  },
12780
12779
  }
12781
12780
  try:
12782
- with open(path, "w", encoding="utf-8") as handle:
12783
- json.dump(payload, handle, indent=2)
12784
- handle.write("\n")
12781
+ _atomic_write_json(path, payload)
12785
12782
  except Exception as exc:
12786
12783
  return {"success": False, "error": str(exc)}
12787
12784
  return {
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.37.2"
83
+ VERSION = "2.38.0"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
@@ -534,5 +534,5 @@ def quit_resolve() -> Dict[str, Any]:
534
534
  resolve = get_resolve()
535
535
  if resolve is None:
536
536
  return {"error": "Not connected to DaVinci Resolve"}
537
- result = resolve.Quit()
537
+ resolve.Quit()
538
538
  return {"success": True, "message": "DaVinci Resolve is quitting"}
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.37.2"
14
+ VERSION = "2.38.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -84,6 +84,8 @@ from src.utils.media_analysis import (
84
84
  summarize_reports as summarize_media_analysis_reports,
85
85
  )
86
86
  from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events
87
+ from src.utils import resolve_busy
88
+ from src.utils.resolve_busy import long_resolve_op
87
89
  from src.utils.media_analysis_jobs import (
88
90
  MEDIA_EXTENSIONS,
89
91
  batch_job_status as media_analysis_batch_job_status,
@@ -830,6 +832,7 @@ ERROR_CATEGORIES = (
830
832
  "wrong_page", # action requires a specific page (Color, Edit, Fairlight…)
831
833
  "invalid_input", # caller-side fixable (bad param shape, unknown enum)
832
834
  "resolve_api_failed", # Resolve returned None/False for unclear reasons
835
+ "busy", # another long Resolve op holds the bridge; retry later
833
836
  "destructive_blocked", # strict-mode/confirm-token refusal
834
837
  "pending_user_decision", # confirm_token required
835
838
  "unsupported", # feature/version/method not available
@@ -846,6 +849,7 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
846
849
  "wrong_page": True, # trivial caller fix; retry after page switch
847
850
  "invalid_input": False, # caller fix required
848
851
  "resolve_api_failed": True, # often transient; retry once
852
+ "busy": True, # another long Resolve op holds the bridge
849
853
  "destructive_blocked": False, # user decision required
850
854
  "pending_user_decision": False, # confirm_token required
851
855
  "unsupported": False, # API/version mismatch
@@ -1671,6 +1675,19 @@ def _annotation_boundary_report(tl, p: Dict[str, Any]):
1671
1675
  }
1672
1676
 
1673
1677
  def _check():
1678
+ busy = resolve_busy.wait_until_free()
1679
+ if busy:
1680
+ return None, None, _err(
1681
+ f"Resolve is busy with a long operation: {busy['label']} "
1682
+ f"(running for {busy['age_seconds']}s). Retry after it completes.",
1683
+ code="RESOLVE_BUSY", category="busy",
1684
+ remediation="Wait for the named operation to finish, then retry this call.",
1685
+ state={
1686
+ "busy_with": busy["label"],
1687
+ "age_seconds": busy["age_seconds"],
1688
+ "same_process": busy["same_process"],
1689
+ },
1690
+ )
1674
1691
  resolve = get_resolve()
1675
1692
  if resolve is None:
1676
1693
  return None, None, _err(
@@ -4916,7 +4933,8 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
4916
4933
  spec = _timeline_export_spec(p, resolve)
4917
4934
  if p.get("dry_run"):
4918
4935
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
4919
- success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4936
+ with long_resolve_op("timeline.export_timeline_checked"):
4937
+ success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
4920
4938
  files = []
4921
4939
  primary_file = path
4922
4940
  if success and os.path.isdir(path):
@@ -4969,7 +4987,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
4969
4987
  tl_existing = proj.GetTimelineByIndex(index)
4970
4988
  if tl_existing:
4971
4989
  before_ids.add(str(tl_existing.GetUniqueId()))
4972
- imported = mp.ImportTimelineFromFile(path, options)
4990
+ with long_resolve_op("timeline.import_timeline_checked"):
4991
+ imported = mp.ImportTimelineFromFile(path, options)
4973
4992
  if not imported:
4974
4993
  return _err("Failed to import timeline")
4975
4994
  imported_id = str(imported.GetUniqueId())
@@ -5937,7 +5956,8 @@ def _subtitle_generation_probe(tl, p: Dict[str, Any]):
5937
5956
  return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
5938
5957
  if not _has_method(tl, "CreateSubtitlesFromAudio"):
5939
5958
  return _err("CreateSubtitlesFromAudio unavailable")
5940
- return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
5959
+ with long_resolve_op("timeline.create_subtitles_from_audio"):
5960
+ return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
5941
5961
 
5942
5962
 
5943
5963
  def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
@@ -6944,11 +6964,22 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
6944
6964
 
6945
6965
 
6946
6966
  def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
6967
+ # Atomic replace: a crash mid-write must not truncate the file, because the
6968
+ # reader resets to {} on corruption and the next save would then wipe every
6969
+ # remaining user preference.
6947
6970
  path = _media_analysis_preferences_path()
6948
6971
  os.makedirs(os.path.dirname(path), exist_ok=True)
6949
- with open(path, "w", encoding="utf-8") as handle:
6950
- json.dump(preferences, handle, indent=2, sort_keys=True)
6951
- handle.write("\n")
6972
+ tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
6973
+ try:
6974
+ with open(tmp_path, "w", encoding="utf-8") as handle:
6975
+ json.dump(preferences, handle, indent=2, sort_keys=True)
6976
+ handle.write("\n")
6977
+ os.replace(tmp_path, path)
6978
+ finally:
6979
+ try:
6980
+ os.remove(tmp_path)
6981
+ except OSError:
6982
+ pass
6952
6983
 
6953
6984
 
6954
6985
  def _setup_text_key(value: Any) -> str:
@@ -11553,6 +11584,11 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
11553
11584
  )
11554
11585
  except (OSError, FileNotFoundError) as exc:
11555
11586
  return _err(f"Failed to launch control panel: {type(exc).__name__}: {exc}")
11587
+ finally:
11588
+ # The child holds its own copy of the fd; keeping ours open would leak
11589
+ # one descriptor per panel launch.
11590
+ if log_handle is not subprocess.DEVNULL:
11591
+ log_handle.close()
11556
11592
 
11557
11593
  # Verify the child actually came up. Bind errors (port in use), import
11558
11594
  # failures, etc. would otherwise leave us reporting "launched" while the
@@ -12270,11 +12306,14 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
12270
12306
  if current_name == name:
12271
12307
  if not p.get("close_current", False):
12272
12308
  return _err("Refusing to delete the currently open project; pass close_current=True")
12273
- if p.get("save_current", True):
12274
- pm.SaveProject()
12309
+ saved = bool(pm.SaveProject()) if p.get("save_current", True) else None
12275
12310
  closed = bool(pm.CloseProject(current))
12276
12311
  if not closed:
12277
12312
  return _err(f"Failed to close current project '{name}' before delete")
12313
+ result = {"success": bool(pm.DeleteProject(name))}
12314
+ if saved is False:
12315
+ result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
12316
+ return result
12278
12317
  return {"success": bool(pm.DeleteProject(name))}
12279
12318
 
12280
12319
 
@@ -13792,7 +13831,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
13792
13831
  elif action == "setup_multicam_timeline":
13793
13832
  return _setup_multicam_timeline(proj, mp, p)
13794
13833
  elif action == "import_timeline":
13795
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13834
+ with long_resolve_op("media_pool.import_timeline"):
13835
+ tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
13796
13836
  return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
13797
13837
  elif action == "delete_timelines":
13798
13838
  count = proj.GetTimelineCount()
@@ -14002,8 +14042,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14002
14042
  elif action == "transcribe_audio":
14003
14043
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14004
14044
  if usd is None:
14005
- return {"success": bool(f.TranscribeAudio())}
14006
- return {"success": bool(f.TranscribeAudio(bool(usd)))}
14045
+ with long_resolve_op("folder.transcribe_audio"):
14046
+ return {"success": bool(f.TranscribeAudio())}
14047
+ with long_resolve_op("folder.transcribe_audio"):
14048
+ return {"success": bool(f.TranscribeAudio(bool(usd)))}
14007
14049
  elif action == "clear_transcription":
14008
14050
  return {"success": bool(f.ClearTranscription())}
14009
14051
  elif action == "perform_audio_classification":
@@ -14319,8 +14361,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14319
14361
  elif action == "transcribe_audio":
14320
14362
  usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
14321
14363
  if usd is None:
14322
- return {"success": bool(clip.TranscribeAudio())}
14323
- return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14364
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14365
+ return {"success": bool(clip.TranscribeAudio())}
14366
+ with long_resolve_op("media_pool_item.transcribe_audio"):
14367
+ return {"success": bool(clip.TranscribeAudio(bool(usd)))}
14324
14368
  elif action == "clear_transcription":
14325
14369
  return {"success": bool(clip.ClearTranscription())}
14326
14370
  elif action == "get_transcription":
@@ -14759,10 +14803,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14759
14803
  prefs["resolve_ai_governance_preset"] = preset
14760
14804
  if isinstance(p.get("overrides"), dict):
14761
14805
  prefs["resolve_ai_governance_overrides"] = p["overrides"]
14762
- path = _media_analysis_preferences_path()
14763
- os.makedirs(os.path.dirname(path), exist_ok=True)
14764
- with open(path, "w", encoding="utf-8") as fh:
14765
- json.dump(prefs, fh, indent=2)
14806
+ _write_media_analysis_preferences(prefs)
14766
14807
  return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
14767
14808
  if action == "set_caps_preset":
14768
14809
  preset = (p.get("preset") or "").strip().lower()
@@ -14774,10 +14815,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14774
14815
  prefs["analysis_caps_preset"] = preset
14775
14816
  if isinstance(p.get("overrides"), dict):
14776
14817
  prefs["analysis_caps_overrides"] = p["overrides"]
14777
- path = _media_analysis_preferences_path()
14778
- os.makedirs(os.path.dirname(path), exist_ok=True)
14779
- with open(path, "w", encoding="utf-8") as fh:
14780
- json.dump(prefs, fh, indent=2)
14818
+ _write_media_analysis_preferences(prefs)
14781
14819
  return {"success": True, "preset": preset, "overrides": prefs.get("analysis_caps_overrides") or {}}
14782
14820
  if action == "get_usage":
14783
14821
  from src.utils import analysis_caps as _ac
@@ -15795,7 +15833,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15795
15833
  elif action == "import_into_timeline":
15796
15834
  return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
15797
15835
  elif action == "export":
15798
- return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15836
+ with long_resolve_op("timeline.export"):
15837
+ return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
15799
15838
  elif action == "get_setting":
15800
15839
  return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
15801
15840
  elif action == "set_setting":
@@ -16099,7 +16138,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
16099
16138
  if mp_err:
16100
16139
  return mp_err
16101
16140
  return _fairlight_boundary_report(proj, mp, tl, p)
16102
- 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",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
16141
+ 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])
16103
16142
 
16104
16143
 
16105
16144
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -16236,9 +16275,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16236
16275
  return err
16237
16276
 
16238
16277
  if action == "create_subtitles":
16239
- return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16278
+ with long_resolve_op("timeline_ai.create_subtitles"):
16279
+ return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
16240
16280
  elif action == "detect_scene_cuts":
16241
- return {"success": bool(tl.DetectSceneCuts())}
16281
+ with long_resolve_op("timeline_ai.detect_scene_cuts"):
16282
+ return {"success": bool(tl.DetectSceneCuts())}
16242
16283
  elif action == "analyze_dolby_vision":
16243
16284
  clip_ids = p.get("clip_ids", [])
16244
16285
  items = []
@@ -16249,7 +16290,8 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16249
16290
  if it.GetUniqueId() in clip_ids:
16250
16291
  items.append(it)
16251
16292
  analysis_type = p.get("analysis_type")
16252
- return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16293
+ with long_resolve_op("timeline_ai.analyze_dolby_vision"):
16294
+ return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
16253
16295
  elif action == "grab_still":
16254
16296
  still = tl.GrabStill()
16255
16297
  return _ok() if still else _err("Failed to grab still")
@@ -18018,7 +18060,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
18018
18060
  return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
18019
18061
  elif action == "regenerate_magic_mask":
18020
18062
  return {"success": bool(item.RegenerateMagicMask())}
18021
- return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask",*_COLOR_GRADE_KERNEL_ACTIONS])
18063
+ return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask","action_help",*_COLOR_GRADE_KERNEL_ACTIONS])
18022
18064
 
18023
18065
 
18024
18066
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -18402,7 +18444,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
18402
18444
  if blocked:
18403
18445
  return blocked
18404
18446
  return {"success": bool(g.ResetAllGrades())}
18405
- return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades"])
18447
+ return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades","action_help"])
18406
18448
 
18407
18449
 
18408
18450
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,171 @@
1
+ """Cross-process visibility for long synchronous DaVinci Resolve operations.
2
+
3
+ The Resolve scripting bridge executes one call at a time. Most calls return in
4
+ milliseconds, but a handful block for seconds-to-minutes (timeline export and
5
+ import, scene-cut detection, subtitle generation, audio transcription). A
6
+ second tool call issued during one of those — from another thread of this
7
+ server, or from the other instance when stdio and networked servers share one
8
+ Resolve — simply hangs inside fusionscript with no feedback.
9
+
10
+ This module gives those long operations a name and a presence that every
11
+ instance can see, so the Resolve preflight can wait briefly and then return a
12
+ structured "busy" answer instead of hanging:
13
+
14
+ with long_resolve_op("timeline.export"):
15
+ tl.Export(...)
16
+
17
+ op = wait_until_free(timeout_seconds=5.0) # None when free
18
+ if op:
19
+ ... return a RESOLVE_BUSY error naming op["label"] ...
20
+
21
+ Registration is advisory and best-effort: a sidecar JSON file in the temp dir
22
+ (same approach as page_lock) carries {label, pid, thread, started_at}. Records
23
+ are ignored when their pid is dead or they exceed MAX_OP_AGE_SECONDS, so a
24
+ crashed operation can never wedge the server. The registering thread is exempt
25
+ from its own gate — long operations call Resolve helpers internally.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import tempfile
32
+ import threading
33
+ import time
34
+ from contextlib import contextmanager
35
+ from typing import Any, Dict, Optional
36
+
37
+ _SIDECAR = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_busy.json")
38
+ # A long op that has run this long is presumed crashed/leaked and ignored.
39
+ MAX_OP_AGE_SECONDS = 2 * 60 * 60
40
+ # Default time a preflight will wait for the bridge to free up before
41
+ # returning a busy error.
42
+ DEFAULT_WAIT_SECONDS = 5.0
43
+ _POLL_SECONDS = 0.25
44
+
45
+ _lock = threading.Lock()
46
+ _local_owner: Optional[int] = None # thread ident of the in-process registrant
47
+
48
+
49
+ def _pid_alive(pid: int) -> bool:
50
+ try:
51
+ os.kill(pid, 0)
52
+ return True
53
+ except ProcessLookupError:
54
+ return False
55
+ except PermissionError:
56
+ return True
57
+ except OSError:
58
+ return False
59
+
60
+
61
+ def _read_record() -> Optional[Dict[str, Any]]:
62
+ try:
63
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
64
+ record = json.load(handle)
65
+ except (OSError, json.JSONDecodeError):
66
+ return None
67
+ if not isinstance(record, dict) or not record.get("label"):
68
+ return None
69
+ started_at = record.get("started_at")
70
+ if not isinstance(started_at, (int, float)):
71
+ return None
72
+ if time.time() - started_at > MAX_OP_AGE_SECONDS:
73
+ return None
74
+ pid = record.get("pid")
75
+ if not isinstance(pid, int) or not _pid_alive(pid):
76
+ return None
77
+ return record
78
+
79
+
80
+ def _write_record(record: Dict[str, Any]) -> None:
81
+ tmp_path = f"{_SIDECAR}.tmp-{os.getpid()}-{threading.get_ident()}"
82
+ try:
83
+ with open(tmp_path, "w", encoding="utf-8") as handle:
84
+ json.dump(record, handle)
85
+ os.replace(tmp_path, _SIDECAR)
86
+ except OSError:
87
+ pass
88
+ finally:
89
+ try:
90
+ os.remove(tmp_path)
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ def _clear_record() -> None:
96
+ record = None
97
+ try:
98
+ with open(_SIDECAR, "r", encoding="utf-8") as handle:
99
+ record = json.load(handle)
100
+ except (OSError, json.JSONDecodeError):
101
+ pass
102
+ # Only the owning process removes the sidecar; never clobber another
103
+ # instance's registration.
104
+ if isinstance(record, dict) and record.get("pid") == os.getpid():
105
+ try:
106
+ os.remove(_SIDECAR)
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ def current_long_op() -> Optional[Dict[str, Any]]:
112
+ """The active long operation visible across instances, or None."""
113
+ record = _read_record()
114
+ if record is None:
115
+ return None
116
+ return {
117
+ "label": record.get("label"),
118
+ "pid": record.get("pid"),
119
+ "same_process": record.get("pid") == os.getpid(),
120
+ "started_at": record.get("started_at"),
121
+ "age_seconds": round(max(0.0, time.time() - float(record.get("started_at", 0.0))), 1),
122
+ }
123
+
124
+
125
+ @contextmanager
126
+ def long_resolve_op(label: str):
127
+ """Register a long synchronous Resolve call for its duration."""
128
+ global _local_owner
129
+ ident = threading.get_ident()
130
+ with _lock:
131
+ nested = _local_owner is not None
132
+ if not nested:
133
+ _local_owner = ident
134
+ if not nested:
135
+ _write_record({
136
+ "label": str(label),
137
+ "pid": os.getpid(),
138
+ "thread": ident,
139
+ "started_at": time.time(),
140
+ })
141
+ try:
142
+ yield
143
+ finally:
144
+ if not nested:
145
+ with _lock:
146
+ _local_owner = None
147
+ _clear_record()
148
+
149
+
150
+ def wait_until_free(timeout_seconds: Optional[float] = None) -> Optional[Dict[str, Any]]:
151
+ """Wait briefly for any long op to finish.
152
+
153
+ Returns None when the bridge is free (or becomes free within the timeout),
154
+ otherwise the still-running operation's info. The thread that registered
155
+ the current in-process op is never gated by it. timeout_seconds defaults
156
+ to DEFAULT_WAIT_SECONDS, read at call time so callers/tests can tune it.
157
+ """
158
+ if timeout_seconds is None:
159
+ timeout_seconds = DEFAULT_WAIT_SECONDS
160
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
161
+ while True:
162
+ op = current_long_op()
163
+ if op is None:
164
+ return None
165
+ if op["same_process"]:
166
+ record = _read_record() or {}
167
+ if record.get("thread") == threading.get_ident():
168
+ return None
169
+ if time.monotonic() >= deadline:
170
+ return op
171
+ time.sleep(_POLL_SECONDS)
@@ -631,13 +631,22 @@ def _read_state(path: Path) -> Dict[str, Any]:
631
631
 
632
632
 
633
633
  def _write_state(path: Path, result: Mapping[str, Any]) -> None:
634
+ # Atomic replace: _read_state resets to {} on a corrupt file, so a crash
635
+ # mid-write would silently drop snooze/ignore preferences.
636
+ tmp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}")
634
637
  try:
635
638
  path.parent.mkdir(parents=True, exist_ok=True)
636
- with path.open("w", encoding="utf-8") as handle:
639
+ with tmp_path.open("w", encoding="utf-8") as handle:
637
640
  json.dump(result, handle, indent=2, sort_keys=True)
638
641
  handle.write("\n")
642
+ os.replace(tmp_path, path)
639
643
  except OSError:
640
644
  return
645
+ finally:
646
+ try:
647
+ os.remove(tmp_path)
648
+ except OSError:
649
+ pass
641
650
 
642
651
 
643
652
  def _set_cached_status(result: Mapping[str, Any]) -> None: