davinci-resolve-mcp 2.70.3 → 2.71.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,94 @@
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.71.0
6
+
7
+ Keyed metadata getters honor a list of keys, and `delete_timelines` names the
8
+ parameter it wants. Reported and fixed by @billcarroll in #113, from live
9
+ cataloguing work.
10
+
11
+ ### Keyed getters silently returned everything
12
+
13
+ Resolve's keyed getters take one string. Handed a list they ignore it and return
14
+ the full dict, so a caller asking for three fields silently received all of them
15
+ with no signal that the request had been dropped — the kind of thing that reads
16
+ as working until someone counts.
17
+
18
+ `get_metadata`, `get_third_party_metadata` and `get_clip_property` now share a
19
+ `_keyed_get` helper that subsets locally, since there is no batch getter to
20
+ delegate to. An empty or non-string list is a clear error rather than a silent
21
+ superset.
22
+
23
+ Missing keys deliberately report differently through the two forms: the list
24
+ form maps them to `null`, which separates "absent" from "present but empty"; the
25
+ string form still returns Resolve's `""`. All three actions document both the
26
+ list form and that divergence.
27
+
28
+ ### `delete_timelines` leaked a KeyError
29
+
30
+ Called with `timeline_names`, or without `timeline_ids` at all, it raised a bare
31
+ `KeyError('timeline_ids')`. It now returns a proper error naming the expected
32
+ parameter, and when `timeline_names` was passed it says explicitly that
33
+ timelines are matched by unique ID rather than by name.
34
+
35
+ ## What's New in v2.70.4
36
+
37
+ Three silent-failure fixes from community reports, plus the Windows bridge
38
+ follow-ups from #112's live confirmation.
39
+
40
+ ### Thumbnails silently failed off the Color page (#110, @billcarroll)
41
+
42
+ `Timeline.GetCurrentClipThumbnailImage` returns data only while Resolve is on
43
+ the Color page — Blackmagic's own reference documents it as returning data "for
44
+ current media in the Color Page". `thumbnail_contact_sheet` (and
45
+ `marker_thumbnail_review`, which routes through it) reported "No thumbnail
46
+ available at frame" for every sample from any other page, which reads as an
47
+ empty timeline rather than a page requirement.
48
+
49
+ A shared `color_page_for_thumbnails` context manager in `page_lock.py` switches
50
+ under the existing page lock and restores the previous page after. The granular
51
+ server's thumbnail tool shares it and gained a real error message in place of a
52
+ bare `{"success": false}`. Where the current page cannot be read, no switch is
53
+ attempted at all, so a skipped restore can never strand the user on Color.
54
+
55
+ ### probe_media_pool truncated silently, and good script runs reported failure (#108, @billcarroll)
56
+
57
+ `_folder_probe` reported `subfolder_count` from the list it had *expanded*, so
58
+ at the default depth every unexpanded folder looked like an empty leaf. In the
59
+ field, a drive-rename relink sweep trusted `subfolder_count: 0` and skipped
60
+ about 40 populated bins and 573 clips. The count is now the real
61
+ `GetSubFolderList()` length at every level, and folders carry `truncated: true`
62
+ at the cutoff so a walker can descend.
63
+
64
+ Separately, `fusionscript`'s RemoteApp thread can SIGSEGV during interpreter
65
+ teardown *after* a spawned script has finished its work, turning exit 0 into
66
+ -11 and a successful run into `success: false`. Scripts now run via `runpy` and
67
+ hard-exit before teardown. The guard catches `SystemExit`, so a script ending in
68
+ `sys.exit(0)` cannot reopen the race, and repoints `sys.path[0]` at the script's
69
+ directory — under `-c` it points at the server's cwd, which would break sibling
70
+ imports and let stray files shadow real modules. The cost of `os._exit` (atexit
71
+ handlers skipped, non-daemon threads not joined) is documented on the action.
72
+
73
+ ### Bridge: a dead listener no longer reads as a live one (#112)
74
+
75
+ `serve()` treated "the serve thread is alive" as "the bridge is serving".
76
+ `serve_forever()` keeps its thread alive, so that is a narrower question than it
77
+ appears, and a bridge was reported lingering with nothing on its port while the
78
+ serve loop kept polling. That divergence is not explained here, but a process
79
+ running with no listener is useless either way, so the exit condition now asks
80
+ the socket directly.
81
+
82
+ The Win32 prototypes are declared rather than relying on ctypes' default
83
+ int-sized handle. The default is safe by documented contract, but the reporter
84
+ had to derive that from Microsoft's interop documentation to rule out
85
+ truncation; declaring the signatures spares the next reader the exercise.
86
+
87
+ **The v2.70.3 Windows fix is now confirmed on real hardware.** ZontarLives ran a
88
+ same-machine control: on v2.70.2 the bridge outlived Resolve indefinitely; on
89
+ v2.70.3 it exits 0.23s after. `_process_is_alive` returned `None` for pid 4
90
+ (System), confirming that access-denied reads as unknown rather than death
91
+ against a genuinely protected process.
92
+
5
93
  ## What's New in v2.70.3
