davinci-resolve-mcp 2.95.2 → 2.96.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.
@@ -117,3 +117,59 @@ test('identityTimemap builds a [02][end,0,end,0,end] map', () => {
117
117
  assert.strictEqual(b.seconds.length, 5);
118
118
  assert.strictEqual(Math.round(b.seconds[0] * (30000 / 1001)), 4575);
119
119
  });
120
+
121
+ // --- Reverse maps: the origin is NOT always (0,0) ---------------------------
122
+ // Captured from a clip reversed via EDL M2 / OTIO negative time_scalar and
123
+ // exported by DaVinci Resolve Studio 21.0.4.5. Two things here used to break:
124
+ // 1. Each keyframe point omits whichever of recordSec/sourceSec is 0 —
125
+ // protobuf default-omission — so the old fixed-offset reader
126
+ // (readDoubleLE(1) / readDoubleLE(10)) threw "offset out of range".
127
+ // 2. The starting source offset is a TOP-LEVEL field 2 double. Assuming a
128
+ // (0,0) origin made a reverse decode as speed 0 — a plausible wrong
129
+ // number rather than an error, which is the worse failure.
130
+ const REVERSED_KEYFRAMES_BA = '800a09115655555555d517400a09095655555555d51740';
131
+
132
+ function reversedMapHex() {
133
+ const { encodeKeyedDict } = require('../keyed-dict');
134
+ const T_DOUBLE = 6; const T_STRING = 10; const T_BYTES = 12;
135
+ return encodeKeyedDict({
136
+ hdr: 1,
137
+ entries: [
138
+ { key: 'YMin', type: T_DOUBLE, subType: 0, value: -1 },
139
+ { key: 'YMax', type: T_DOUBLE, subType: 0, value: -1 },
140
+ { key: 'XMax', type: T_DOUBLE, subType: 0, value: 5.958333333333334 },
141
+ { key: 'UniqueId', type: T_STRING, subType: 0, value: '4dbe3e42-ab1e-4b93-8118-67fdc376c962' },
142
+ { key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: 5.958333333333333 },
143
+ { key: 'KeyframesBA', type: T_BYTES, subType: 0, value: REVERSED_KEYFRAMES_BA },
144
+ { key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
145
+ ],
146
+ }).toString('hex');
147
+ }
148
+
149
+ test('decodeTimemap: a reversed map decodes without throwing', () => {
150
+ assert.doesNotThrow(() => decodeTimemap(reversedMapHex()));
151
+ });
152
+
153
+ test('decodeTimemap: a reversed map reports NEGATIVE speed, not 0', () => {
154
+ const d = decodeTimemap(reversedMapHex());
155
+ assert.equal(d.segments.length, 1);
156
+ assert.equal(d.segments[0].speed, -1);
157
+ assert.ok(d.segments[0].speed < 0, 'reverse must not decode as speed 0');
158
+ });
159
+
160
+ test('_decodeKeyframePoint: a point may omit either value (protobuf default 0)', () => {
161
+ // sourceSec-only and recordSec-only points both appear in one real reversed map.
162
+ const d = decodeTimemap(reversedMapHex());
163
+ assert.equal(d.recordDurationSec, 5.958333333333334);
164
+ assert.equal(d.sourceDurationSec, 5.958333333333333);
165
+ });
166
+
167
+ test('forward maps are unaffected by the origin fix', () => {
168
+ const fwd = buildTimemap({
169
+ keyframes: [{ recordSec: 2, sourceSec: 1 }, { recordSec: 4, sourceSec: 5 }],
170
+ sourceDurationSec: 6, recordDurationSec: 4, uniqueId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
171
+ });
172
+ const d = decodeTimemap(fwd.toString('hex'));
173
+ assert.equal(d.variable, true);
174
+ assert.deepEqual(d.segments.map((s) => s.speed), [0.5, 2]);
175
+ });
@@ -41,17 +41,69 @@ function _isKeyedForm(b) {
41
41
  * (fixed64 LE doubles). The map starts at the implicit (0,0); constant speed has one keyframe,
42
42
  * a variable-speed ramp has one keyframe per added speed point.
43
43
  */
44
+ /**
45
+ * One keyframe point: an inner message of field 1 (recordSec) and field 2
46
+ * (sourceSec), both wire type 1 (64-bit double) — tags 0x09 and 0x11.
47
+ *
48
+ * Both are OPTIONAL. Protobuf omits a field whose value is the default (0), so
49
+ * a point can legitimately carry only one of the two, and fixed offsets do not
50
+ * work. This is not theoretical: a REVERSED clip exported by Resolve 21.0.4.5
51
+ * encodes its points as `0a 09 11 <double>` (sourceSec only) and
52
+ * `0a 09 09 <double>` (recordSec only), and the previous fixed-offset reader
53
+ * — `readDoubleLE(1)` / `readDoubleLE(10)` — threw
54
+ * "offset out of range … Received 10" on every reversed map. Forward maps only
55
+ * decoded because both values happened to be non-zero.
56
+ */
57
+ function _decodeKeyframePoint(buf) {
58
+ let recordSec = 0;
59
+ let sourceSec = 0;
60
+ let i = 0;
61
+ while (i < buf.length) {
62
+ const tag = buf[i];
63
+ if (tag === 0x09 && i + 9 <= buf.length) { recordSec = buf.readDoubleLE(i + 1); i += 9; }
64
+ else if (tag === 0x11 && i + 9 <= buf.length) { sourceSec = buf.readDoubleLE(i + 1); i += 9; }
65
+ else break; // unknown tag or truncated — stop rather than misread
66
+ }
67
+ return { recordSec, sourceSec };
68
+ }
69
+
70
+ /**
71
+ * Keyframe points plus the map's ORIGIN.
72
+ *
73
+ * The origin is not always (0,0). A reversed clip starts at the far end of the
74
+ * source and walks backwards, and Resolve encodes that starting source offset as
75
+ * a TOP-LEVEL field 2 double alongside the keyframe messages. Measured on a
76
+ * reversed clip exported by Studio 21.0.4.5:
77
+ *
78
+ * 80 0a 09 field 160 = 9
79
+ * 11 5655555555d51740 field 2 = 5.9583 <- origin sourceSec
80
+ * 0a 09 09 5655555555d51740 field 1 = { recordSec: 5.9583 }
81
+ *
82
+ * Reading that as an implicit (0,0) origin yields a segment from (0,0) to
83
+ * (5.9583, 0) — slope 0 — so a reverse decodes as "speed 0", a plausible-looking
84
+ * wrong answer rather than an error. With the origin it is (0, 5.9583) to
85
+ * (5.9583, 0): slope -1, a reverse.
86
+ */
44
87
  function _decodeKeyframes(hex) {
45
- if (hex == null) return [];
88
+ if (hex == null) return { origin: { recordSec: 0, sourceSec: 0 }, keyframes: [] };
46
89
  const fields = decodeProtobuf(hex);
47
- return fields
48
- .filter((f) => f.field === 1 && f.wire === 2)
49
- .map((f) => ({ recordSec: f.value.readDoubleLE(1), sourceSec: f.value.readDoubleLE(10) }));
90
+ const originField = fields.find((f) => f.field === 2 && f.wire === 1);
91
+ const originSource = originField
92
+ ? (Buffer.isBuffer(originField.value)
93
+ ? originField.value.readDoubleLE(0)
94
+ : Number(originField.value))
95
+ : 0;
96
+ return {
97
+ origin: { recordSec: 0, sourceSec: Number.isFinite(originSource) ? originSource : 0 },
98
+ keyframes: fields
99
+ .filter((f) => f.field === 1 && f.wire === 2)
100
+ .map((f) => _decodeKeyframePoint(f.value)),
101
+ };
50
102
  }
51
103
 
52
- /** Per-segment speeds from the keyframe points (slope Δsource/Δrecord); starts at (0,0). */
53
- function _segments(keyframes) {
54
- const pts = [{ recordSec: 0, sourceSec: 0 }, ...keyframes];
104
+ /** Per-segment speeds from the keyframe points (slope Δsource/Δrecord). */
105
+ function _segments(keyframes, origin = { recordSec: 0, sourceSec: 0 }) {
106
+ const pts = [origin, ...keyframes];
55
107
  const segs = [];
56
108
  for (let i = 1; i < pts.length; i++) {
57
109
  const dr = pts[i].recordSec - pts[i - 1].recordSec;
@@ -68,8 +120,8 @@ function decodeTimemap(input) {
68
120
  const get = (k) => { const e = entries.find((x) => x.key === k); return e ? e.value : undefined; };
69
121
  const recordDurationSec = get('XMax');
70
122
  const sourceDurationSec = get('LastValidYOffset');
71
- const keyframes = _decodeKeyframes(get('KeyframesBA'));
72
- const segments = _segments(keyframes);
123
+ const { origin, keyframes } = _decodeKeyframes(get('KeyframesBA'));
124
+ const segments = _segments(keyframes, origin);
73
125
  // The EXACT speed lives in the keyframe ratios (source/record per segment); XMax and
74
126
  // LastValidYOffset are frame-quantized. `speed` is the first segment's (whole clip if 1 kf);
75
127
  // `segments` carries the full variable-speed ramp.
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.95.2"
90
+ VERSION = "2.96.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
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.2"
14
+ VERSION = "2.96.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,322 @@ 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_frame_preview(tl, p: Dict[str, Any]):
13002
+ """Current frame via GetCurrentClipThumbnailImage, as MCP image content."""
13003
+ max_width = p.get("max_width", p.get("maxWidth"))
13004
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
13005
+ original_tc, seek_err = _playhead_seek(tl, p)
13006
+ if seek_err:
13007
+ return seek_err
13008
+ try:
13009
+ try:
13010
+ thumbnail = tl.GetCurrentClipThumbnailImage()
13011
+ except Exception as exc:
13012
+ return _err(f"GetCurrentClipThumbnailImage raised: {exc}", code="THUMBNAIL_FAILED", category="api_error")
13013
+ if not thumbnail:
13014
+ return _err(
13015
+ "Resolve returned no thumbnail for the current frame."
13016
+ if on_color else
13017
+ "Resolve returned no thumbnail: GetCurrentClipThumbnailImage only "
13018
+ "works on the Color page and the automatic switch failed (headless, "
13019
+ "or the page is locked).",
13020
+ code="NO_THUMBNAIL", category="precondition",
13021
+ remediation="Ensure a video item sits under the playhead and the Color page is reachable."
13022
+ if on_color else
13023
+ "Open the Color page in Resolve, or use quality='full' which does not depend on the thumbnail API.",
13024
+ )
13025
+ try:
13026
+ width, height, raw = _thumbnail_raw_rgb(thumbnail)
13027
+ except ValueError as exc:
13028
+ return _err(str(exc), code="THUMBNAIL_DECODE_FAILED", category="api_error")
13029
+ if max_width:
13030
+ width, height, raw = _box_downscale_rgb(width, height, raw, int(max_width))
13031
+ return Image(data=_rgb_to_png_bytes(width, height, raw), format="png")
13032
+ finally:
13033
+ if original_tc:
13034
+ try:
13035
+ tl.SetCurrentTimecode(original_tc)
13036
+ except Exception:
13037
+ pass
13038
+
13039
+
13040
+ def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
13041
+ """Current frame at full resolution via GrabStill + ExportStills."""
13042
+ fmt = str(p.get("format", "png")).lower().lstrip(".")
13043
+ if fmt == "jpeg":
13044
+ fmt = "jpg"
13045
+ if fmt not in _PLAYHEAD_STILL_FORMATS:
13046
+ return _err(
13047
+ f"format must be one of {sorted(_PLAYHEAD_STILL_FORMATS)} for an image response; got {fmt!r}",
13048
+ code="INVALID_FORMAT", category="invalid_input",
13049
+ remediation="Use gallery_stills(action='grab_and_export') for dpx/cin/drx and other non-displayable formats.",
13050
+ )
13051
+ max_width = p.get("max_width", p.get("maxWidth"))
13052
+ if max_width and not shutil.which("ffmpeg"):
13053
+ # Never silently hand back a full-size frame when the caller asked for a
13054
+ # bounded one — max_width is usually a context-budget decision.
13055
+ return _err(
13056
+ "max_width on quality='full' needs ffmpeg to rescale, and ffmpeg is not on PATH",
13057
+ code="FFMPEG_REQUIRED", category="precondition",
13058
+ remediation="Install ffmpeg, drop max_width to get the full-resolution frame, or use quality='preview'.",
13059
+ )
13060
+ if fmt == "tif" and not shutil.which("ffmpeg"):
13061
+ return _err(
13062
+ "format='tif' needs ffmpeg to convert into displayable image content",
13063
+ code="FFMPEG_REQUIRED", category="precondition",
13064
+ remediation="Install ffmpeg, or use format='png' / 'jpg'.",
13065
+ )
13066
+
13067
+ gal = proj.GetGallery()
13068
+ if not gal:
13069
+ return _err("Gallery not available", code="NO_GALLERY", category="precondition")
13070
+ album = gal.GetCurrentStillAlbum()
13071
+ if not album:
13072
+ albums = gal.GetGalleryStillAlbums() or []
13073
+ album = albums[0] if albums else None
13074
+ if not album:
13075
+ return _err("No still album available", code="NO_GALLERY", category="precondition")
13076
+
13077
+ folder = _resolve_safe_dir(os.path.join(tempfile.gettempdir(), "resolve-playhead-frames"))
13078
+ os.makedirs(folder, exist_ok=True)
13079
+ prefix = f"playhead-{int(time.time() * 1000)}"
13080
+
13081
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
13082
+ original_tc, seek_err = _playhead_seek(tl, p)
13083
+ if seek_err:
13084
+ return seek_err
13085
+ still = None
13086
+ try:
13087
+ try:
13088
+ still = tl.GrabStill()
13089
+ except Exception as exc:
13090
+ return _err(f"GrabStill raised: {exc}", code="GRAB_STILL_FAILED", category="api_error")
13091
+ if not still:
13092
+ return _err(
13093
+ "GrabStill returned nothing."
13094
+ if on_color else
13095
+ "GrabStill returned nothing and Resolve could not be switched to the Color page.",
13096
+ code="GRAB_STILL_FAILED", category="precondition",
13097
+ remediation="Ensure the Color page is open with a video item under the playhead.",
13098
+ )
13099
+ before = set(os.listdir(folder))
13100
+ exported = False
13101
+ for attempt_fmt in (fmt, "tif", "png"):
13102
+ if album.ExportStills([still], folder, prefix, attempt_fmt):
13103
+ exported = True
13104
+ fmt = attempt_fmt
13105
+ break
13106
+ time.sleep(0.3)
13107
+ if not exported:
13108
+ return _err(
13109
+ "ExportStills failed",
13110
+ code="EXPORT_STILL_FAILED", category="api_error",
13111
+ remediation="Open the Gallery panel on the Color page (Workspace > Gallery) and retry.",
13112
+ )
13113
+ time.sleep(0.3)
13114
+ new_files = [f for f in sorted(set(os.listdir(folder)) - before) if not f.endswith(".drx")]
13115
+ if not new_files:
13116
+ return _err(
13117
+ "ExportStills reported success but wrote no image file",
13118
+ code="EXPORT_STILL_FAILED", category="api_error",
13119
+ state={"folder": folder, "format": fmt},
13120
+ )
13121
+ src_path = os.path.join(folder, new_files[0])
13122
+ out_format = "jpg" if fmt == "jpg" else "png"
13123
+ if max_width or fmt == "tif":
13124
+ data, ff_err = _ffmpeg_scale_to_bytes(src_path, int(max_width) if max_width else None, out_format)
13125
+ if ff_err:
13126
+ return _err(
13127
+ f"Failed to rescale the exported still: {ff_err}",
13128
+ code="RESCALE_FAILED", category="api_error",
13129
+ )
13130
+ else:
13131
+ with open(src_path, "rb") as handle:
13132
+ data = handle.read()
13133
+ return Image(data=data, format=out_format)
13134
+ finally:
13135
+ # GrabStill puts a still in the user's gallery; a capture is a read,
13136
+ # so take it back out. Same for the files ExportStills wrote — the
13137
+ # bytes are already in the response.
13138
+ if still:
13139
+ try:
13140
+ album.DeleteStills([still])
13141
+ except Exception:
13142
+ pass
13143
+ if original_tc:
13144
+ try:
13145
+ tl.SetCurrentTimecode(original_tc)
13146
+ except Exception:
13147
+ pass
13148
+ try:
13149
+ for name in os.listdir(folder):
13150
+ if name.startswith(prefix):
13151
+ try:
13152
+ os.remove(os.path.join(folder, name))
13153
+ except OSError:
13154
+ pass
13155
+ if not os.listdir(folder):
13156
+ os.rmdir(folder)
13157
+ except OSError:
13158
+ pass
13159
+
13160
+
13161
+ def _playhead_frame_capture(p: Dict[str, Any]):
13162
+ """Dispatch a playhead capture, honouring an optional timeline_name."""
13163
+ quality = str(p.get("quality", "preview")).lower()
13164
+ if quality not in ("preview", "full"):
13165
+ return _err(
13166
+ f"quality must be 'preview' or 'full'; got {quality!r}",
13167
+ code="INVALID_QUALITY", category="invalid_input",
13168
+ )
13169
+ proj, tl, err = _get_tl()
13170
+ if err:
13171
+ return err
13172
+
13173
+ # A non-current timeline has no playhead of its own, so capturing one means
13174
+ # making it current. Restore the caller's timeline afterwards.
13175
+ wanted = p.get("timeline_name", p.get("timelineName"))
13176
+ original_tl = None
13177
+ if wanted and (tl.GetName() or "") != wanted:
13178
+ target = None
13179
+ for idx in range(1, (proj.GetTimelineCount() or 0) + 1):
13180
+ candidate = proj.GetTimelineByIndex(idx)
13181
+ if candidate and candidate.GetName() == wanted:
13182
+ target = candidate
13183
+ break
13184
+ if not target:
13185
+ return _err(
13186
+ f"No timeline named {wanted!r} in this project",
13187
+ code="TIMELINE_NOT_FOUND", category="invalid_input",
13188
+ )
13189
+ original_tl, tl = tl, target
13190
+ if not proj.SetCurrentTimeline(target):
13191
+ return _err(
13192
+ f"Failed to make {wanted!r} the current timeline",
13193
+ code="SET_TIMELINE_FAILED", category="api_error",
13194
+ )
13195
+ try:
13196
+ if quality == "full":
13197
+ return _playhead_frame_full(proj, tl, p)
13198
+ return _playhead_frame_preview(tl, p)
13199
+ finally:
13200
+ if original_tl is not None:
13201
+ try:
13202
+ proj.SetCurrentTimeline(original_tl)
13203
+ except Exception:
13204
+ pass
13205
+
13206
+
12890
13207
  def _unknown(action, valid):
12891
13208
  return _err(f"Unknown action '{action}'. Valid actions: {', '.join(valid)}")
12892
13209
 
@@ -22464,22 +22781,27 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
22464
22781
  it = tl.GetCurrentVideoItem()
22465
22782
  return {"name": it.GetName(), "id": it.GetUniqueId()} if it else {"name": None, "id": None}
22466
22783
  elif action == "get_thumbnail":
22467
- thumbnail = tl.GetCurrentClipThumbnailImage()
22784
+ # GetCurrentClipThumbnailImage returns None on every page but Color, and
22785
+ # says nothing about why — hold the Color page for the read rather than
22786
+ # reporting a page problem as a missing thumbnail.
22787
+ with _color_page_for_thumbnails(get_resolve()) as on_color:
22788
+ thumbnail = tl.GetCurrentClipThumbnailImage()
22468
22789
  if thumbnail is None:
22469
22790
  return {
22470
22791
  "success": False,
22471
22792
  "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.",
22793
+ "error": (
22794
+ "Resolve did not return a thumbnail for the current playhead. Ensure a video item is under the playhead."
22795
+ if on_color else
22796
+ "Resolve did not return a thumbnail: GetCurrentClipThumbnailImage only works on the Color page and the automatic switch failed (headless, or the page is locked)."
22797
+ ),
22473
22798
  }
22474
22799
  return _ser(thumbnail)
22475
22800
  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))
22801
+ # Same capture as timeline_frame(action="capture"), kept here for the
22802
+ # callers that already use it; that tool is the documented surface and
22803
+ # takes timecode/frame/max_width on top of this.
22804
+ return _playhead_frame_preview(tl, p)
22483
22805
  elif action == "annotation_capabilities":
22484
22806
  return _annotation_capabilities()
22485
22807
  elif action == "probe_annotations":
@@ -22502,7 +22824,82 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
22502
22824
 
22503
22825
 
22504
22826
  # ═══════════════════════════════════════════════════════════════════════════════
22505
- # TOOL 17: timeline_ai
22827
+ # TOOL 17: timeline_frame
22828
+ # ═══════════════════════════════════════════════════════════════════════════════
22829
+
22830
+ @mcp.tool()
22831
+ @_guard_missing_params
22832
+ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
22833
+ """See what Resolve is rendering — capture a timeline frame as a viewable image.
22834
+
22835
+ <when_to_use>
22836
+ - Verifying anything visual: title placement and safe area, framing, a grade,
22837
+ a Fusion comp, a transition, an artefact. Read the frame instead of
22838
+ inferring from metadata.
22839
+ - Confirming an edit landed where you meant it — capture at the cut timecode.
22840
+ - Before and after a change, at the same timecode, to show what moved.
22841
+ </when_to_use>
22842
+
22843
+ Captures Resolve's processed output — grade, Fusion, titles, transitions —
22844
+ not the source file. For the raw camera file use
22845
+ media_analysis(action="extract_frames").
22846
+
22847
+ Actions:
22848
+ capture(timecode?|frame?, quality?, max_width?, format?, timeline_name?) -> MCP image content
22849
+ capabilities() -> {quality_modes, ffmpeg, current_page, playhead, timeline}
22850
+
22851
+ capture parameters:
22852
+ timecode Absolute ('01:00:15:12') or elapsed ('00:00:15:12') timeline
22853
+ timecode. Omit to capture the current playhead.
22854
+ frame Alternative to timecode: absolute timeline frame number.
22855
+ quality 'preview' (default) — Resolve's thumbnail; fast, small, no
22856
+ files written. 'full' — full-resolution via Gallery still.
22857
+ max_width Cap the width in pixels to conserve context. 'full' needs
22858
+ ffmpeg to rescale; without it the call fails rather than
22859
+ quietly returning a full-size frame.
22860
+ format 'full' only: png (default), jpg, or tif.
22861
+ timeline_name Capture from a different timeline; it is made current for
22862
+ the read and the original is restored afterwards.
22863
+
22864
+ Both qualities need the Color page (GetCurrentClipThumbnailImage and
22865
+ GrabStill are Color-page-only); the tool switches there and restores the
22866
+ user's page. A named timecode is restored to the original playhead position
22867
+ afterwards, and 'full' removes the still it grabbed from the Gallery — a
22868
+ capture is a read, not an edit.
22869
+ """
22870
+ p = _params(params)
22871
+ if action == "capture":
22872
+ return _playhead_frame_capture(p)
22873
+ elif action == "capabilities":
22874
+ resolve = get_resolve()
22875
+ try:
22876
+ current_page = resolve.GetCurrentPage() if resolve else None
22877
+ except Exception:
22878
+ current_page = None
22879
+ payload = {
22880
+ "quality_modes": ["preview", "full"],
22881
+ "formats": {"preview": ["png"], "full": sorted(_PLAYHEAD_STILL_FORMATS)},
22882
+ "ffmpeg": bool(shutil.which("ffmpeg")),
22883
+ "max_width_supported": {"preview": True, "full": bool(shutil.which("ffmpeg"))},
22884
+ "current_page": current_page,
22885
+ }
22886
+ _, tl, err = _get_tl()
22887
+ if err:
22888
+ payload["timeline"] = None
22889
+ payload["playhead"] = None
22890
+ payload["note"] = "No current timeline — capture will fail until one is open."
22891
+ return payload
22892
+ payload["timeline"] = tl.GetName()
22893
+ try:
22894
+ payload["playhead"] = tl.GetCurrentTimecode()
22895
+ except Exception:
22896
+ payload["playhead"] = None
22897
+ return payload
22898
+ return _unknown(action, ["capture", "capabilities"])
22899
+
22900
+
22901
+ # ═══════════════════════════════════════════════════════════════════════════════
22902
+ # TOOL 18: timeline_ai
22506
22903
  # ═══════════════════════════════════════════════════════════════════════════════
22507
22904
 
22508
22905
  @mcp.tool()
@@ -28071,5 +28468,5 @@ if __name__ == "__main__":
28071
28468
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
28072
28469
  sys.exit(2)
28073
28470
 
28074
- logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
28471
+ logger.info("Starting DaVinci Resolve MCP Server (35 compound tools)")
28075
28472
  run_fastmcp_stdio(mcp)