davinci-resolve-mcp 2.96.0 → 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 +62 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +50 -32
- package/docs/reference/api-limitations.md +17 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +283 -27
- package/src/utils/api_truth.py +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.97.0
|
|
6
|
+
|
|
7
|
+
**`timeline_frame(action="capture")` now renders the frame.** v2.96.0 shipped it
|
|
8
|
+
reading Resolve's thumbnail API; live validation on Studio 19.1.3.7 showed that
|
|
9
|
+
API cannot do the job, so the default route changed. Callers using `preview` or
|
|
10
|
+
`full` keep working and now get a frame-accurate image.
|
|
11
|
+
|
|
12
|
+
### Measured — the thumbnail API is per-CLIP, not per-frame
|
|
13
|
+
|
|
14
|
+
`GetCurrentClipThumbnailImage` returns the same image for every frame of a clip.
|
|
15
|
+
Seeking to 00:00, 01:00, 02:00 and 04:00 within one clip returned
|
|
16
|
+
**byte-identical data every time**; the image changed only when the playhead
|
|
17
|
+
crossed a clip boundary. Two further conditions make it fail silently:
|
|
18
|
+
|
|
19
|
+
- It returns `None` whenever Resolve is **not the frontmost application**, at any
|
|
20
|
+
delay, even on the Color page with a clip under the playhead.
|
|
21
|
+
- The first read after a page switch or a playhead move can be empty while the
|
|
22
|
+
viewer catches up, so a single failed read proves nothing.
|
|
23
|
+
|
|
24
|
+
None of this is distinguishable from "no frame here."
|
|
25
|
+
|
|
26
|
+
### Measured — `ExportStills` needs the Gallery panel open
|
|
27
|
+
|
|
28
|
+
It returns a bare `False`, writing nothing, unless the Gallery panel is visible
|
|
29
|
+
on the Color page — across png/jpg/tif/dpx, three destination folders, settle
|
|
30
|
+
delays of 0.5s/1.5s/3.0s, with Resolve frontmost. `GrabStill` succeeds; only the
|
|
31
|
+
export fails. No scripting call can open that panel.
|
|
32
|
+
|
|
33
|
+
### So `capture` renders one frame
|
|
34
|
+
|
|
35
|
+
`MarkIn == MarkOut` on a still-image render. Frame-exact, full resolution, well
|
|
36
|
+
under a second, and it needs no GUI panel and no foreground window. Verified
|
|
37
|
+
live: three timecodes produced three different images, and the rendered burn-in
|
|
38
|
+
matched the requested frame.
|
|
39
|
+
|
|
40
|
+
`quality` is now `frame` (default), `preview` (same render bounded to 1280px),
|
|
41
|
+
`thumbnail` (the instant per-clip image), and `still` (Gallery still). `full` and
|
|
42
|
+
`preview` from the original issue schema both map to the render.
|
|
43
|
+
|
|
44
|
+
**The cost, stated rather than hidden:** render settings are project-level.
|
|
45
|
+
Format and codec are snapshotted and restored, the render job is deleted, and
|
|
46
|
+
the Deliver page and playhead that rendering pulls Resolve onto are put back —
|
|
47
|
+
but `TargetDir`, `CustomName` and the mark range cannot be read back on builds
|
|
48
|
+
without `GetRenderSettings`, so they are reset to the full timeline rather than
|
|
49
|
+
truly restored. `quality="thumbnail"` remains for callers who need a strictly
|
|
50
|
+
side-effect-free read and can live with per-clip granularity. A capture also
|
|
51
|
+
refuses while another render is running.
|
|
52
|
+
|
|
53
|
+
### Corrected — docs that claimed per-frame accuracy
|
|
54
|
+
|
|
55
|
+
`docs/SKILL.md` said `get_thumbnail` "reflects the current frame as rendered by
|
|
56
|
+
Resolve." It reflects the clip. `timeline(action="thumbnail_contact_sheet")`
|
|
57
|
+
samples the same API, so it is a shot inventory, not frame evidence; both are
|
|
58
|
+
now described accurately. Both API findings are recorded in `api_truth` and
|
|
59
|
+
regenerated into `docs/reference/api-limitations.md`.
|
|
60
|
+
|
|
61
|
+
### Fixed — thumbnail reads poll instead of trusting one call
|
|
62
|
+
|
|
63
|
+
`timeline_markers(action="get_thumbnail")` and `get_thumbnail_image` now hold the
|
|
64
|
+
Color page, poll for the viewer to catch up, and name the foreground requirement
|
|
65
|
+
when the read stays empty.
|
|
66
|
+
|
|
5
67
|
## What's New in v2.96.0
|
|
6
68
|
|
|
7
69
|
New tool **`timeline_frame`** — the assistant can look at what Resolve is
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.97.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/docs/SKILL.md
CHANGED
|
@@ -1393,10 +1393,12 @@ Key actions:
|
|
|
1393
1393
|
crop/composite/audio/property groups to many timeline items in one call
|
|
1394
1394
|
- `apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?)` — apply a
|
|
1395
1395
|
normalized CDL and/or copy a source grade to multiple video items
|
|
1396
|
-
- `thumbnail_contact_sheet` / `marker_thumbnail_review` — sample Resolve
|
|
1397
|
-
thumbnails under the project analysis root
|
|
1398
|
-
|
|
1399
|
-
|
|
1396
|
+
- `thumbnail_contact_sheet` / `marker_thumbnail_review` — sample Resolve
|
|
1397
|
+
thumbnails under the project analysis root. These are CLIP thumbnails, so the
|
|
1398
|
+
sheet is effectively one image per clip, not per sampled frame — a shot
|
|
1399
|
+
inventory rather than frame evidence. Resolve only serves them on the Color
|
|
1400
|
+
page and only while it is frontmost; the tool switches page automatically and
|
|
1401
|
+
restores the previous one. Expect a page flash in the GUI,
|
|
1400
1402
|
and note that landing on Color can kick off cache/render work for the current
|
|
1401
1403
|
clip — on a large timeline the switch is not free
|
|
1402
1404
|
- `edit_kernel_capabilities` — report supported, partially supported, and
|
|
@@ -1486,15 +1488,18 @@ The compound tool accepts `frame`, `frame_id`, and `frameId` aliases.
|
|
|
1486
1488
|
|
|
1487
1489
|
Note: `get_thumbnail` returns raw pixel data from `GetCurrentClipThumbnailImage()`.
|
|
1488
1490
|
The dictionary includes `data` (raw bytes as a Python bytes-like object),
|
|
1489
|
-
`format`, `width`, `height`, `noOfComponents`, and `depth`. This reflects
|
|
1490
|
-
|
|
1491
|
-
|
|
1491
|
+
`format`, `width`, `height`, `noOfComponents`, and `depth`. This reflects Resolve's processed
|
|
1492
|
+
output — including color grading and effects — rather than the source file. It
|
|
1493
|
+
is the CLIP's thumbnail, though: every frame of a clip returns the same image, so
|
|
1494
|
+
it cannot verify a specific frame. Use `timeline_frame(action="capture")` for
|
|
1495
|
+
that.
|
|
1492
1496
|
|
|
1493
1497
|
Use `get_thumbnail_image` when the MCP client can display image content directly.
|
|
1494
1498
|
It converts the same Resolve thumbnail payload to PNG bytes without writing a
|
|
1495
|
-
file to disk. Both actions hold the Color page for the read
|
|
1496
|
-
previous page
|
|
1497
|
-
|
|
1499
|
+
file to disk. Both actions hold the Color page for the read, restore the
|
|
1500
|
+
previous page, and poll rather than trusting a single read; both still need
|
|
1501
|
+
Resolve to be the frontmost application. Prefer `timeline_frame(action="capture")`
|
|
1502
|
+
for new work — it renders the frame you actually asked for.
|
|
1498
1503
|
|
|
1499
1504
|
**`timeline_frame`** — Capture a timeline frame as viewable image content.
|
|
1500
1505
|
|
|
@@ -1506,17 +1511,26 @@ rendering — grade, Fusion, titles, transitions — rather than inferring it fr
|
|
|
1506
1511
|
metadata. (For the raw camera file instead, use
|
|
1507
1512
|
`media_analysis(action="extract_frames")`.)
|
|
1508
1513
|
|
|
1509
|
-
- `quality="
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1514
|
+
- `quality="frame"` (default) renders exactly that frame — the only
|
|
1515
|
+
frame-accurate route. Full resolution, well under a second, works headless.
|
|
1516
|
+
`preview` is the same render bounded to 1280px.
|
|
1517
|
+
- `quality="thumbnail"` is instant and touches nothing, but returns the **clip's**
|
|
1518
|
+
thumbnail — identical for every frame of that clip. Use it to see which clip is
|
|
1519
|
+
under the playhead, never to judge a specific frame. Needs the Color page *and*
|
|
1520
|
+
Resolve frontmost.
|
|
1521
|
+
- `quality="still"` uses a Gallery still; requires the Gallery panel to be open.
|
|
1522
|
+
- `max_width` caps the width to conserve context (needs ffmpeg; without it the
|
|
1523
|
+
call fails rather than quietly returning a full-size frame). `format` is `jpg`
|
|
1524
|
+
(default), `png`, or `tif`.
|
|
1515
1525
|
- `timecode` accepts absolute (`01:00:15:12`) or elapsed (`00:00:15:12`) time;
|
|
1516
1526
|
`frame` is the absolute timeline frame. Omit both to capture the playhead.
|
|
1517
1527
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1528
|
+
The playhead, page, current timeline and Gallery are restored. The render route
|
|
1529
|
+
additionally touches project render settings: format and codec are restored and
|
|
1530
|
+
the render job is deleted, but `TargetDir`/`CustomName`/mark range cannot be read
|
|
1531
|
+
back on builds without `GetRenderSettings`, so they are reset to the full
|
|
1532
|
+
timeline rather than restored. Reach for `quality="thumbnail"` when zero side
|
|
1533
|
+
effects matter more than accuracy.
|
|
1520
1534
|
|
|
1521
1535
|
```
|
|
1522
1536
|
timeline_frame(action="capture", params={"timecode": "01:00:15:12", "max_width": 1280})
|
|
@@ -2084,27 +2098,31 @@ file.
|
|
|
2084
2098
|
|
|
2085
2099
|
**Start here: `timeline_frame(action="capture")`** — Returns the frame at the
|
|
2086
2100
|
playhead (or at any `timecode`/`frame` you name) as MCP image content, so a
|
|
2087
|
-
multimodal assistant can simply look at it.
|
|
2088
|
-
|
|
2089
|
-
context cost. The Color page, playhead, current timeline, and Gallery are all
|
|
2090
|
-
restored afterwards.
|
|
2101
|
+
multimodal assistant can simply look at it. It renders that one frame, which is
|
|
2102
|
+
what makes it frame-accurate; `max_width` bounds the context cost.
|
|
2091
2103
|
|
|
2092
2104
|
```
|
|
2093
2105
|
timeline_frame(action="capture", params={"timecode": "01:00:15:12", "max_width": 1280})
|
|
2094
2106
|
```
|
|
2095
2107
|
|
|
2096
|
-
|
|
2097
|
-
the
|
|
2098
|
-
|
|
2099
|
-
|
|
2108
|
+
⚠️ **The thumbnail API is per-clip, not per-frame.** `GetCurrentClipThumbnailImage`
|
|
2109
|
+
returns the same image for every frame of a given clip — verified by seeking
|
|
2110
|
+
within one clip and getting byte-identical data, with the image changing only at
|
|
2111
|
+
a clip boundary. It also returns nothing unless Resolve is the frontmost app.
|
|
2112
|
+
Everything below is built on it, so none of it can confirm what a *specific*
|
|
2113
|
+
frame looks like. Use `timeline_frame` for that.
|
|
2100
2114
|
|
|
2101
|
-
**`timeline_markers(action="
|
|
2102
|
-
|
|
2103
|
-
|
|
2115
|
+
**`timeline_markers(action="get_thumbnail")`** — Raw thumbnail data for the clip
|
|
2116
|
+
under the playhead: `data`, `format`, `width`, `height`, `noOfComponents`,
|
|
2117
|
+
`depth`. Use it when you need pixel data for tooling.
|
|
2104
2118
|
|
|
2105
|
-
**`
|
|
2106
|
-
|
|
2107
|
-
|
|
2119
|
+
**`timeline_markers(action="get_thumbnail_image")`** — The same clip thumbnail as
|
|
2120
|
+
image content; equivalent to `timeline_frame(action="capture", params={"quality":
|
|
2121
|
+
"thumbnail"})`. Kept for existing callers.
|
|
2122
|
+
|
|
2123
|
+
**`timeline(action="thumbnail_contact_sheet")`** — A labeled PNG sheet written to
|
|
2124
|
+
the analysis root. Because it samples the same API, it is effectively one image
|
|
2125
|
+
per clip; treat it as a shot inventory, not as frame evidence.
|
|
2108
2126
|
|
|
2109
2127
|
**`gallery_stills(action="grab_and_export", params={...})`** — Grabs a still from
|
|
2110
2128
|
the current frame on the Color page and returns the image encoded as base64 in the
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:** 27 missing capabilities,
|
|
15
|
+
**Totals:** 27 missing capabilities, 35 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -360,6 +360,22 @@ values, or automation-hostile modal prompts.
|
|
|
360
360
|
- **Workaround / current handling:** Use comp.CurrentFrame.FlowView.SetPos(tool, x, y); confirm with GetPosTable and a liberal position parser.
|
|
361
361
|
- **Tags:** fusion, unreliable-return
|
|
362
362
|
|
|
363
|
+
### Timeline.GetCurrentClipThumbnailImage (foreground only)
|
|
364
|
+
|
|
365
|
+
- **Object:** `Timeline`
|
|
366
|
+
- **Signature:** `() -> {width, height, format, data} | None`
|
|
367
|
+
- **Behavior:** Being on the Color page is necessary but NOT sufficient: the call also returns None whenever Resolve is not the frontmost application. Measured on Studio 19.1.3.7 — with the Color page open, a clip under the playhead, and GetCurrentVideoItem returning that clip, every read came back None while Resolve sat behind a terminal window, at delays from 0 to 2 seconds; bringing Resolve to the front made the very next read return a 288x162 'RGB 8 bit' thumbnail. Separately, the first read after a page switch or a playhead move can be None while the viewer catches up, so a single failed read proves nothing. The two failure modes are indistinguishable from 'no thumbnail exists' and from each other.
|
|
368
|
+
- **Workaround / current handling:** Poll the read a few times before concluding it failed (src/server.py:_playhead_thumbnail_settled), and when it stays empty, name the foreground requirement in the error rather than reporting a missing frame. Headless callers cannot use this API at all; there is no scripting call to raise Resolve, so a background agent must fall back to a Gallery still or a render.
|
|
369
|
+
- **Tags:** timeline, thumbnail, silent-failure, focus-dependent
|
|
370
|
+
|
|
371
|
+
### GalleryStillAlbum.ExportStills (Gallery panel must be visible)
|
|
372
|
+
|
|
373
|
+
- **Object:** `GalleryStillAlbum`
|
|
374
|
+
- **Signature:** `(stills, folder, prefix, format) -> bool`
|
|
375
|
+
- **Behavior:** Returns a bare False, writing nothing, unless the Gallery panel is actually open on the Color page. Measured on Studio 19.1.3.7: Timeline.GrabStill() succeeded and the still landed in the album (count 0 -> 1), yet ExportStills returned False for png, jpg, tif and dpx alike, into three different destination folders, at settle delays of 0.5s, 1.5s and 3.0s, and with Resolve frontmost. Panel visibility is not readable or settable from the scripting API, so a script cannot establish the precondition it depends on, nor distinguish this from a permissions or format failure.
|
|
376
|
+
- **Workaround / current handling:** Treat False as 'the Gallery panel is probably closed' and say so in the error; ask the user to open Workspace > Gallery on the Color page. Do not retry formats hoping one sticks — when the panel is closed they all fail.
|
|
377
|
+
- **Tags:** gallery, stills, silent-failure, ui-dependent
|
|
378
|
+
|
|
363
379
|
### MediaPoolItem.GetClipProperty('Transcription')
|
|
364
380
|
|
|
365
381
|
- **Object:** `MediaPoolItem`
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.97.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
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.97.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
|
@@ -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
|
|
@@ -12998,6 +12998,31 @@ def _playhead_seek(tl, p: Dict[str, Any]) -> Tuple[Optional[str], Optional[Dict[
|
|
|
12998
12998
|
return original, None
|
|
12999
12999
|
|
|
13000
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
|
+
|
|
13001
13026
|
def _playhead_frame_preview(tl, p: Dict[str, Any]):
|
|
13002
13027
|
"""Current frame via GetCurrentClipThumbnailImage, as MCP image content."""
|
|
13003
13028
|
max_width = p.get("max_width", p.get("maxWidth"))
|
|
@@ -13006,10 +13031,9 @@ def _playhead_frame_preview(tl, p: Dict[str, Any]):
|
|
|
13006
13031
|
if seek_err:
|
|
13007
13032
|
return seek_err
|
|
13008
13033
|
try:
|
|
13009
|
-
|
|
13010
|
-
|
|
13011
|
-
|
|
13012
|
-
return _err(f"GetCurrentClipThumbnailImage raised: {exc}", code="THUMBNAIL_FAILED", category="api_error")
|
|
13034
|
+
thumbnail, thumb_err = _playhead_thumbnail_settled(tl)
|
|
13035
|
+
if thumb_err:
|
|
13036
|
+
return thumb_err
|
|
13013
13037
|
if not thumbnail:
|
|
13014
13038
|
return _err(
|
|
13015
13039
|
"Resolve returned no thumbnail for the current frame."
|
|
@@ -13018,9 +13042,13 @@ def _playhead_frame_preview(tl, p: Dict[str, Any]):
|
|
|
13018
13042
|
"works on the Color page and the automatic switch failed (headless, "
|
|
13019
13043
|
"or the page is locked).",
|
|
13020
13044
|
code="NO_THUMBNAIL", category="precondition",
|
|
13021
|
-
remediation=
|
|
13022
|
-
|
|
13023
|
-
|
|
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'.",
|
|
13024
13052
|
)
|
|
13025
13053
|
try:
|
|
13026
13054
|
width, height, raw = _thumbnail_raw_rgb(thumbnail)
|
|
@@ -13037,6 +13065,194 @@ def _playhead_frame_preview(tl, p: Dict[str, Any]):
|
|
|
13037
13065
|
pass
|
|
13038
13066
|
|
|
13039
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
|
+
|
|
13040
13256
|
def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
|
|
13041
13257
|
"""Current frame at full resolution via GrabStill + ExportStills."""
|
|
13042
13258
|
fmt = str(p.get("format", "png")).lower().lstrip(".")
|
|
@@ -13158,14 +13374,32 @@ def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
|
|
|
13158
13374
|
pass
|
|
13159
13375
|
|
|
13160
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
|
+
|
|
13161
13389
|
def _playhead_frame_capture(p: Dict[str, Any]):
|
|
13162
13390
|
"""Dispatch a playhead capture, honouring an optional timeline_name."""
|
|
13163
|
-
|
|
13164
|
-
|
|
13391
|
+
requested = str(p.get("quality", "frame")).lower()
|
|
13392
|
+
quality = _PLAYHEAD_QUALITY_ALIASES.get(requested)
|
|
13393
|
+
if not quality:
|
|
13165
13394
|
return _err(
|
|
13166
|
-
f"quality must be
|
|
13395
|
+
f"quality must be one of {sorted(_PLAYHEAD_QUALITY_ALIASES)}; got {requested!r}",
|
|
13167
13396
|
code="INVALID_QUALITY", category="invalid_input",
|
|
13168
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
|
|
13169
13403
|
proj, tl, err = _get_tl()
|
|
13170
13404
|
if err:
|
|
13171
13405
|
return err
|
|
@@ -13193,9 +13427,11 @@ def _playhead_frame_capture(p: Dict[str, Any]):
|
|
|
13193
13427
|
code="SET_TIMELINE_FAILED", category="api_error",
|
|
13194
13428
|
)
|
|
13195
13429
|
try:
|
|
13196
|
-
if quality == "
|
|
13430
|
+
if quality == "thumbnail":
|
|
13431
|
+
return _playhead_frame_preview(tl, p)
|
|
13432
|
+
if quality == "still":
|
|
13197
13433
|
return _playhead_frame_full(proj, tl, p)
|
|
13198
|
-
return
|
|
13434
|
+
return _playhead_frame_render(proj, tl, p)
|
|
13199
13435
|
finally:
|
|
13200
13436
|
if original_tl is not None:
|
|
13201
13437
|
try:
|
|
@@ -22846,26 +23082,44 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
22846
23082
|
|
|
22847
23083
|
Actions:
|
|
22848
23084
|
capture(timecode?|frame?, quality?, max_width?, format?, timeline_name?) -> MCP image content
|
|
22849
|
-
capabilities() -> {quality_modes, ffmpeg,
|
|
23085
|
+
capabilities() -> {quality_modes, ffmpeg, render_settings_restorable, ...}
|
|
22850
23086
|
|
|
22851
23087
|
capture parameters:
|
|
22852
23088
|
timecode Absolute ('01:00:15:12') or elapsed ('00:00:15:12') timeline
|
|
22853
23089
|
timecode. Omit to capture the current playhead.
|
|
22854
23090
|
frame Alternative to timecode: absolute timeline frame number.
|
|
22855
|
-
quality '
|
|
22856
|
-
|
|
22857
|
-
|
|
22858
|
-
|
|
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
|
|
22859
23098
|
quietly returning a full-size frame.
|
|
22860
|
-
format
|
|
23099
|
+
format jpg (default), png, or tif.
|
|
22861
23100
|
timeline_name Capture from a different timeline; it is made current for
|
|
22862
23101
|
the read and the original is restored afterwards.
|
|
22863
23102
|
|
|
22864
|
-
|
|
22865
|
-
|
|
22866
|
-
|
|
22867
|
-
|
|
22868
|
-
|
|
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.
|
|
22869
23123
|
"""
|
|
22870
23124
|
p = _params(params)
|
|
22871
23125
|
if action == "capture":
|
|
@@ -22877,10 +23131,12 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
22877
23131
|
except Exception:
|
|
22878
23132
|
current_page = None
|
|
22879
23133
|
payload = {
|
|
22880
|
-
"quality_modes": ["preview", "
|
|
22881
|
-
"
|
|
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"],
|
|
22882
23138
|
"ffmpeg": bool(shutil.which("ffmpeg")),
|
|
22883
|
-
"max_width_supported":
|
|
23139
|
+
"max_width_supported": bool(shutil.which("ffmpeg")),
|
|
22884
23140
|
"current_page": current_page,
|
|
22885
23141
|
}
|
|
22886
23142
|
_, tl, err = _get_tl()
|
package/src/utils/api_truth.py
CHANGED
|
@@ -496,6 +496,55 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
496
496
|
"submit": "missing",
|
|
497
497
|
"mitigation": ["color_page_for_thumbnails", "_timeline_thumbnail_contact_sheet"],
|
|
498
498
|
},
|
|
499
|
+
{
|
|
500
|
+
"symbol": "Timeline.GetCurrentClipThumbnailImage (foreground only)",
|
|
501
|
+
"object": "Timeline",
|
|
502
|
+
"signature": "() -> {width, height, format, data} | None",
|
|
503
|
+
"reality": "Being on the Color page is necessary but NOT sufficient: the "
|
|
504
|
+
"call also returns None whenever Resolve is not the frontmost "
|
|
505
|
+
"application. Measured on Studio 19.1.3.7 — with the Color page "
|
|
506
|
+
"open, a clip under the playhead, and GetCurrentVideoItem "
|
|
507
|
+
"returning that clip, every read came back None while Resolve "
|
|
508
|
+
"sat behind a terminal window, at delays from 0 to 2 seconds; "
|
|
509
|
+
"bringing Resolve to the front made the very next read return a "
|
|
510
|
+
"288x162 'RGB 8 bit' thumbnail. Separately, the first read after "
|
|
511
|
+
"a page switch or a playhead move can be None while the viewer "
|
|
512
|
+
"catches up, so a single failed read proves nothing. The two "
|
|
513
|
+
"failure modes are indistinguishable from 'no thumbnail exists' "
|
|
514
|
+
"and from each other.",
|
|
515
|
+
"recommended": "Poll the read a few times before concluding it failed "
|
|
516
|
+
"(src/server.py:_playhead_thumbnail_settled), and when it "
|
|
517
|
+
"stays empty, name the foreground requirement in the error "
|
|
518
|
+
"rather than reporting a missing frame. Headless callers "
|
|
519
|
+
"cannot use this API at all; there is no scripting call to "
|
|
520
|
+
"raise Resolve, so a background agent must fall back to a "
|
|
521
|
+
"Gallery still or a render.",
|
|
522
|
+
"tags": ["timeline", "thumbnail", "silent-failure", "focus-dependent"],
|
|
523
|
+
"submit": "bug",
|
|
524
|
+
"mitigation": ["_playhead_thumbnail_settled", "timeline_frame"],
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
"symbol": "GalleryStillAlbum.ExportStills (Gallery panel must be visible)",
|
|
528
|
+
"object": "GalleryStillAlbum",
|
|
529
|
+
"signature": "(stills, folder, prefix, format) -> bool",
|
|
530
|
+
"reality": "Returns a bare False, writing nothing, unless the Gallery "
|
|
531
|
+
"panel is actually open on the Color page. Measured on Studio "
|
|
532
|
+
"19.1.3.7: Timeline.GrabStill() succeeded and the still landed "
|
|
533
|
+
"in the album (count 0 -> 1), yet ExportStills returned False "
|
|
534
|
+
"for png, jpg, tif and dpx alike, into three different "
|
|
535
|
+
"destination folders, at settle delays of 0.5s, 1.5s and 3.0s, "
|
|
536
|
+
"and with Resolve frontmost. Panel visibility is not readable "
|
|
537
|
+
"or settable from the scripting API, so a script cannot "
|
|
538
|
+
"establish the precondition it depends on, nor distinguish this "
|
|
539
|
+
"from a permissions or format failure.",
|
|
540
|
+
"recommended": "Treat False as 'the Gallery panel is probably closed' and "
|
|
541
|
+
"say so in the error; ask the user to open Workspace > "
|
|
542
|
+
"Gallery on the Color page. Do not retry formats hoping one "
|
|
543
|
+
"sticks — when the panel is closed they all fail.",
|
|
544
|
+
"tags": ["gallery", "stills", "silent-failure", "ui-dependent"],
|
|
545
|
+
"submit": "bug",
|
|
546
|
+
"mitigation": ["_playhead_frame_full", "gallery_stills.grab_and_export"],
|
|
547
|
+
},
|
|
499
548
|
{
|
|
500
549
|
"symbol": "Timeline.GetTimelineByName",
|
|
501
550
|
"object": "Project",
|