6
94
 
7
95
  The free-edition bridge could never notice Resolve exiting on Windows, so it
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.70.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.71.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-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -1339,7 +1339,11 @@ Key actions:
1339
1339
  - `apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?)` — apply a
1340
1340
  normalized CDL and/or copy a source grade to multiple video items
1341
1341
  - `thumbnail_contact_sheet` / `marker_thumbnail_review` — sample Resolve-rendered
1342
- thumbnails under the project analysis root for visual verification
1342
+ thumbnails under the project analysis root for visual verification. Resolve
1343
+ only serves these thumbnails on the Color page; the tool switches there
1344
+ automatically and restores the previous page. Expect a page flash in the GUI,
1345
+ and note that landing on Color can kick off cache/render work for the current
1346
+ clip — on a large timeline the switch is not free
1343
1347
  - `edit_kernel_capabilities` — report supported, partially supported, and
1344
1348
  unsupported timeline edit kernel behavior
1345
1349
  - `probe_edit_kernel_item(clip_ids? selected? timeline_item?)` — read-only
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.0
14
14
 
15
- **Totals:** 21 missing capabilities, 20 bugs / unreliable behaviors.
15
+ **Totals:** 22 missing capabilities, 20 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -48,6 +48,14 @@ equivalent, blocking full automation.
48
48
  - **Workaround / current handling:** Ask the user to set it in Project Settings > Master Settings > Playback frame rate as a SETUP step, before any timeline exists. Read it back to confirm; do not report it as set on the strength of the call alone.
49
49
  - **Tags:** project-settings, silent-failure, timeline
50
50
 
51
+ ### Timeline.GetCurrentClipThumbnailImage (Color page only)
52
+
53
+ - **Object:** `Timeline`
54
+ - **Signature:** `() -> {width, height, format, data} | None`
55
+ - **Behavior:** Returns thumbnail data only while Resolve is on the Color page — the reference documents it as returning data 'for current media in the Color Page'. On every other page it silently returns None for every frame, indistinguishable from 'no thumbnail exists', with no error naming the page requirement.
56
+ - **Workaround / current handling:** Switch to the Color page under the page lock before reading and restore the user's page after (src/utils/page_lock.py:color_page_for_thumbnails does exactly this); when the switch fails (headless), name the Color-page requirement in the error instead of reporting a missing thumbnail.
57
+ - **Tags:** timeline, thumbnail, silent-failure, page-dependent
58
+
51
59
  ### Timeline.GetTimelineByName
52
60
 
53
61
  - **Object:** `Project`
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.70.3"
39
+ VERSION = "2.71.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (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.70.3",
3
+ "version": "2.71.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.70.3"
88
+ VERSION = "2.71.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
@@ -2,6 +2,8 @@
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
4
 
