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.
- package/CHANGELOG.md +123 -0
- package/README.md +5 -5
- package/README.zh-CN.md +6 -6
- package/docs/SKILL.md +58 -8
- 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 +1 -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/resolve-advanced/vendor/drp-format/__tests__/media-timemap.test.js +56 -0
- package/resolve-advanced/vendor/drp-format/media-timemap.js +61 -9
- package/src/granular/common.py +1 -1
- package/src/server.py +412 -15
- package/src/utils/api_truth.py +33 -3
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
|
53
|
-
function _segments(keyframes) {
|
|
54
|
-
const pts = [
|
|
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.
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "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
|
-
|
|
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.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
|
|
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,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
|
-
|
|
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":
|
|
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
|
-
|
|
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))
|
|
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:
|
|
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 (
|
|
28471
|
+
logger.info("Starting DaVinci Resolve MCP Server (35 compound tools)")
|
|
28075
28472
|
run_fastmcp_stdio(mcp)
|