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 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
- [![Version](https://img.shields.io/badge/version-2.96.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.96.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.96.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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-rendered
1397
- thumbnails under the project analysis root for visual verification. Resolve
1398
- only serves these thumbnails on the Color page; the tool switches there
1399
- automatically and restores the previous page. Expect a page flash in the GUI,
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 the
1490
- current frame as rendered by Resolveincluding any color grading or effects
1491
- applied which is different from reading the source file directly.
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 and restore the
1496
- previous page. Prefer `timeline_frame(action="capture")` for new work it is the
1497
- same capture plus a timecode target, `max_width`, and a full-resolution mode.
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="preview"` (default) is Resolve's thumbnail: fast, small, writes
1510
- nothing.
1511
- - `quality="full"` grabs a Gallery still for a full-resolution frame, then
1512
- removes it again. `format` is `png` (default), `jpg`, or `tif`.
1513
- - `max_width` caps the width to conserve context. On `full` this needs ffmpeg;
1514
- without it the call fails rather than quietly returning a full-size frame.
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
- A capture is a read: the Color page, the playhead, the current timeline, and the
1519
- Gallery are all restored afterwards.
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. `quality="preview"` is fast and
2088
- writes nothing; `quality="full"` is full resolution; `max_width` bounds the
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
- **`timeline_markers(action="get_thumbnail")`** Returns raw thumbnail data at
2097
- the current playhead position. The response is a dictionary with keys `data`,
2098
- `format`, `width`, `height`, `noOfComponents`, and `depth`. Use it when you need
2099
- pixel data for tooling rather than an image to look at.
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="get_thumbnail_image")`** — The original image-content
2102
- action, equivalent to `timeline_frame(action="capture")` at default quality with
2103
- no positional arguments. Kept for existing callers.
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
- **`timeline(action="thumbnail_contact_sheet")`** — Many frames at once as a single
2106
- labeled PNG written to the analysis root. Use it to review a stretch of cut rather
2107
- than one moment.
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, 33 bugs / unreliable behaviors.
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.96.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.96.0",
3
+ "version": "2.97.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.96.0"
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.96.0"
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
- try:
13010
- thumbnail = tl.GetCurrentClipThumbnailImage()
13011
- except Exception as exc:
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="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.",
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
- quality = str(p.get("quality", "preview")).lower()
13164
- if quality not in ("preview", "full"):
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 'preview' or 'full'; got {quality!r}",
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 == "full":
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 _playhead_frame_preview(tl, p)
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, current_page, playhead, timeline}
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 '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
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 'full' only: png (default), jpg, or tif.
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
- 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.
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", "full"],
22881
- "formats": {"preview": ["png"], "full": sorted(_PLAYHEAD_STILL_FORMATS)},
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": {"preview": True, "full": bool(shutil.which("ffmpeg"))},
23139
+ "max_width_supported": bool(shutil.which("ffmpeg")),
22884
23140
  "current_page": current_page,
22885
23141
  }
22886
23142
  _, tl, err = _get_tl()
@@ -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",