5
+ from src.utils.page_lock import color_page_for_thumbnails
6
+
5
7
  resolve = ResolveProxy()
6
8
 
7
9
  @mcp.resource("resolve://timelines")
@@ -930,6 +932,9 @@ def timeline_get_current_video_item() -> Dict[str, Any]:
930
932
  def timeline_get_current_clip_thumbnail(width: int = 320, height: int = 180) -> Dict[str, Any]:
931
933
  """Get thumbnail image data for the current clip.
932
934
 
935
+ Switches to the Color page for the read (GetCurrentClipThumbnailImage only
936
+ returns data there) and restores the previous page after.
937
+
933
938
  Args:
934
939
  width: Thumbnail width. Default: 320.
935
940
  height: Thumbnail height. Default: 180.
@@ -937,10 +942,19 @@ def timeline_get_current_clip_thumbnail(width: int = 320, height: int = 180) ->
937
942
  _, tl, err = _get_timeline()
938
943
  if err:
939
944
  return err
940
- result = tl.GetCurrentClipThumbnailImage()
945
+ with color_page_for_thumbnails(resolve) as on_color:
946
+ result = tl.GetCurrentClipThumbnailImage()
941
947
  if result:
942
- return {"success": True, "has_data": bool(result)}
943
- return {"success": False}
948
+ return {"success": True, "has_data": True}
949
+ return {
950
+ "success": False,
951
+ "error": (
952
+ "No thumbnail available for the current clip"
953
+ if on_color
954
+ else "No thumbnail: GetCurrentClipThumbnailImage requires the "
955
+ "Color page and automatic switching failed (headless or page locked)"
956
+ ),
957
+ }
944
958
 
945
959
 
946
960
  @mcp.tool()
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.70.3"
14
+ VERSION = "2.71.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -48,7 +48,11 @@ from src.utils.mcp_stdio import run_fastmcp_stdio
48
48
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
49
  from src.utils.contracts import validate as _validate_params
50
50
  from src.utils.cut_ir import build_cut_list as _build_cut_list
51
- from src.utils.page_lock import open_page_serialized as _open_page_serialized
51
+ from src.utils.page_lock import (
52
+ color_page_for_thumbnails as _color_page_for_thumbnails,
53
+ open_page_serialized as _open_page_serialized,
54
+ page_lock as _page_lock,
55
+ )
52
56
  from src.utils.proc import safe_run
53
57
  from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
54
58
  from src.utils.render_ids import (
@@ -5513,32 +5517,42 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
5513
5517
  original_timecode = tl.GetCurrentTimecode()
5514
5518
  except Exception:
5515
5519
  pass
5520
+ # GetCurrentClipThumbnailImage only returns data "for current media in the
5521
+ # Color Page" (docs/reference/resolve_scripting_api.txt); on any other page
5522
+ # every frame silently yields None. Switch there for the sampling loop and
5523
+ # restore the user's page afterwards.
5516
5524
  sampled = []
5517
- try:
5518
- for sample in samples:
5519
- timecode, tc_err = _timeline_frame_id_to_timecode(tl, _marker_display_frame(tl, sample["frame"]))
5520
- if tc_err:
5521
- sample["error"] = tc_err.get("error")
5525
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
5526
+ try:
5527
+ for sample in samples:
5528
+ timecode, tc_err = _timeline_frame_id_to_timecode(tl, _marker_display_frame(tl, sample["frame"]))
5529
+ if tc_err:
5530
+ sample["error"] = tc_err.get("error")
5531
+ sampled.append(sample)
5532
+ continue
5533
+ try:
5534
+ tl.SetCurrentTimecode(timecode)
5535
+ thumbnail = tl.GetCurrentClipThumbnailImage()
5536
+ if not thumbnail:
5537
+ sample["error"] = (
5538
+ "No thumbnail available at frame"
5539
+ if on_color
5540
+ else "No thumbnail: GetCurrentClipThumbnailImage requires the "
5541
+ "Color page and automatic switching failed (headless or page locked)"
5542
+ )
5543
+ else:
5544
+ sample["timecode"] = timecode
5545
+ sample["thumbnail_rgb"] = _thumbnail_raw_rgb(thumbnail)
5546
+ sample["thumbnail_available"] = True
5547
+ except Exception as exc:
5548
+ sample["error"] = str(exc)
5522
5549
  sampled.append(sample)
5523
- continue
5524
- try:
5525
- tl.SetCurrentTimecode(timecode)
5526
- thumbnail = tl.GetCurrentClipThumbnailImage()
5527
- if not thumbnail:
5528
- sample["error"] = "No thumbnail available at frame"
5529
- else:
5530
- sample["timecode"] = timecode
5531
- sample["thumbnail_rgb"] = _thumbnail_raw_rgb(thumbnail)
5532
- sample["thumbnail_available"] = True
5533
- except Exception as exc:
5534
- sample["error"] = str(exc)
5535
- sampled.append(sample)
5536
- finally:
5537
- if original_timecode:
5538
- try:
5539
- tl.SetCurrentTimecode(original_timecode)
5540
- except Exception:
5541
- pass
5550
+ finally:
5551
+ if original_timecode:
5552
+ try:
5553
+ tl.SetCurrentTimecode(original_timecode)
5554
+ except Exception:
5555
+ pass
5542
5556
  sheet_samples = [sample for sample in sampled if sample.get("thumbnail_rgb")]
5543
5557
  if not sheet_samples:
5544
5558
  return {"success": False, "samples": sampled, "error": "No thumbnails could be sampled"}
@@ -10877,9 +10891,10 @@ def _folder_probe(folder, depth: int = 1):
10877
10891
  clips = []
10878
10892
  for clip in (folder.GetClipList() or []):
10879
10893
  clips.append(_media_pool_item_summary(clip))
10894
+ subs = folder.GetSubFolderList() or []
10880
10895
  subfolders = []
10881
10896
  if depth > 0:
10882
- for sub in (folder.GetSubFolderList() or []):
10897
+ for sub in subs:
10883
10898
  subfolders.append(_folder_probe(sub, depth - 1))
10884
10899
  stale = None
10885
10900
  try:
@@ -10892,8 +10907,11 @@ def _folder_probe(folder, depth: int = 1):
10892
10907
  "stale": stale,
10893
10908
  "clip_count": len(clips),
10894
10909
  "clips": clips,
10895
- "subfolder_count": len(subfolders),
10910
+ # True count even below the depth cutoff — reporting len(subfolders)
10911
+ # here made unexpanded folders look like empty leaves.
10912
+ "subfolder_count": len(subs),
10896
10913
  "subfolders": subfolders,
10914
+ "truncated": len(subs) > len(subfolders),
10897
10915
  }
10898
10916
 
10899
10917
 
@@ -16449,6 +16467,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16449
16467
  import_folder(path, source_clips_path?) -> {success}
16450
16468
  ingest_capabilities() -> {supported, partially_supported, unsupported}
16451
16469
  probe_media_pool(depth?) -> {media_pool_id, methods, root, current_folder, selected_clips}
16470
+ depth defaults to 1, max 4. Folders below the cutoff have truncated:true
16471
+ and a real subfolder_count; re-probe deeper or use folder.get_subfolders.
16452
16472
  probe_ingest_item(clip_ids? selected?) -> {items, count}
16453
16473
  safe_import_media(paths, target_folder?, dry_run?) -> {success, imported, clips}
16454
16474
  safe_import_sequence(FilePath|file_path|pattern, StartIndex?, EndIndex?, target_folder?, dry_run?) -> {success, imported, clips}
@@ -16606,11 +16626,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16606
16626
 
16607
16627
  return _run_maybe_background("media_pool.import_timeline", p, _work)
16608
16628
  elif action == "delete_timelines":
16629
+ ids = p.get("timeline_ids")
16630
+ if not isinstance(ids, list) or not ids:
16631
+ hint = (" ('timeline_names' is not supported — timelines are matched"
16632
+ " by unique ID, e.g. from timeline.get_unique_id)"
16633
+ if "timeline_names" in p else "")
16634
+ return _err("delete_timelines requires 'timeline_ids', a non-empty"
16635
+ " list of timeline unique IDs" + hint)
16609
16636
  count = proj.GetTimelineCount()
16610
16637
  timelines = []
16611
16638
  for i in range(1, count + 1):
16612
16639
  tl = proj.GetTimelineByIndex(i)
16613
- if tl and tl.GetUniqueId() in p["timeline_ids"]:
16640
+ if tl and tl.GetUniqueId() in ids:
16614
16641
  timelines.append(tl)
16615
16642
  if not timelines:
16616
16643
  return _err("No timelines found")
@@ -16986,6 +17013,22 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16986
17013
  # TOOL 13: media_pool_item
16987
17014
  # ═══════════════════════════════════════════════════════════════════════════════
16988
17015
 
17016
+ def _keyed_get(getter, key):
17017
+ """Resolve's keyed getters take one string key; passed a list they silently
17018
+ ignore it and return the full dict. Subset it ourselves instead.
17019
+
17020
+ Returns (value, error) — exactly one is non-None unless value is legitimately
17021
+ empty."""
17022
+ if isinstance(key, list):
17023
+ if not key or not all(isinstance(k, str) for k in key):
17024
+ return None, _err("'key' must be a string or a non-empty list of strings")
17025
+ full = getter("")
17026
+ if not isinstance(full, dict):
17027
+ full = {}
17028
+ return {k: full.get(k) for k in key}, None
17029
+ return getter(key), None
17030
+
17031
+
16989
17032
  @mcp.tool()
16990
17033
  @_guard_missing_params
16991
17034
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -16994,11 +17037,26 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16994
17037
  Actions:
16995
17038
  get_name(clip_id) -> {name}
16996
17039
  get_metadata(clip_id, key?) -> {metadata}
17040
+ — key: one string, or a list of strings to get just that subset.
17041
+ Missing keys: the list form maps them to null (distinguishing
17042
+ absent from empty); the string form passes Resolve's own answer
17043
+ through unchanged, which is "" or null depending on the getter
17044
+ and build (get_clip_property returns null on Studio 19.1.3.7).
16997
17045
  set_metadata(clip_id, key, value) OR set_metadata(clip_id, metadata) -> {success}
16998
17046
  get_third_party_metadata(clip_id, key?) -> {metadata}
17047
+ — key: one string, or a list of strings to get just that subset.
17048
+ Missing keys: the list form maps them to null (distinguishing
17049
+ absent from empty); the string form passes Resolve's own answer
17050
+ through unchanged, which is "" or null depending on the getter
17051
+ and build (get_clip_property returns null on Studio 19.1.3.7).
16999
17052
  set_third_party_metadata(clip_id, key, value) -> {success}
17000
17053
  get_media_id(clip_id) -> {media_id}
17001
17054
  get_clip_property(clip_id, key?) -> {properties}
17055
+ — key: one string, or a list of strings to get just that subset.
17056
+ Missing keys: the list form maps them to null (distinguishing
17057
+ absent from empty); the string form passes Resolve's own answer
17058
+ through unchanged, which is "" or null depending on the getter
17059
+ and build (get_clip_property returns null on Studio 19.1.3.7).
17002
17060
  set_clip_property(clip_id, key, value) -> {success}
17003
17061
  get_clip_color(clip_id) -> {color}
17004
17062
  set_clip_color(clip_id, color) -> {success}
@@ -17160,7 +17218,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17160
17218
  if action == "get_name":
17161
17219
  return {"name": clip.GetName()}
17162
17220
  elif action == "get_metadata":
17163
- return {"metadata": _ser(clip.GetMetadata(p.get("key", "")))}
17221
+ value, key_err = _keyed_get(clip.GetMetadata, p.get("key", ""))
17222
+ if key_err:
17223
+ return key_err
17224
+ return {"metadata": _ser(value)}
17164
17225
  elif action == "set_metadata":
17165
17226
  if "metadata" in p:
17166
17227
  ok = bool(clip.SetMetadata(p["metadata"]))
@@ -17176,13 +17237,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17176
17237
  return silent
17177
17238
  return {"success": ok}
17178
17239
  elif action == "get_third_party_metadata":
17179
- return {"metadata": _ser(clip.GetThirdPartyMetadata(p.get("key", "")))}
17240
+ value, key_err = _keyed_get(clip.GetThirdPartyMetadata, p.get("key", ""))
17241
+ if key_err:
17242
+ return key_err
17243
+ return {"metadata": _ser(value)}
17180
17244
  elif action == "set_third_party_metadata":
17181
17245
  return {"success": bool(clip.SetThirdPartyMetadata(p["key"], p["value"]))}
17182
17246
  elif action == "get_media_id":
17183
17247
  return {"media_id": clip.GetMediaId()}
17184
17248
  elif action == "get_clip_property":
17185
- return {"properties": _ser(clip.GetClipProperty(p.get("key", "")))}
17249
+ value, key_err = _keyed_get(clip.GetClipProperty, p.get("key", ""))
17250
+ if key_err:
17251
+ return key_err
17252
+ return {"properties": _ser(value)}
17186
17253
  elif action == "set_clip_property":
17187
17254
  ok = bool(clip.SetClipProperty(p["key"], p["value"]))
17188
17255
  if ok:
@@ -25558,11 +25625,44 @@ def _python_env_for_resolve() -> Dict[str, str]:
25558
25625
  return env
25559
25626
 
25560
25627
 
25628
+ # fusionscript's RemoteApp thread keeps dispatching packets from Resolve while
25629
+ # the interpreter tears down at exit, and can SIGSEGV *after* the script has
25630
+ # finished — turning a successful run into exit code -11 / success:false.
25631
+ # Run the script via runpy and hard-exit before teardown so the exit code is
25632
+ # truthful. SystemExit must be caught here: uncaught, a plain sys.exit(0) at
25633
+ # the end of a script would take the normal teardown path and reopen the
25634
+ # segfault window. sys.path[0] is pointed at the script's directory to mimic
25635
+ # `python script.py` (under -c it points at the server's cwd, which both
25636
+ # breaks sibling imports and lets stray files there shadow real modules).
25637
+ # Cost of os._exit: atexit handlers never run and non-daemon threads are not
25638
+ # joined — documented in script_plugin's execute action.
25639
+ _PY_SCRIPT_EXIT_GUARD = (
25640
+ "import os, runpy, sys, traceback\n"
25641
+ "sys.argv = sys.argv[1:]\n"
25642
+ "sys.path[0] = os.path.dirname(os.path.abspath(sys.argv[0]))\n"
25643
+ "code = 0\n"
25644
+ "try:\n"
25645
+ " runpy.run_path(sys.argv[0], run_name='__main__')\n"
25646
+ "except SystemExit as e:\n"
25647
+ " if isinstance(e.code, int):\n"
25648
+ " code = e.code\n"
25649
+ " elif e.code is not None:\n"
25650
+ " print(e.code, file=sys.stderr)\n"
25651
+ " code = 1\n"
25652
+ "except BaseException:\n"
25653
+ " traceback.print_exc()\n"
25654
+ " code = 1\n"
25655
+ "sys.stdout.flush()\n"
25656
+ "sys.stderr.flush()\n"
25657
+ "os._exit(code)\n"
25658
+ )
25659
+
25660
+
25561
25661
  def _execute_python_script(path: str, args: List[str],
25562
25662
  timeout: int) -> Dict[str, Any]:
25563
25663
  # Ensure Resolve is running so the script can connect.
25564
25664
  get_resolve()
25565
- cmd = [sys.executable, path] + [str(a) for a in args]
25665
+ cmd = [sys.executable, "-c", _PY_SCRIPT_EXIT_GUARD, path] + [str(a) for a in args]
25566
25666
  try:
25567
25667
  result = safe_run(cmd, env=_python_env_for_resolve(),
25568
25668
  capture_output=True, text=True, timeout=timeout)
@@ -26240,6 +26340,10 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
26240
26340
  — args: list of CLI args for the Python subprocess (Python only).
26241
26341
  — timeout: seconds (default 120 for execute, 60 for run_inline).
26242
26342
  — Auto-launches Resolve if not running.
26343
+ — Python scripts hard-exit after the script body (guards against
26344
+ fusionscript's segfault-at-exit race), so atexit handlers do not
26345
+ run and non-daemon threads are not joined. Do cleanup inline or
26346
+ in try/finally, not in atexit.
26243
26347
  run_inline(source, language, timeout?) -> {success, stdout?, stderr?, result?}
26244
26348
  — Python: writes to temp file with `resolve`/`project`/`mp`/`timeline`
26245
26349
  pre-bound, runs as subprocess, captures stdout/stderr.
@@ -354,6 +354,26 @@ API_TRUTH: List[Dict[str, Any]] = [
354
354
  "tags": ["fusion", "unreliable-return"],
355
355
  "submit": "bug",
356
356
  },
357
+ {
358
+ "symbol": "Timeline.GetCurrentClipThumbnailImage (Color page only)",
359
+ "object": "Timeline",
360
+ "signature": "() -> {width, height, format, data} | None",
361
+ "reality": "Returns thumbnail data only while Resolve is on the Color "
362
+ "page — the reference documents it as returning data 'for "
363
+ "current media in the Color Page'. On every other page it "
364
+ "silently returns None for every frame, indistinguishable "
365
+ "from 'no thumbnail exists', with no error naming the page "
366
+ "requirement.",
367
+ "recommended": "Switch to the Color page under the page lock before "
368
+ "reading and restore the user's page after "
369
+ "(src/utils/page_lock.py:color_page_for_thumbnails does "
370
+ "exactly this); when the switch fails (headless), name "
371
+ "the Color-page requirement in the error instead of "
372
+ "reporting a missing thumbnail.",
373
+ "tags": ["timeline", "thumbnail", "silent-failure", "page-dependent"],
374
+ "submit": "missing",
375
+ "mitigation": ["color_page_for_thumbnails", "_timeline_thumbnail_contact_sheet"],
376
+ },
357
377
  {
358
378
  "symbol": "Timeline.GetTimelineByName",
359
379
  "object": "Project",
@@ -77,3 +77,39 @@ def open_page_serialized(resolve, page):
77
77
  """Switch Resolve to `page` under the page lock. Returns OpenPage's result."""
