davinci-resolve-mcp 2.95.3 → 2.97.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/src/server.py CHANGED
@@ -2,7 +2,7 @@
2
2
  """
3
3
  DaVinci Resolve MCP Server (Compound Tools)
4
4
 
5
- 34 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
5
+ 35 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
6
6
  plus Fusion Fuse, DCTL, and Resolve-page Script authoring tools.
7
7
  Each tool groups related operations via an 'action' parameter.
8
8
 
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.95.3"
14
+ VERSION = "2.97.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -317,14 +317,15 @@ def davinci_resolve_workflow() -> str:
317
317
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
318
318
 
319
319
  Core pattern:
320
- - Prefer the 34 compound tools and their action names over raw scripting.
320
+ - Prefer the 35 compound tools and their action names over raw scripting.
321
321
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
322
322
  - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
323
323
  - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
324
324
  - Do not silently downgrade media analysis. Source-safe does not mean no visuals, no transcription, no persistence, no metadata, or no markers. For Resolve-target media analysis, keep visual analysis, transcription, persisted artifacts, metadata writeback, and Media Pool marker writeback enabled unless the user explicitly opts out. Vision uses host_chat_paths by default: analyze actions return absolute frame_paths in a deferred payload; you must read those frames as images and call media_analysis(action="commit_vision", ...) to finalize. Not completing commit_vision leaves the analysis in pending_host_vision_analysis — that is a failure mode, not a success.
325
325
 
326
326
  Visual feedback:
327
- - For the current Color-page frame, use timeline_markers(action="get_thumbnail_image") when the client can display MCP images.
327
+ - To see what Resolve renders — grade, Fusion, titles — use timeline_frame(action="capture") when the client can display MCP images. It takes an optional timecode/frame, max_width to bound context cost, and quality="full" for a full-resolution frame; it restores the page, playhead, and timeline afterwards. Look at the frame instead of inferring from metadata.
328
+ - Use timeline(action="thumbnail_contact_sheet") to review many frames at once.
328
329
  - Use timeline_markers(action="get_thumbnail") when raw Resolve thumbnail data is needed for tooling.
329
330
  - Use project_settings(action="export_frame_as_still") only when a file export is explicitly useful, and write to a temp/stills location rather than near source media.
330
331
 
@@ -12887,6 +12888,558 @@ def _thumbnail_data_to_png_bytes(thumbnail_data: Dict[str, Any]) -> bytes:
12887
12888
  + _png_chunk(b"IEND", b"")
12888
12889
  )
12889
12890
 
12891
+
12892
+ # ── Playhead frame capture ────────────────────────────────────────────────────
12893
+ # Shared by timeline_frame(action="capture") and the older
12894
+ # timeline_markers(action="get_thumbnail_image"). Both paths read what Resolve
12895
+ # renders — grade, Fusion, titles — not the source file.
12896
+
12897
+ _PLAYHEAD_STILL_FORMATS = {"png", "jpg", "tif"}
12898
+
12899
+
12900
+ def _box_downscale_rgb(width: int, height: int, raw: bytes, max_width: int) -> Tuple[int, int, bytes]:
12901
+ """Area-average downscale of packed RGB, in pure Python.
12902
+
12903
+ Box-average rather than nearest-neighbour because the caller is usually a
12904
+ vision model: nearest aliases fine detail (titles, credits, hair) into
12905
+ artefacts that read as real image content. Only ever runs on the preview
12906
+ path — Resolve's thumbnail is small enough that a per-pixel Python loop is
12907
+ cheap. The full-resolution path scales with ffmpeg instead, because the same
12908
+ loop over a 4K frame takes seconds.
12909
+ """
12910
+ if max_width <= 0 or width <= max_width:
12911
+ return width, height, raw
12912
+ new_w = max(1, int(max_width))
12913
+ new_h = max(1, int(round(height * new_w / width)))
12914
+ out = bytearray(new_w * new_h * 3)
12915
+ for y in range(new_h):
12916
+ y0 = (y * height) // new_h
12917
+ y1 = max(y0 + 1, ((y + 1) * height) // new_h)
12918
+ for x in range(new_w):
12919
+ x0 = (x * width) // new_w
12920
+ x1 = max(x0 + 1, ((x + 1) * width) // new_w)
12921
+ r = g = b = count = 0
12922
+ for sy in range(y0, y1):
12923
+ row = sy * width * 3
12924
+ for sx in range(x0, x1):
12925
+ off = row + sx * 3
12926
+ r += raw[off]
12927
+ g += raw[off + 1]
12928
+ b += raw[off + 2]
12929
+ count += 1
12930
+ dst = (y * new_w + x) * 3
12931
+ out[dst] = r // count
12932
+ out[dst + 1] = g // count
12933
+ out[dst + 2] = b // count
12934
+ return new_w, new_h, bytes(out)
12935
+
12936
+
12937
+ def _ffmpeg_scale_to_bytes(src_path: str, max_width: Optional[int], out_format: str) -> Tuple[Optional[bytes], Optional[str]]:
12938
+ """Scale/transcode a still with ffmpeg. Returns (bytes, error_message)."""
12939
+ ffmpeg = shutil.which("ffmpeg")
12940
+ if not ffmpeg:
12941
+ return None, "ffmpeg not found on PATH"
12942
+ suffix = ".jpg" if out_format in ("jpg", "jpeg") else ".png"
12943
+ fd, tmp_out = tempfile.mkstemp(suffix=suffix)
12944
+ os.close(fd)
12945
+ try:
12946
+ args = [ffmpeg, "-y", "-loglevel", "error", "-i", src_path]
12947
+ if max_width:
12948
+ # -2 keeps the height even (required by some encoders) and preserves AR.
12949
+ args += ["-vf", f"scale='min({int(max_width)},iw)':-2:flags=lanczos"]
12950
+ args += ["-frames:v", "1", tmp_out]
12951
+ proc = subprocess.run(args, capture_output=True, timeout=120)
12952
+ if proc.returncode != 0:
12953
+ return None, (proc.stderr.decode("utf-8", "replace").strip() or "ffmpeg failed")[:400]
12954
+ with open(tmp_out, "rb") as handle:
12955
+ return handle.read(), None
12956
+ except (OSError, subprocess.SubprocessError) as exc:
12957
+ return None, str(exc)
12958
+ finally:
12959
+ try:
12960
+ os.remove(tmp_out)
12961
+ except OSError:
12962
+ pass
12963
+
12964
+
12965
+ def _playhead_seek(tl, p: Dict[str, Any]) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
12966
+ """Move the playhead if the caller named a position. Returns (original_tc, error).
12967
+
12968
+ original_tc is non-None only when we actually moved, so the caller restores
12969
+ exactly what it disturbed and a read-only capture never touches the playhead.
12970
+ """
12971
+ timecode = p.get("timecode")
12972
+ frame = p.get("frame")
12973
+ if timecode is None and frame is None:
12974
+ return None, None
12975
+ if timecode is None:
12976
+ try:
12977
+ frame_id = int(frame)
12978
+ except (TypeError, ValueError):
12979
+ return None, _err("frame must be an integer", code="INVALID_FRAME", category="invalid_input")
12980
+ timecode, tc_err = _timeline_frame_id_to_timecode(tl, frame_id)
12981
+ if tc_err:
12982
+ return None, tc_err
12983
+ try:
12984
+ original = tl.GetCurrentTimecode()
12985
+ except Exception:
12986
+ original = None
12987
+ target = _playhead_absolute_timecode(tl, timecode)
12988
+ try:
12989
+ moved = bool(tl.SetCurrentTimecode(target))
12990
+ except Exception as exc:
12991
+ return None, _err(f"Failed to move the playhead: {exc}", code="SEEK_FAILED", category="api_error")
12992
+ if not moved:
12993
+ return None, _err(
12994
+ f"Resolve refused the timecode {target!r}",
12995
+ code="SEEK_FAILED", category="invalid_input",
12996
+ remediation="Pass a timecode inside the timeline, as absolute ('01:00:15:12') or elapsed ('00:00:15:12') time.",
12997
+ )
12998
+ return original, None
12999
+
13000
+
13001
+ def _playhead_thumbnail_settled(tl, attempts: int = 12, delay: float = 0.3):
13002
+ """Read the thumbnail, giving Resolve time to catch up first.
13003
+
13004
+ Two things make the first read come back empty even when everything is
13005
+ correct: a page switch to Color, and a playhead move — the viewer has not
13006
+ caught up when the very next scripting call lands (measured on Studio
13007
+ 19.1.3.7, where the first capture after a switch returned None and later
13008
+ ones on the same timeline succeeded). Poll instead of sleeping a fixed
13009
+ amount, so the common warm case stays immediate.
13010
+ """
13011
+ for attempt in range(attempts):
13012
+ try:
13013
+ thumbnail = tl.GetCurrentClipThumbnailImage()
13014
+ except Exception as exc:
13015
+ return None, _err(
13016
+ f"GetCurrentClipThumbnailImage raised: {exc}",
13017
+ code="THUMBNAIL_FAILED", category="api_error",
13018
+ )
13019
+ if thumbnail:
13020
+ return thumbnail, None
13021
+ if attempt < attempts - 1:
13022
+ time.sleep(delay)
13023
+ return None, None
13024
+
13025
+
13026
+ def _playhead_frame_preview(tl, p: Dict[str, Any]):
13027
+ """Current frame via GetCurrentClipThumbnailImage, as MCP image content."""
13028
+ max_width = p.get("max_width", p.get("maxWidth"))
13029
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
13030
+ original_tc, seek_err = _playhead_seek(tl, p)
13031
+ if seek_err:
13032
+ return seek_err
13033
+ try:
13034
+ thumbnail, thumb_err = _playhead_thumbnail_settled(tl)
13035
+ if thumb_err:
13036
+ return thumb_err
13037
+ if not thumbnail:
13038
+ return _err(
13039
+ "Resolve returned no thumbnail for the current frame."
13040
+ if on_color else
13041
+ "Resolve returned no thumbnail: GetCurrentClipThumbnailImage only "
13042
+ "works on the Color page and the automatic switch failed (headless, "
13043
+ "or the page is locked).",
13044
+ code="NO_THUMBNAIL", category="precondition",
13045
+ remediation=(
13046
+ "Bring DaVinci Resolve to the front — the thumbnail API returns "
13047
+ "nothing while Resolve is in the background, even on the Color "
13048
+ "page (measured on Studio 19.1.3.7). Also confirm a video item "
13049
+ "sits under the playhead."
13050
+ ) if on_color else
13051
+ "Open the Color page in Resolve and bring it to the front, or use quality='full'.",
13052
+ )
13053
+ try:
13054
+ width, height, raw = _thumbnail_raw_rgb(thumbnail)
13055
+ except ValueError as exc:
13056
+ return _err(str(exc), code="THUMBNAIL_DECODE_FAILED", category="api_error")
13057
+ if max_width:
13058
+ width, height, raw = _box_downscale_rgb(width, height, raw, int(max_width))
13059
+ return Image(data=_rgb_to_png_bytes(width, height, raw), format="png")
13060
+ finally:
13061
+ if original_tc:
13062
+ try:
13063
+ tl.SetCurrentTimecode(original_tc)
13064
+ except Exception:
13065
+ pass
13066
+
13067
+
13068
+ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
13069
+ """Render exactly one frame — the only frame-accurate capture route.
13070
+
13071
+ The two cheaper routes cannot do this job (both measured on Studio 19.1.3.7,
13072
+ recorded in api_truth):
13073
+ - GetCurrentClipThumbnailImage returns the CLIP's thumbnail. Seeking within
13074
+ a clip returns byte-identical data; it changes only at a clip boundary.
13075
+ - ExportStills returns bare False unless the Gallery panel is open, which
13076
+ no scripting call can arrange.
13077
+ A single-frame render honours the grade, Fusion and titles, is frame-exact,
13078
+ runs in well under a second, and needs no GUI panel or foreground window.
13079
+
13080
+ The cost is that render settings are project-level state. Format and codec
13081
+ are readable and are restored; the rest (TargetDir, CustomName, mark range)
13082
+ is NOT readable on builds without GetRenderSettings, so this resets those to
13083
+ sane values rather than truly restoring them. Callers who need a strictly
13084
+ side-effect-free read should use quality="thumbnail" and accept per-clip
13085
+ granularity.
13086
+ """
13087
+ fmt = str(p.get("format", "jpg")).lower().lstrip(".")
13088
+ if fmt == "jpeg":
13089
+ fmt = "jpg"
13090
+ if fmt not in ("jpg", "png", "tif"):
13091
+ return _err(
13092
+ f"format must be jpg, png or tif; got {fmt!r}",
13093
+ code="INVALID_FORMAT", category="invalid_input",
13094
+ )
13095
+ max_width = p.get("max_width", p.get("maxWidth"))
13096
+ if max_width and not shutil.which("ffmpeg"):
13097
+ return _err(
13098
+ "max_width needs ffmpeg to rescale, and ffmpeg is not on PATH",
13099
+ code="FFMPEG_REQUIRED", category="precondition",
13100
+ remediation="Install ffmpeg, or drop max_width to get the full-resolution frame.",
13101
+ )
13102
+ if proj.IsRenderingInProgress():
13103
+ return _err(
13104
+ "A render is already in progress",
13105
+ code="RENDER_BUSY", category="precondition", retryable=True,
13106
+ remediation="Wait for the current render to finish, or use quality='thumbnail'.",
13107
+ )
13108
+
13109
+ # Which frame? Default to wherever the playhead already is.
13110
+ frame = p.get("frame")
13111
+ timecode = p.get("timecode")
13112
+ if frame is None and timecode is not None:
13113
+ frame, frame_err = _timeline_timecode_to_frame_id(tl, _playhead_absolute_timecode(tl, timecode))
13114
+ if frame_err:
13115
+ return frame_err
13116
+ elif frame is None:
13117
+ frame, frame_err = _current_timeline_frame_id(tl)
13118
+ if frame_err:
13119
+ return frame_err
13120
+ try:
13121
+ frame = int(frame)
13122
+ except (TypeError, ValueError):
13123
+ return _err("frame must be an integer", code="INVALID_FRAME", category="invalid_input")
13124
+
13125
+ folder = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-frame-captures"))
13126
+ os.makedirs(folder, exist_ok=True)
13127
+ name = f"capture-{int(time.time() * 1000)}"
13128
+
13129
+ original_fc = None
13130
+ try:
13131
+ original_fc = proj.GetCurrentRenderFormatAndCodec()
13132
+ except Exception:
13133
+ pass
13134
+ # Rendering pulls Resolve onto the Deliver page and moves the playhead;
13135
+ # measured leaving the user on Deliver at a different frame. Both are ours
13136
+ # to put back.
13137
+ resolve = get_resolve()
13138
+ original_page = None
13139
+ try:
13140
+ original_page = resolve.GetCurrentPage() if resolve else None
13141
+ except Exception:
13142
+ original_page = None
13143
+ original_tc = None
13144
+ try:
13145
+ original_tc = tl.GetCurrentTimecode()
13146
+ except Exception:
13147
+ pass
13148
+
13149
+ job = None
13150
+ try:
13151
+ codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
13152
+ codec = list(codecs.values())[0] if codecs else fmt
13153
+ if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
13154
+ return _err(
13155
+ f"Resolve refused render format {fmt!r} with codec {codec!r}",
13156
+ code="RENDER_FORMAT_REFUSED", category="api_error",
13157
+ state={"format": fmt, "codec": codec},
13158
+ )
13159
+ applied = proj.SetRenderSettings({
13160
+ "TargetDir": folder,
13161
+ "CustomName": name,
13162
+ "MarkIn": frame,
13163
+ "MarkOut": frame,
13164
+ "SelectAllFrames": False,
13165
+ "ExportVideo": True,
13166
+ "ExportAudio": False,
13167
+ })
13168
+ if not applied:
13169
+ return _err(
13170
+ "SetRenderSettings refused the single-frame range",
13171
+ code="RENDER_SETTINGS_REFUSED", category="api_error",
13172
+ state={"frame": frame},
13173
+ )
13174
+ job = proj.AddRenderJob()
13175
+ if not job:
13176
+ return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
13177
+ before = set(os.listdir(folder))
13178
+ if not proj.StartRendering([job], isInteractiveMode=False):
13179
+ return _err("StartRendering refused the job", code="RENDER_START_FAILED", category="api_error")
13180
+ waited = 0.0
13181
+ while proj.IsRenderingInProgress() and waited < 120:
13182
+ time.sleep(0.25)
13183
+ waited += 0.25
13184
+ status = _ser(proj.GetRenderJobStatus(job)) or {}
13185
+ if status.get("JobStatus") != "Complete":
13186
+ return _err(
13187
+ f"Render did not complete: {status.get('JobStatus')}",
13188
+ code="RENDER_FAILED", category="api_error",
13189
+ state={"status": status, "frame": frame},
13190
+ )
13191
+ # Resolve appends the frame number to CustomName, so match on the prefix.
13192
+ written = sorted(f for f in set(os.listdir(folder)) - before if f.startswith(name))
13193
+ if not written:
13194
+ return _err(
13195
+ "Render reported success but wrote no file",
13196
+ code="RENDER_FAILED", category="api_error",
13197
+ state={"folder": folder, "frame": frame},
13198
+ )
13199
+ src_path = os.path.join(folder, written[0])
13200
+ out_format = "jpg" if fmt == "jpg" else "png"
13201
+ if max_width or fmt == "tif":
13202
+ data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
13203
+ if ff_err:
13204
+ return _err(f"Failed to rescale the rendered frame: {ff_err}", code="RESCALE_FAILED", category="api_error")
13205
+ else:
13206
+ with open(src_path, "rb") as handle:
13207
+ data = handle.read()
13208
+ return Image(data=data, format=out_format)
13209
+ finally:
13210
+ if job:
13211
+ try:
13212
+ proj.DeleteRenderJob(job)
13213
+ except Exception:
13214
+ pass
13215
+ if original_fc:
13216
+ try:
13217
+ proj.SetCurrentRenderFormatAndCodec(
13218
+ original_fc.get("format"), original_fc.get("codec"))
13219
+ except Exception:
13220
+ pass
13221
+ # Best-effort, not a restore: without GetRenderSettings there is nothing
13222
+ # to restore FROM, so put the mark range back to the whole timeline
13223
+ # rather than leaving it pinned to the captured frame.
13224
+ try:
13225
+ proj.SetRenderSettings({
13226
+ "SelectAllFrames": True,
13227
+ "MarkIn": tl.GetStartFrame(),
13228
+ "MarkOut": tl.GetEndFrame(),
13229
+ "CustomName": "",
13230
+ })
13231
+ except Exception:
13232
+ pass
13233
+ try:
13234
+ for f in os.listdir(folder):
13235
+ if f.startswith(name):
13236
+ try:
13237
+ os.remove(os.path.join(folder, f))
13238
+ except OSError:
13239
+ pass
13240
+ if not os.listdir(folder):
13241
+ os.rmdir(folder)
13242
+ except OSError:
13243
+ pass
13244
+ if original_tc:
13245
+ try:
13246
+ tl.SetCurrentTimecode(original_tc)
13247
+ except Exception:
13248
+ pass
13249
+ if original_page and original_page != "deliver":
13250
+ try:
13251
+ _open_page_serialized(resolve, original_page)
13252
+ except Exception:
13253
+ pass
13254
+
13255
+
13256
+ def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
13257
+ """Current frame at full resolution via GrabStill + ExportStills."""
13258
+ fmt = str(p.get("format", "png")).lower().lstrip(".")
13259
+ if fmt == "jpeg":
13260
+ fmt = "jpg"
13261
+ if fmt not in _PLAYHEAD_STILL_FORMATS:
13262
+ return _err(
13263
+ f"format must be one of {sorted(_PLAYHEAD_STILL_FORMATS)} for an image response; got {fmt!r}",
13264
+ code="INVALID_FORMAT", category="invalid_input",
13265
+ remediation="Use gallery_stills(action='grab_and_export') for dpx/cin/drx and other non-displayable formats.",
13266
+ )
13267
+ max_width = p.get("max_width", p.get("maxWidth"))
13268
+ if max_width and not shutil.which("ffmpeg"):
13269
+ # Never silently hand back a full-size frame when the caller asked for a
13270
+ # bounded one — max_width is usually a context-budget decision.
13271
+ return _err(
13272
+ "max_width on quality='full' needs ffmpeg to rescale, and ffmpeg is not on PATH",
13273
+ code="FFMPEG_REQUIRED", category="precondition",
13274
+ remediation="Install ffmpeg, drop max_width to get the full-resolution frame, or use quality='preview'.",
13275
+ )
13276
+ if fmt == "tif" and not shutil.which("ffmpeg"):
13277
+ return _err(
13278
+ "format='tif' needs ffmpeg to convert into displayable image content",
13279
+ code="FFMPEG_REQUIRED", category="precondition",
13280
+ remediation="Install ffmpeg, or use format='png' / 'jpg'.",
13281
+ )
13282
+
13283
+ gal = proj.GetGallery()
13284
+ if not gal:
13285
+ return _err("Gallery not available", code="NO_GALLERY", category="precondition")
13286
+ album = gal.GetCurrentStillAlbum()
13287
+ if not album:
13288
+ albums = gal.GetGalleryStillAlbums() or []
13289
+ album = albums[0] if albums else None
13290
+ if not album:
13291
+ return _err("No still album available", code="NO_GALLERY", category="precondition")
13292
+
13293
+ folder = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-playhead-frames"))
13294
+ os.makedirs(folder, exist_ok=True)
13295
+ prefix = f"playhead-{int(time.time() * 1000)}"
13296
+
13297
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
13298
+ original_tc, seek_err = _playhead_seek(tl, p)
13299
+ if seek_err:
13300
+ return seek_err
13301
+ still = None
13302
+ try:
13303
+ try:
13304
+ still = tl.GrabStill()
13305
+ except Exception as exc:
13306
+ return _err(f"GrabStill raised: {exc}", code="GRAB_STILL_FAILED", category="api_error")
13307
+ if not still:
13308
+ return _err(
13309
+ "GrabStill returned nothing."
13310
+ if on_color else
13311
+ "GrabStill returned nothing and Resolve could not be switched to the Color page.",
13312
+ code="GRAB_STILL_FAILED", category="precondition",
13313
+ remediation="Ensure the Color page is open with a video item under the playhead.",
13314
+ )
13315
+ before = set(os.listdir(folder))
13316
+ exported = False
13317
+ for attempt_fmt in (fmt, "tif", "png"):
13318
+ if album.ExportStills([still], folder, prefix, attempt_fmt):
13319
+ exported = True
13320
+ fmt = attempt_fmt
13321
+ break
13322
+ time.sleep(0.3)
13323
+ if not exported:
13324
+ return _err(
13325
+ "ExportStills failed",
13326
+ code="EXPORT_STILL_FAILED", category="api_error",
13327
+ remediation="Open the Gallery panel on the Color page (Workspace > Gallery) and retry.",
13328
+ )
13329
+ time.sleep(0.3)
13330
+ new_files = [f for f in sorted(set(os.listdir(folder)) - before) if not f.endswith(".drx")]
13331
+ if not new_files:
13332
+ return _err(
13333
+ "ExportStills reported success but wrote no image file",
13334
+ code="EXPORT_STILL_FAILED", category="api_error",
13335
+ state={"folder": folder, "format": fmt},
13336
+ )
13337
+ src_path = os.path.join(folder, new_files[0])
13338
+ out_format = "jpg" if fmt == "jpg" else "png"
13339
+ if max_width or fmt == "tif":
13340
+ data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
13341
+ if ff_err:
13342
+ return _err(
13343
+ f"Failed to rescale the exported still: {ff_err}",
13344
+ code="RESCALE_FAILED", category="api_error",
13345
+ )
13346
+ else:
13347
+ with open(src_path, "rb") as handle:
13348
+ data = handle.read()
13349
+ return Image(data=data, format=out_format)
13350
+ finally:
13351
+ # GrabStill puts a still in the user's gallery; a capture is a read,
13352
+ # so take it back out. Same for the files ExportStills wrote — the
13353
+ # bytes are already in the response.
13354
+ if still:
13355
+ try:
13356
+ album.DeleteStills([still])
13357
+ except Exception:
13358
+ pass
13359
+ if original_tc:
13360
+ try:
13361
+ tl.SetCurrentTimecode(original_tc)
13362
+ except Exception:
13363
+ pass
13364
+ try:
13365
+ for name in os.listdir(folder):
13366
+ if name.startswith(prefix):
13367
+ try:
13368
+ os.remove(os.path.join(folder, name))
13369
+ except OSError:
13370
+ pass
13371
+ if not os.listdir(folder):
13372
+ os.rmdir(folder)
13373
+ except OSError:
13374
+ pass
13375
+
13376
+
13377
+ # quality -> capture route. "frame" renders and is the only frame-accurate one,
13378
+ # so it is the default; the aliases exist because issue #146 proposed
13379
+ # preview/full, and both of those mean "the frame", just at different sizes.
13380
+ _PLAYHEAD_QUALITY_ALIASES = {
13381
+ "frame": "frame",
13382
+ "full": "frame",
13383
+ "preview": "frame",
13384
+ "thumbnail": "thumbnail",
13385
+ "still": "still",
13386
+ }
13387
+
13388
+
13389
+ def _playhead_frame_capture(p: Dict[str, Any]):
13390
+ """Dispatch a playhead capture, honouring an optional timeline_name."""
13391
+ requested = str(p.get("quality", "frame")).lower()
13392
+ quality = _PLAYHEAD_QUALITY_ALIASES.get(requested)
13393
+ if not quality:
13394
+ return _err(
13395
+ f"quality must be one of {sorted(_PLAYHEAD_QUALITY_ALIASES)}; got {requested!r}",
13396
+ code="INVALID_QUALITY", category="invalid_input",
13397
+ )
13398
+ # "preview" asked for a fast, downscaled version of the real frame; honour
13399
+ # the intent with a default bound rather than silently rendering full size.
13400
+ if requested == "preview" and not p.get("max_width", p.get("maxWidth")):
13401
+ p = dict(p)
13402
+ p["max_width"] = 1280
13403
+ proj, tl, err = _get_tl()
13404
+ if err:
13405
+ return err
13406
+
13407
+ # A non-current timeline has no playhead of its own, so capturing one means
13408
+ # making it current. Restore the caller's timeline afterwards.
13409
+ wanted = p.get("timeline_name", p.get("timelineName"))
13410
+ original_tl = None
13411
+ if wanted and (tl.GetName() or "") != wanted:
13412
+ target = None
13413
+ for idx in range(1, (proj.GetTimelineCount() or 0) + 1):
13414
+ candidate = proj.GetTimelineByIndex(idx)
13415
+ if candidate and candidate.GetName() == wanted:
13416
+ target = candidate
13417
+ break
13418
+ if not target:
13419
+ return _err(
13420
+ f"No timeline named {wanted!r} in this project",
13421
+ code="TIMELINE_NOT_FOUND", category="invalid_input",
13422
+ )
13423
+ original_tl, tl = tl, target
13424
+ if not proj.SetCurrentTimeline(target):
13425
+ return _err(
13426
+ f"Failed to make {wanted!r} the current timeline",
13427
+ code="SET_TIMELINE_FAILED", category="api_error",
13428
+ )
13429
+ try:
13430
+ if quality == "thumbnail":
13431
+ return _playhead_frame_preview(tl, p)
13432
+ if quality == "still":
13433
+ return _playhead_frame_full(proj, tl, p)
13434
+ return _playhead_frame_render(proj, tl, p)
13435
+ finally:
13436
+ if original_tl is not None:
13437
+ try:
13438
+ proj.SetCurrentTimeline(original_tl)
13439
+ except Exception:
13440
+ pass
13441
+
13442
+
12890
13443
  def _unknown(action, valid):
12891
13444
  return _err(f"Unknown action '{action}'. Valid actions: {', '.join(valid)}")
12892
13445
 
@@ -22464,22 +23017,27 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
22464
23017
  it = tl.GetCurrentVideoItem()
22465
23018
  return {"name": it.GetName(), "id": it.GetUniqueId()} if it else {"name": None, "id": None}
22466
23019
  elif action == "get_thumbnail":
22467
- thumbnail = tl.GetCurrentClipThumbnailImage()
23020
+ # GetCurrentClipThumbnailImage returns None on every page but Color, and
23021
+ # says nothing about why — hold the Color page for the read rather than
23022
+ # reporting a page problem as a missing thumbnail.
23023
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
23024
+ thumbnail = tl.GetCurrentClipThumbnailImage()
22468
23025
  if thumbnail is None:
22469
23026
  return {
22470
23027
  "success": False,
22471
23028
  "thumbnail": None,
22472
- "error": "Resolve did not return a thumbnail for the current playhead. Open the Color page and ensure a video item is under the playhead.",
23029
+ "error": (
23030
+ "Resolve did not return a thumbnail for the current playhead. Ensure a video item is under the playhead."
23031
+ if on_color else
23032
+ "Resolve did not return a thumbnail: GetCurrentClipThumbnailImage only works on the Color page and the automatic switch failed (headless, or the page is locked)."
23033
+ ),
22473
23034
  }
22474
23035
  return _ser(thumbnail)
22475
23036
  elif action == "get_thumbnail_image":
22476
- thumbnail = tl.GetCurrentClipThumbnailImage()
22477
- if not thumbnail:
22478
- return _err("No thumbnail available. Open the Color page with a current clip selected.")
22479
- try:
22480
- return Image(data=_thumbnail_data_to_png_bytes(thumbnail), format="png")
22481
- except ValueError as exc:
22482
- return _err(str(exc))
23037
+ # Same capture as timeline_frame(action="capture"), kept here for the
23038
+ # callers that already use it; that tool is the documented surface and
23039
+ # takes timecode/frame/max_width on top of this.
23040
+ return _playhead_frame_preview(tl, p)
22483
23041
  elif action == "annotation_capabilities":
22484
23042
  return _annotation_capabilities()
22485
23043
  elif action == "probe_annotations":
@@ -22502,7 +23060,102 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
22502
23060
 
22503
23061
 
22504
23062
  # ═══════════════════════════════════════════════════════════════════════════════
22505
- # TOOL 17: timeline_ai
23063
+ # TOOL 17: timeline_frame
23064
+ # ═══════════════════════════════════════════════════════════════════════════════
23065
+
23066
+ @mcp.tool()
23067
+ @_guard_missing_params
23068
+ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
23069
+ """See what Resolve is rendering — capture a timeline frame as a viewable image.
23070
+
23071
+ <when_to_use>
23072
+ - Verifying anything visual: title placement and safe area, framing, a grade,
23073
+ a Fusion comp, a transition, an artefact. Read the frame instead of
23074
+ inferring from metadata.
23075
+ - Confirming an edit landed where you meant it — capture at the cut timecode.
23076
+ - Before and after a change, at the same timecode, to show what moved.
23077
+ </when_to_use>
23078
+
23079
+ Captures Resolve's processed output — grade, Fusion, titles, transitions —
23080
+ not the source file. For the raw camera file use
23081
+ media_analysis(action="extract_frames").
23082
+
23083
+ Actions:
23084
+ capture(timecode?|frame?, quality?, max_width?, format?, timeline_name?) -> MCP image content
23085
+ capabilities() -> {quality_modes, ffmpeg, render_settings_restorable, ...}
23086
+
23087
+ capture parameters:
23088
+ timecode Absolute ('01:00:15:12') or elapsed ('00:00:15:12') timeline
23089
+ timecode. Omit to capture the current playhead.
23090
+ frame Alternative to timecode: absolute timeline frame number.
23091
+ quality 'frame' (default) renders exactly that frame — the only
23092
+ frame-accurate route, full resolution, ~1s, works headless.
23093
+ 'preview' is the same render bounded to max_width 1280.
23094
+ 'thumbnail' is instant and touches nothing, but returns the
23095
+ CLIP's thumbnail (see below). 'still' uses a Gallery still.
23096
+ max_width Cap the width in pixels to conserve context. Needs ffmpeg on
23097
+ the render path; without it the call fails rather than
23098
+ quietly returning a full-size frame.
23099
+ format jpg (default), png, or tif.
23100
+ timeline_name Capture from a different timeline; it is made current for
23101
+ the read and the original is restored afterwards.
23102
+
23103
+ Choosing a quality — the trade-off is accuracy against side effects:
23104
+
23105
+ 'frame'/'preview' Frame-exact. Renders one frame, so it changes
23106
+ project-level render settings. Format and codec are restored;
23107
+ TargetDir, CustomName and the mark range cannot be read back
23108
+ on builds without GetRenderSettings, so they are reset to the
23109
+ full timeline rather than truly restored. Refuses while
23110
+ another render is running.
23111
+ 'thumbnail' Changes nothing and returns instantly, but it is NOT frame
23112
+ accurate: GetCurrentClipThumbnailImage returns the clip's
23113
+ thumbnail, identical for every frame of that clip (measured
23114
+ on Studio 19.1.3.7). Use it to see which clip is under the
23115
+ playhead, never to judge a specific frame. It also needs the
23116
+ Color page AND Resolve frontmost, or it returns nothing.
23117
+ 'still' Full-resolution Gallery still. Requires the Gallery panel to
23118
+ be open on the Color page — no scripting call can open it,
23119
+ so this fails with a bare refusal when it is closed.
23120
+
23121
+ The playhead, the Color page, the current timeline and the Gallery are all
23122
+ restored; a capture is a read of the picture, not an edit of the cut.
23123
+ """
23124
+ p = _params(params)
23125
+ if action == "capture":
23126
+ return _playhead_frame_capture(p)
23127
+ elif action == "capabilities":
23128
+ resolve = get_resolve()
23129
+ try:
23130
+ current_page = resolve.GetCurrentPage() if resolve else None
23131
+ except Exception:
23132
+ current_page = None
23133
+ payload = {
23134
+ "quality_modes": ["frame", "preview", "thumbnail", "still"],
23135
+ "default_quality": "frame",
23136
+ "frame_accurate": {"frame": True, "preview": True, "thumbnail": False, "still": True},
23137
+ "formats": ["jpg", "png", "tif"],
23138
+ "ffmpeg": bool(shutil.which("ffmpeg")),
23139
+ "max_width_supported": bool(shutil.which("ffmpeg")),
23140
+ "current_page": current_page,
23141
+ }
23142
+ _, tl, err = _get_tl()
23143
+ if err:
23144
+ payload["timeline"] = None
23145
+ payload["playhead"] = None
23146
+ payload["note"] = "No current timeline — capture will fail until one is open."
23147
+ return payload
23148
+ payload["timeline"] = tl.GetName()
23149
+ try:
23150
+ payload["playhead"] = tl.GetCurrentTimecode()
23151
+ except Exception:
23152
+ payload["playhead"] = None
23153
+ return payload
23154
+ return _unknown(action, ["capture", "capabilities"])
23155
+
23156
+
23157
+ # ═══════════════════════════════════════════════════════════════════════════════
23158
+ # TOOL 18: timeline_ai
22506
23159
  # ═══════════════════════════════════════════════════════════════════════════════
22507
23160
 
22508
23161
  @mcp.tool()
@@ -28071,5 +28724,5 @@ if __name__ == "__main__":
28071
28724
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
28072
28725
  sys.exit(2)
28073
28726
 
28074
- logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
28727
+ logger.info("Starting DaVinci Resolve MCP Server (35 compound tools)")
28075
28728
  run_fastmcp_stdio(mcp)