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/CHANGELOG.md +140 -0
- package/README.md +5 -5
- package/README.zh-CN.md +6 -6
- package/docs/SKILL.md +85 -17
- package/docs/contributing.md +1 -1
- package/docs/install.md +17 -1
- package/docs/reference/api-coverage.md +2 -2
- package/docs/reference/api-limitations.md +17 -1
- package/install.py +383 -4
- package/package.json +1 -1
- package/resolve-advanced/server/aaf_probe.py +117 -14
- package/resolve-advanced/server/tools/project_read.mjs +35 -2
- package/src/granular/common.py +1 -1
- package/src/server.py +668 -15
- package/src/utils/api_truth.py +49 -0
package/src/server.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
DaVinci Resolve MCP Server (Compound Tools)
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
22477
|
-
|
|
22478
|
-
|
|
22479
|
-
|
|
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:
|
|
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 (
|
|
28727
|
+
logger.info("Starting DaVinci Resolve MCP Server (35 compound tools)")
|
|
28075
28728
|
run_fastmcp_stdio(mcp)
|