78
78
  with page_lock():
79
79
  return resolve.OpenPage(page)
80
+
81
+
82
+ @contextmanager
83
+ def color_page_for_thumbnails(resolve):
84
+ """Hold the Color page for the block, restoring the user's page after.
85
+
86
+ GetCurrentClipThumbnailImage returns data only "for current media in the
87
+ Color Page" (docs/reference/resolve_scripting_api.txt) — on every other
88
+ page it silently returns None. Yields True when Resolve is on the Color
89
+ page for the block. If the current page can't be captured (GetCurrentPage
90
+ returned None or raised), no switch is attempted, so a skipped restore can
91
+ never strand the user on the Color page.
92
+
93
+ Switching to Color is not free in the GUI: besides the visible page flash,
94
+ Resolve may start cache/render work for the current clip.
95
+ """
96
+ original = None
97
+ try:
98
+ original = resolve.GetCurrentPage() if resolve else None
99
+ except Exception:
100
+ original = None
101
+ with page_lock():
102
+ on_color = original == "color"
103
+ if original and not on_color:
104
+ try:
105
+ on_color = bool(resolve.OpenPage("color"))
106
+ except Exception:
107
+ pass
108
+ try:
109
+ yield on_color
110
+ finally:
111
+ if original and original != "color":
112
+ try:
113
+ resolve.OpenPage(original)
114
+ except Exception:
115
+ pass
@@ -233,7 +233,7 @@ def _windows_process_name(pid: int) -> str: # pragma: no cover - exercised on W
233
233
  import ctypes
234
234
  from ctypes import wintypes
235
235
 
236
- kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
236
+ kernel32 = _win_kernel32()
237
237
  handle = kernel32.OpenProcess(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
238
238
  if not handle:
239
239
  return ""
@@ -257,6 +257,32 @@ _WIN_WAIT_TIMEOUT = 0x102
257
257
  _WIN_ERROR_INVALID_PARAMETER = 87
258
258
 
259
259
 
260
+ def _win_kernel32(): # pragma: no cover - exercised on Windows
261
+ """kernel32 with prototypes declared.
262
+
263
+ Undeclared, ctypes treats the returned HANDLE as a 32-bit `c_int` while a
264
+ Win64 HANDLE is 64 bits. That is safe here by documented contract — Windows
265
+ keeps handle values within 32 bits for 32/64-bit interop, and observed
266
+ values are in the low thousands — but relying on it silently means the next
267
+ reader has to re-derive that argument, so declare the signatures instead.
268
+ """
269
+ import ctypes
270
+ from ctypes import wintypes
271
+
272
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
273
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
274
+ kernel32.OpenProcess.restype = wintypes.HANDLE
275
+ kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
276
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
277
+ kernel32.QueryFullProcessImageNameW.argtypes = [
278
+ wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)
279
+ ]
280
+ kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
281
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
282
+ kernel32.CloseHandle.restype = wintypes.BOOL
283
+ return kernel32
284
+
285
+
260
286
  def _process_is_alive(pid: int) -> Optional[bool]:
261
287
  """True / False / None when it genuinely cannot be determined.
262
288
 
@@ -270,7 +296,7 @@ def _process_is_alive(pid: int) -> Optional[bool]:
270
296
  try:
271
297
  import ctypes
272
298
 
273
- kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
299
+ kernel32 = _win_kernel32()
274
300
  access = _WIN_SYNCHRONIZE | _WIN_PROCESS_QUERY_LIMITED_INFORMATION
275
301
  handle = kernel32.OpenProcess(access, False, int(pid))
276
302
  if not handle:
@@ -656,6 +682,24 @@ class Bridge:
656
682
  def port(self) -> int:
657
683
  return self._server.server_address[1] if self._server else self.config["port"]
658
684
 
685
+ def _listener_is_live(self) -> bool:
686
+ """Is the socket still open, not merely: is the thread still running?
687
+
688
+ `serve_forever()` keeps a thread alive, so `is_alive()` answers a
689
+ narrower question than it appears to. A bridge was reported lingering
690
+ with nothing listening on its port while its serve loop kept polling
691
+ (issue #112) — that combination is unexplained, but a process still
692
+ running with no listener is useless either way, so the exit condition
693
+ asks about the socket directly rather than inferring it from the thread.
694
+ """
695
+ server = self._server
696
+ if server is None:
697
+ return False
698
+ try:
699
+ return server.fileno() >= 0
700
+ except Exception:
701
+ return False
702
+
659
703
  def serve(self, *, poll_seconds: float = 1.0, host_model: Optional[Dict[str, Any]] = None,
660
704
  parent_exited: Optional[Callable[[int, str], bool]] = None) -> Dict[str, Any]:
661
705
  """Start, then block or return according to the detected host model.
@@ -684,7 +728,7 @@ class Bridge:
684
728
  if self._stop_event.is_set():
685
729
  reason = self._stop_mode or "exit"
686
730
  break
687
- if self._thread is None or not self._thread.is_alive():
731
+ if self._thread is None or not self._thread.is_alive() or not self._listener_is_live():
688
732
  reason = "listener_died"
689
733
  break
690
734
  if host_exited(expected_parent, expected_name):