davinci-resolve-mcp 2.81.0 → 2.82.1

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,80 @@
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.82.1
6
+
7
+ Corrects the Resolve version the v2.82.0 subtitle-style validation was actually
8
+ run against.
9
+
10
+ ### Fixed
11
+
12
+ - **v2.82.0 claimed the subtitle-style write path was confirmed on Resolve 21.
13
+ It was confirmed on Resolve Studio 19.1.3.** The validation itself stands —
14
+ Resolve opened the patched track and re-serialised it to its own zstd form
15
+ with the patched values intact — but it was run against 19.1.3, which is the
16
+ build that was installed. Corrected in `api_truth`, the codec header, and the
17
+ changelog. If anything this widens the supported range rather than narrowing
18
+ it, but the version on the claim has to be the one actually tested.
19
+ - **`GetFairlightPresets` / `ApplyFairlightPresetToCurrentTimeline` require
20
+ Resolve 20.2.2+**, now recorded in the `api_truth` Fairlight entry. On 19.1.3
21
+ both are absent (confirmed live), so on older builds the per-parameter gap
22
+ really is the whole story and the preset workaround is unavailable.
23
+
24
+ ## What's New in v2.82.0
25
+
26
+ Caption styling, which the scripting API cannot touch at all, is now readable
27
+ and writable — plus a correction to a claim this repo was about to send to
28
+ Blackmagic.
29
+
30
+ ### Added
31
+
32
+ - **`project_db list_subtitle_styles` / `set_subtitle_style`** — read and patch
33
+ the caption style on a subtitle track: font family, point size, weight,
34
+ italic, and normalised on-screen position. The scripting API exposes none of
35
+ this (subtitle `TimelineItem`s return only the 21 transform/composite
36
+ properties, and every `subtitleFontName`/`subtitlePreset`-shaped setting key
37
+ returns `None`), but the style is persisted in `Sm2TiTrack.FieldsBlob` for
38
+ `Type = 2` tracks: a keyed-dict holding an `EffectFiltersBA` payload whose
39
+ effect 136 carries a Qt `QFont::toString()` descriptor (param 18) and a
40
+ position vector (param 17). New codec at
41
+ `resolve-advanced/vendor/drp-format/subtitle-style.js`.
42
+
43
+ Verified live on Resolve Studio 19.1.3 (2026-08-06): a patched track opens without error
44
+ and, once Resolve next re-serialises it, is written back out in Resolve's own
45
+ zstd form with the patched values intact — so Resolve genuinely parses the
46
+ write rather than passing the bytes through. Read side verified against a
47
+ real project carrying 12 subtitle tracks.
48
+
49
+ Caveats, all reported by the tool: this is a whole-**track** style and not
50
+ per-caption, the project must be CLOSED, Resolve must be fully quit and
51
+ relaunched afterwards, and the track must already carry a style blob — a
52
+ freshly added subtitle track has none until it is styled once in the UI.
53
+
54
+ Only the font descriptor and position are named. The neighbouring parameters
55
+ vary across real projects but have not been correlated against the UI, so
56
+ they round-trip untouched and are reported as opaque rather than guessed at.
57
+
58
+ ### Fixed
59
+
60
+ - **The `api_truth` Fairlight entry claimed more was missing than actually is.**
61
+ It read "only voice-isolation state and channel-mapping reads are scriptable",
62
+ which omits `Project.ApplyFairlightPresetToCurrentTimeline(name)` — already
63
+ exposed as `project_settings apply_fairlight_preset`, with the names coming
64
+ from `resolve_control get_fairlight_presets`. The real gap is *per-parameter*
65
+ control (volume/pan/EQ/automation/FairlightFX), not the whole surface. Since
66
+ this text generates `docs/reference/api-limitations.md`, the incorrect claim
67
+ was headed for the Blackmagic submission. The subtitle-styling entry got the
68
+ same treatment: it claimed no workaround existed, which the above now closes.
69
+
70
+ ### Documented
71
+
72
+ - **AI Audio Assistant has no scripting method** — logged with the reason, which
73
+ is *not* that it is a menu command: the API has no generic menu-invocation
74
+ hook, so scriptability is per-feature, and `DetectSceneCuts`, `Stabilize`,
75
+ `SmartReframe` and `TranscribeAudio` are all menu commands that do have
76
+ methods. For a repeatable mix, save the Assistant's result as a Fairlight
77
+ preset once and apply it per-timeline (issues #127, #128).
78
+
5
79
  ## What's New in v2.81.0
6
80
 
7
81
  One render bug where every readback agreed and the file disagreed, plus the two
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.81.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.82.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 25 missing capabilities, 29 bugs / unreliable behaviors.
15
+ **Totals:** 27 missing capabilities, 30 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
@@ -131,10 +131,17 @@ equivalent, blocking full automation.
131
131
  ### Fairlight audio levels / pan / EQ / automation / FairlightFX
132
132
 
133
133
  - **Object:** `TimelineItem / Timeline`
134
- - **Behavior:** There is no API to set clip or track volume, pan, EQ, audio automation, or to add/configure FairlightFX. SetProperty covers video transform only; the audio surface is read-only (GetSourceAudioChannelMapping, GetAudioMapping, voice isolation). Verified via dir() + SetProperty docs AND by live mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO transform key, not audio pan, so it misleadingly succeeds).
135
- - **Workaround / current handling:** Mix in the Fairlight UI; only voice-isolation state and channel-mapping reads are scriptable.
134
+ - **Behavior:** There is no API to set clip or track volume, pan, EQ, audio automation, or to add/configure FairlightFX. SetProperty covers video transform only; the audio surface is read-only (GetSourceAudioChannelMapping, GetAudioMapping, voice isolation). Verified via dir() + SetProperty docs AND by live mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO transform key, not audio pan, so it misleadingly succeeds). The gap is PER-PARAMETER control specifically: a whole saved mix CAN be applied wholesale via Project.ApplyFairlightPresetToCurrentTimeline(name), with the available names from Resolve.GetFairlightPresets() — so 'no Fairlight write path exists' would be too strong. Both methods require Resolve 20.2.2+; on 19.1.3 they are absent (confirmed live 2026-08-06), so on older builds the per-parameter gap really is the whole story.
135
+ - **Workaround / current handling:** To reapply a known mix, save it once as a Fairlight preset in the UI and apply it per-timeline with ApplyFairlightPresetToCurrentTimeline (exposed as resolve_control get_fairlight_presets + project_settings apply_fairlight_preset). Dial individual levels/pan/EQ/automation/FairlightFX in the Fairlight UI; beyond presets, only voice-isolation state and channel-mapping reads are scriptable.
136
136
  - **Tags:** missing-method, audio, fairlight
137
137
 
138
+ ### AI Audio Assistant (one-click timeline auto-mix)
139
+
140
+ - **Object:** `Timeline / Project`
141
+ - **Behavior:** The Fairlight AI Audio Assistant — which analyses a timeline and generates a balanced dialogue/music/effects mix — has no scripting method. Nothing matching it appears in the Resolve scripting API reference or in a dir() audit of Resolve, Project, Timeline or TimelineItem. Note this is NOT because it is a menu command: the API has no generic menu-invocation hook at all, so scriptability is per-feature, and plenty of menu commands DO have methods (DetectSceneCuts, Stabilize, SmartReframe, CreateMagicMask, TranscribeAudio, RemoveMotionBlur, AnalyzeForIntellisearch). It is compounded by the per-parameter Fairlight gap above: even the mix it produces cannot be read back or reconstructed clip-by-clip.
142
+ - **Workaround / current handling:** No way to trigger it from a script. For a repeatable mix, run the Assistant once in the UI, save the result as a Fairlight preset, then apply that preset per-timeline with project_settings apply_fairlight_preset — content-adaptive per run is not achievable, a consistent template mix is.
143
+ - **Tags:** missing-method, audio, fairlight, ai, auto-mix
144
+
138
145
  ### Proxy / optimized-media generation
139
146
 
140
147
  - **Object:** `MediaPoolItem`
@@ -175,7 +182,7 @@ equivalent, blocking full automation.
175
182
 
176
183
  - **Object:** `TimelineItem / Timeline / Project`
177
184
  - **Behavior:** There is no API method to set or query subtitle font family, font size, text color, background color, outline, shadow, position, alignment, or to apply/query subtitle style presets. TimelineItem.GetProperty() on subtitle items returns only transform/composite keys. Timeline.GetSetting() and Project.GetSetting() return None for all probed subtitle-style keys (e.g. 'subtitleFontName', 'subtitleFontSize', 'subtitleTextColor', 'subtitleBackgroundColor', 'subtitlePosition', 'subtitleAlignment', 'subtitlePreset', 'subtitleStyle'). Verified via dir(), GetProperty(), and GetSetting() on Resolve 21.0.0.48.
178
- - **Workaround / current handling:** No workaround exists subtitle styling is UI-only. Burn-in overlays via Fusion titles are a visual alternative but do not produce proper subtitle tracks.
185
+ - **Workaround / current handling:** No API workaround exists, but the style IS reachable below the API: it lives in Sm2TiTrack.FieldsBlob for Type=2 tracks, as an EffectFiltersBA payload whose effect 136 carries a Qt QFont descriptor (param 18) and a normalised position vector (param 17). Exposed as project_db list_subtitle_styles / set_subtitle_style (font family/size/weight/italic + position). Confirmed live on Studio 19.1.3 (2026-08-06): Resolve opens a patched track and re-serialises it back to its own zstd form with the patched values intact, so it genuinely parses the write. Caveats: whole-TRACK style not per-caption, project must be CLOSED, Resolve must be fully quit and relaunched afterwards, and the track must already carry a style blob (a freshly added subtitle track has none until it is styled once in the UI). Burn-in overlays via Fusion titles remain a visual alternative but do not produce subtitle tracks.
179
186
  - **Tags:** missing-method, subtitle, style, preset
180
187
 
181
188
  ### Speech recognition engine selection and SRT import
@@ -215,6 +222,15 @@ equivalent, blocking full automation.
215
222
  - **Workaround / current handling:** SetCurrentFolder to the target bin first (media_pool set_current_folder), import, then restore the previous current folder if it matters.
216
223
  - **Tags:** media-pool, import
217
224
 
225
+ ### SetClipColor (undocumented value space; marker constants are a decoy)
226
+
227
+ - **Object:** `TimelineItem / MediaPoolItem`
228
+ - **Signature:** `(colorName) -> bool`
229
+ - **Behavior:** SetClipColor accepts exactly 16 names — the Edit-page clip-colour palette — and refuses everything else with a bare False, no exception and no other signal. Enumerated live on Studio 19.1.3.7 (2026-08-06) against both objects; the accepted set is IDENTICAL on TimelineItem and MediaPoolItem: Orange, Apricot, Yellow, Lime, Olive, Green, Teal, Navy, Blue, Purple, Violet, Pink, Tan, Beige, Brown, Chocolate. The empty string is refused too. The trap is that the scripting reference documents colorName as a bare string with no enumerated values, while exporting the MARKER palette as constants (resolve.MARKER_ROSE, resolve.MARKER_FUCHSIA, ...) — so the only colour vocabulary reachable from the API surface is the wrong one. Cyan, Red, Fuchsia, Rose, Lavender, Sky, Mint, Lemon, Sand, Cocoa and Cream are all marker-only and all refused. Five names overlap both palettes (Blue, Green, Yellow, Pink, Purple), which is why the decoy survives: reasoning from the marker constants scores 5 of 16 and looks like the right vocabulary with a few gaps.
230
+ - **Workaround / current handling:** Pass only the 16 clip-colour names; on False, treat it as an invalid name rather than an item/lock/page problem. The set is pinned in utils/clip_colors.py and named in the refusal remediation, but deliberately NOT enforced — it was measured on one build and a later Resolve could extend it.
231
+ - **Reference:** [issue #124](https://github.com/samuelgursky/davinci-resolve-mcp/issues/124)
232
+ - **Tags:** timeline-item, media-pool, silent-failure, undocumented-enum
233
+
218
234
  ### Project.SetCurrentRenderFormatAndCodec
219
235
 
220
236
  - **Object:** `Project`
@@ -436,6 +452,15 @@ values, or automation-hostile modal prompts.
436
452
  - **Reference:** [issue #59](https://github.com/samuelgursky/davinci-resolve-mcp/issues/59)
437
453
  - **Tags:** render, deliver, silent-failure, id-vs-label
438
454
 
455
+ ### TimelineItem.SetClipColor on generator/title items
456
+
457
+ - **Object:** `TimelineItem`
458
+ - **Signature:** `(colorName) -> bool`
459
+ - **Behavior:** On a generator or title item SetClipColor returns True with a VALID colour name and the colour does not persist — GetClipColor still reads '' immediately afterwards, and ClearClipColor changes nothing because there is nothing to clear. Measured on Studio 19.1.3.7 (2026-08-06) on a Solid Color generator. A media-backed item on the SAME timeline in the same session persists correctly (SetClipColor('Teal') -> True, GetClipColor -> 'Teal'), so the bool is honest for some items and a lie for others, with nothing in the return value to tell them apart. A timeline item's colour is also independent of its backing MediaPoolItem's: colouring the item leaves the pool clip at ''.
460
+ - **Workaround / current handling:** Never trust the bool alone — read GetClipColor back and compare. To mark a generator or title, use a timeline marker at its start instead; markers read back reliably.
461
+ - **Reference:** [issue #124](https://github.com/samuelgursky/davinci-resolve-mcp/issues/124)
462
+ - **Tags:** timeline-item, silent-failure, generator, readback-lies
463
+
439
464
  ### Project.SetRenderSettings (inherits the loaded preset)
440
465
 
441
466
  - **Object:** `Project`
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.81.0"
39
+ VERSION = "2.82.1"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (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.81.0",
3
+ "version": "2.82.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -83,6 +83,9 @@ Each dispatches on an `action`. Highlights:
83
83
  reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC.
84
84
  - **`color_trace`** — cross-project clip matching → a trace plan for carrying grades across a re-conform.
85
85
  - **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
86
+ Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
87
+ and normalised position, which the scripting API cannot touch at all. Whole-track (not
88
+ per-caption); project must be CLOSED and Resolve fully quit + relaunched afterwards.
86
89
  - **`pipeline`** — the DB-as-truth pipeline foundation (see below).
87
90
  - **`deliverable`** — deliverable QC / compliance: `deliverable_qc` (ffprobe a render vs its spec →
88
91
  pass/fail per field), `loudness_qc` (ebur128 LUFS/true-peak/LRA), `reframe_blanking_check`,
@@ -12,6 +12,9 @@
12
12
  * set_clip_marks — Sm2MpMedia.MarkIn/MarkOut (set clip in/out points)
13
13
  * relayout_node_graphs — rewrite node x/y in every graded ListMgt::LmVersion Body
14
14
  * (whole-project "Cleanup Node Graph"; the UI command has NO scripting API)
15
+ * list_subtitle_styles — read caption style per subtitle track (font + position)
16
+ * set_subtitle_style — patch Sm2TiTrack.FieldsBlob caption style (font family/size/
17
+ * italic/weight + normalised position). The API exposes NO subtitle styling at all.
15
18
  */
16
19
 
17
20
  import { z } from 'zod';
@@ -52,6 +55,33 @@ const relayoutSchema = z.object({
52
55
  iConfirmProjectClosed: confirm,
53
56
  });
54
57
 
58
+ const listSubtitleStylesSchema = z.object({ ...dbTarget });
59
+ const setSubtitleStyleSchema = z.object({
60
+ ...dbTarget,
61
+ timeline: z.string().describe('Timeline name carrying the subtitle track'),
62
+ track: z.number().int().min(1).optional().describe('1-based subtitle track index within that timeline (default 1)'),
63
+ family: z.string().optional().describe('Font family, e.g. "Montserrat"'),
64
+ pointSize: z.number().int().optional().describe('Font point size'),
65
+ weight: z.number().int().optional().describe('Qt font weight (50 = Regular, 75 = Bold)'),
66
+ italic: z.boolean().optional(),
67
+ styleName: z.string().optional().describe('Qt style name, e.g. "Regular" / "Bold" / "Oblique"'),
68
+ positionX: z.number().optional().describe('Normalised X (0-1, 0.5 = centre)'),
69
+ positionY: z.number().optional().describe('Normalised Y (0-1)'),
70
+ dryRun: z.boolean().optional().describe('Report the decoded before/after without writing'),
71
+ iConfirmProjectClosed: confirm,
72
+ });
73
+
74
+ // Subtitle tracks are Sm2TiTrack rows with Type = 2, linked to their timeline via
75
+ // Sm2TiTrack.Sequence -> Sm2Sequence.Sm2Sequence_id -> Sm2Timeline. (Note the join
76
+ // column is `Sequence`, NOT the same-table `Sm2Sequence_id`, which is null here.)
77
+ const SUBTITLE_TRACK_QUERY = `
78
+ SELECT tl.Name AS timeline, tr.Sm2TiTrack_id AS id, tr.FieldsBlob AS blob
79
+ FROM Sm2TiTrack tr
80
+ JOIN Sm2Sequence sq ON tr.Sequence = sq.Sm2Sequence_id
81
+ JOIN Sm2Timeline tl ON sq.Sm2Timeline_id = tl.Sm2Timeline_id
82
+ WHERE tr.Type = 2
83
+ ORDER BY tl.Name, tr.rowid`;
84
+
55
85
  function selectOne(db, table, col, value) {
56
86
  const rows = db.prepare(`SELECT rowid AS rid, Name FROM ${table} WHERE Name = ?`).all(value);
57
87
  if (!rows.length) throw new Error(`no ${table} named "${value}"`);
@@ -62,7 +92,7 @@ function selectOne(db, table, col, value) {
62
92
  export const projectDbTool = {
63
93
  name: 'project_db',
64
94
  description:
65
- 'Beyond-the-API live Project.db patches (plain columns) — closes gaps the scripting API cannot. Project must be CLOSED (auto-backup + schema guard + verify). Actions: list_folders, rename_folder (no RenameSubFolder API), set_folder_color, list_clips, set_clip_marks, relayout_node_graphs (whole-project Cleanup Node Graph — rewrites node x/y in every graded version Body; grade content untouched; REQUIRES full Resolve quit+relaunch after patching, it caches open projects in memory). Needs optional better-sqlite3.',
95
+ 'Beyond-the-API live Project.db patches (plain columns) — closes gaps the scripting API cannot. Project must be CLOSED (auto-backup + schema guard + verify). Actions: list_folders, rename_folder (no RenameSubFolder API), set_folder_color, list_clips, set_clip_marks, relayout_node_graphs (whole-project Cleanup Node Graph — rewrites node x/y in every graded version Body; grade content untouched; REQUIRES full Resolve quit+relaunch after patching, it caches open projects in memory), list_subtitle_styles, set_subtitle_style (caption font family/size/italic/weight + normalised position — the scripting API exposes NO subtitle styling; whole-track, not per-caption; same quit+relaunch requirement). Needs optional better-sqlite3.',
66
96
  async handler({ action, args }) {
67
97
  if (action === 'list_folders') {
68
98
  const p = listFoldersSchema.parse(args);
@@ -195,6 +225,104 @@ export const projectDbTool = {
195
225
  db.close();
196
226
  }
197
227
  }
228
+ if (action === 'list_subtitle_styles') {
229
+ const p = listSubtitleStylesSchema.parse(args);
230
+ const style = require('../../vendor/drp-format/subtitle-style.js');
231
+ const db = openGuarded(resolveDbPath(p), { table: 'Sm2TiTrack', column: 'FieldsBlob' });
232
+ try {
233
+ const counts = new Map();
234
+ const tracks = [];
235
+ for (const row of db.prepare(SUBTITLE_TRACK_QUERY).all()) {
236
+ const n = (counts.get(row.timeline) || 0) + 1;
237
+ counts.set(row.timeline, n);
238
+ const entry = { timeline: row.timeline, track: n };
239
+ try {
240
+ const dec = style.decodeSubtitleStyle(Buffer.from(row.blob));
241
+ entry.styled = dec.styled;
242
+ if (dec.styled) {
243
+ entry.font = dec.font && {
244
+ family: dec.font.family,
245
+ pointSize: dec.font.pointSize,
246
+ weight: dec.font.weight,
247
+ italic: dec.font.italic,
248
+ styleName: dec.font.styleName,
249
+ };
250
+ entry.position = dec.position;
251
+ entry.opaqueParams = dec.opaque.length;
252
+ }
253
+ } catch (e) {
254
+ // Report an undecodable track rather than failing the whole listing.
255
+ entry.error = e.message;
256
+ }
257
+ tracks.push(entry);
258
+ }
259
+ return {
260
+ tracks,
261
+ note: tracks.some((t) => t.styled === false)
262
+ ? 'Tracks with styled:false carry no style blob (Resolve writes a NumLayers-only stub until the track is styled once in the UI); set_subtitle_style cannot patch those.'
263
+ : undefined,
264
+ };
265
+ } finally {
266
+ db.close();
267
+ }
268
+ }
269
+ if (action === 'set_subtitle_style') {
270
+ const p = setSubtitleStyleSchema.parse(args);
271
+ const style = require('../../vendor/drp-format/subtitle-style.js');
272
+ const changes = {};
273
+ for (const k of ['family', 'pointSize', 'weight', 'italic', 'styleName']) {
274
+ if (p[k] != null) changes[k] = p[k];
275
+ }
276
+ if ((p.positionX == null) !== (p.positionY == null)) {
277
+ throw new Error('positionX and positionY must be given together');
278
+ }
279
+ if (p.positionX != null) changes.position = { x: p.positionX, y: p.positionY };
280
+ if (!Object.keys(changes).length) throw new Error('nothing to change — pass family/pointSize/weight/italic/styleName and/or positionX+positionY');
281
+
282
+ const write = !p.dryRun;
283
+ if (write) requireClosed(p);
284
+ const dbPath = resolveDbPath(p);
285
+ const bak = write ? backup(dbPath) : null;
286
+ const db = openGuarded(dbPath, { writable: write, table: 'Sm2TiTrack', column: 'FieldsBlob' });
287
+ try {
288
+ const wanted = p.track || 1;
289
+ let seen = 0;
290
+ let target = null;
291
+ for (const row of db.prepare(SUBTITLE_TRACK_QUERY).all()) {
292
+ if (row.timeline !== p.timeline) continue;
293
+ seen += 1;
294
+ if (seen === wanted) { target = row; break; }
295
+ }
296
+ if (!target) {
297
+ throw new Error(`timeline "${p.timeline}" has no subtitle track ${wanted} (found ${seen}) — run list_subtitle_styles`);
298
+ }
299
+ const before = style.decodeSubtitleStyle(Buffer.from(target.blob));
300
+ const patched = style.encodeSubtitleStyle(Buffer.from(target.blob), changes);
301
+ const after = style.decodeSubtitleStyle(patched);
302
+ if (write) {
303
+ db.prepare('UPDATE Sm2TiTrack SET FieldsBlob = ? WHERE Sm2TiTrack_id = ?').run(patched, target.id);
304
+ const back = db.prepare('SELECT FieldsBlob FROM Sm2TiTrack WHERE Sm2TiTrack_id = ?').get(target.id);
305
+ const verify = style.decodeSubtitleStyle(Buffer.from(back.FieldsBlob));
306
+ if (verify.font.raw !== after.font.raw || JSON.stringify(verify.position) !== JSON.stringify(after.position)) {
307
+ throw new Error(`read-back verify failed on track ${target.id} — restore from backup ${bak}`);
308
+ }
309
+ }
310
+ return {
311
+ dryRun: !write,
312
+ backup: bak,
313
+ timeline: p.timeline,
314
+ track: wanted,
315
+ before: { font: before.font && before.font.raw, position: before.position },
316
+ after: { font: after.font && after.font.raw, position: after.position },
317
+ opaqueParamsPreserved: JSON.stringify(before.opaque) === JSON.stringify(after.opaque),
318
+ note: write
319
+ ? 'Resolve caches open projects IN MEMORY: fully QUIT Resolve and relaunch before reopening this project, or the patched style will not be visible (and an oversave could revert it). This is a whole-TRACK style, not per-caption.'
320
+ : 'Dry run — nothing written. Re-run with dryRun:false + iConfirmProjectClosed:true (project CLOSED in Resolve).',
321
+ };
322
+ } finally {
323
+ db.close();
324
+ }
325
+ }
198
326
  throw new Error(`Unknown project_db action: ${action}`);
199
327
  },
200
328
  };
@@ -0,0 +1,416 @@
1
+ /**
2
+ * DaVinci Resolve subtitle-track style codec.
3
+ *
4
+ * Reads and writes the caption style Resolve stores on a SUBTITLE track
5
+ * (`Sm2TiTrack` rows with `Type = 2`). The scripting API exposes none of this:
6
+ * subtitle TimelineItems return only the 21 transform/composite properties, and
7
+ * `Timeline`/`Project.GetSetting()` return null for every subtitle-style key. See
8
+ * the `Subtitle track styling and presets` entry in src/utils/api_truth.py.
9
+ *
10
+ * Container chain (outermost first):
11
+ *
12
+ * Sm2TiTrack.FieldsBlob keyed-dict (keyed-dict.js) — { NumLayers, EffectFiltersBA }
13
+ * -> EffectFiltersBA [u32 version=2][u32 payloadLen][u8 0x80|0x81][payload]
14
+ * 0x80 = raw protobuf, 0x81 = compressed (Resolve 21 writes ZSTD)
15
+ * -> protobuf repeated f1 = effect, each:
16
+ * f1 varint effect id (136 carries the text style)
17
+ * f9 LEN parameter, repeated and POSITIONAL —
18
+ * empty f9 entries are placeholders and
19
+ * MUST be preserved to keep param order
20
+ * f1 varint param id
21
+ * f3 LEN value wrapper -> f1 LEN -> one of:
22
+ * f1 varint integer
23
+ * f3 LEN string
24
+ * f2 fixed64 double (LITTLE-endian)
25
+ * f7 LEN double vector (BIG-endian)
26
+ *
27
+ * Note the endianness split: scalar doubles (f2) are little-endian, but the f7
28
+ * double vector used by the position parameter is big-endian. Both were confirmed
29
+ * against live Resolve 21 projects — reading either one with the wrong order
30
+ * yields plausible-looking garbage (~1e-319), so this is worth not "fixing".
31
+ *
32
+ * Only two parameters of effect 136 are given names here, because only these two
33
+ * are self-describing:
34
+ * param 18 — Qt QFont::toString() descriptor, e.g.
35
+ * "Helvetica,13,-1,5,50,0,0,0,0,0,Regular"
36
+ * (family, pointSize, pixelSize, styleHint, weight, italic,
37
+ * underline, strikeOut, fixedPitch, rawMode, styleName)
38
+ * param 17 — normalised position vector [x, y], origin top-left, e.g. [0.5, 0.109]
39
+ *
40
+ * Every other parameter (19, 34, 142, and effects 16/54/56) round-trips untouched
41
+ * and is reported under `opaque`. They vary across real projects but their meaning
42
+ * has NOT been correlated against the UI, so they are deliberately left unlabelled
43
+ * rather than guessed at.
44
+ *
45
+ * @module drp-format/subtitle-style
46
+ */
47
+
48
+ const { decodeKeyedDict, encodeKeyedDict } = require('./keyed-dict.js');
49
+
50
+ let fzstd = null;
51
+ try { fzstd = require('fzstd'); } catch (e) { /* optional; 0x81 zstd payloads need it */ }
52
+
53
+ const TEXT_STYLE_EFFECT = 136;
54
+ const PARAM_FONT = 18;
55
+ const PARAM_POSITION = 17;
56
+
57
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // protobuf primitives (field-preserving: we never drop unknown fields)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function readVarint(buf, off) {
64
+ let value = 0n;
65
+ let shift = 0n;
66
+ let n = 0;
67
+ while (off + n < buf.length) {
68
+ const byte = buf[off + n];
69
+ value |= BigInt(byte & 0x7f) << shift;
70
+ n += 1;
71
+ if (!(byte & 0x80)) return { value: Number(value), bytesRead: n };
72
+ shift += 7n;
73
+ }
74
+ throw new Error('truncated varint');
75
+ }
76
+
77
+ function writeVarint(value) {
78
+ const bytes = [];
79
+ let remaining = value;
80
+ while (remaining > 0x7f) {
81
+ bytes.push((remaining & 0x7f) | 0x80);
82
+ remaining = Math.floor(remaining / 128);
83
+ }
84
+ bytes.push(remaining & 0x7f);
85
+ return Buffer.from(bytes);
86
+ }
87
+
88
+ /**
89
+ * Parse a protobuf message into a flat list of {field, wire, ...} records.
90
+ * Length-delimited payloads are kept as raw Buffers so unknown structure
91
+ * survives a re-encode byte-for-byte.
92
+ */
93
+ function parseMessage(buf) {
94
+ const out = [];
95
+ let off = 0;
96
+ while (off < buf.length) {
97
+ const tag = readVarint(buf, off);
98
+ off += tag.bytesRead;
99
+ const field = tag.value >> 3;
100
+ const wire = tag.value & 7;
101
+ if (wire === 0) {
102
+ const v = readVarint(buf, off);
103
+ off += v.bytesRead;
104
+ out.push({ field, wire, value: v.value });
105
+ } else if (wire === 1) {
106
+ out.push({ field, wire, bytes: buf.slice(off, off + 8) });
107
+ off += 8;
108
+ } else if (wire === 2) {
109
+ const len = readVarint(buf, off);
110
+ off += len.bytesRead;
111
+ out.push({ field, wire, bytes: buf.slice(off, off + len.value) });
112
+ off += len.value;
113
+ } else if (wire === 5) {
114
+ out.push({ field, wire, bytes: buf.slice(off, off + 4) });
115
+ off += 4;
116
+ } else {
117
+ throw new Error(`unsupported protobuf wire type ${wire} at offset ${off}`);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function serializeMessage(records) {
124
+ const parts = [];
125
+ for (const rec of records) {
126
+ parts.push(writeVarint((rec.field << 3) | rec.wire));
127
+ if (rec.wire === 0) {
128
+ parts.push(writeVarint(rec.value));
129
+ } else if (rec.wire === 2) {
130
+ parts.push(writeVarint(rec.bytes.length));
131
+ parts.push(rec.bytes);
132
+ } else {
133
+ parts.push(rec.bytes);
134
+ }
135
+ }
136
+ return Buffer.concat(parts);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // EffectFiltersBA envelope
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function unwrapEffectFilters(raw) {
144
+ if (raw.length < 9) throw new Error('EffectFiltersBA too short');
145
+ const version = raw.readUInt32BE(0);
146
+ const declared = raw.readUInt32BE(4);
147
+ const marker = raw[8];
148
+ const payload = raw.slice(9);
149
+ if (declared !== payload.length + 1) {
150
+ throw new Error(`EffectFiltersBA length mismatch: declared ${declared}, payload ${payload.length + 1}`);
151
+ }
152
+ if (marker === 0x80) return { version, marker, protobuf: payload };
153
+ if (marker === 0x81) {
154
+ if (!payload.slice(0, 4).equals(ZSTD_MAGIC)) {
155
+ throw new Error('EffectFiltersBA 0x81 payload is not a zstd frame');
156
+ }
157
+ if (!fzstd) throw new Error("EffectFiltersBA 0x81 payload is zstd; optional dep 'fzstd' is required to decode");
158
+ return { version, marker, protobuf: Buffer.from(fzstd.decompress(new Uint8Array(payload))) };
159
+ }
160
+ throw new Error(`unknown EffectFiltersBA payload marker 0x${marker.toString(16)}`);
161
+ }
162
+
163
+ /**
164
+ * Re-wrap a protobuf payload as EffectFiltersBA.
165
+ *
166
+ * Always emits the UNCOMPRESSED 0x80 form. This avoids depending on a zstd
167
+ * *compressor* (the bundled fzstd is decompress-only); the cost is a larger
168
+ * blob, which does not matter here.
169
+ *
170
+ * Confirmed live against Resolve Studio 19.1.3 (2026-08-06): a subtitle track patched with
171
+ * a 0x80 payload opens without error, and once Resolve next re-serialises that
172
+ * track it writes the style back out as 0x81 zstd with the font descriptor and
173
+ * position preserved exactly — i.e. Resolve genuinely parses the 0x80 form into
174
+ * its in-memory model rather than passing the bytes through untouched.
175
+ */
176
+ function wrapEffectFilters(protobuf, version = 2) {
177
+ const head = Buffer.alloc(9);
178
+ head.writeUInt32BE(version, 0);
179
+ head.writeUInt32BE(protobuf.length + 1, 4);
180
+ head[8] = 0x80;
181
+ return Buffer.concat([head, protobuf]);
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // parameter access
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** Split one effect into { idRecord, params: [{record, id, wrapper}] , others } */
189
+ function readEffect(effectBytes) {
190
+ const records = parseMessage(effectBytes);
191
+ const params = [];
192
+ for (const rec of records) {
193
+ if (rec.field !== 9 || rec.wire !== 2 || rec.bytes.length === 0) continue;
194
+ const inner = parseMessage(rec.bytes);
195
+ const idRec = inner.find((r) => r.field === 1 && r.wire === 0);
196
+ if (!idRec) continue;
197
+ params.push({ record: rec, id: idRec.value, inner });
198
+ }
199
+ const idRec = records.find((r) => r.field === 1 && r.wire === 0);
200
+ return { records, effectId: idRec ? idRec.value : null, params };
201
+ }
202
+
203
+ /** Decode the value wrapper (f3 -> f1 -> typed leaf) into a JS value. */
204
+ function readParamValue(wrapperBytes) {
205
+ const outer = parseMessage(wrapperBytes).find((r) => r.field === 1 && r.wire === 2);
206
+ if (!outer) return null;
207
+ const leaves = parseMessage(outer.bytes);
208
+ const result = {};
209
+ for (const leaf of leaves) {
210
+ if (leaf.field === 1 && leaf.wire === 0) result.int = leaf.value;
211
+ else if (leaf.field === 3 && leaf.wire === 2) result.string = leaf.bytes.toString('utf8');
212
+ else if (leaf.field === 2 && leaf.wire === 1) result.double = leaf.bytes.readDoubleLE(0);
213
+ else if (leaf.field === 7 && leaf.wire === 2) {
214
+ // BIG-endian double vector — see module note on the endianness split.
215
+ const vec = [];
216
+ for (let i = 0; i + 8 <= leaf.bytes.length; i += 8) vec.push(leaf.bytes.readDoubleBE(i));
217
+ result.vector = vec;
218
+ }
219
+ }
220
+ return Object.keys(result).length ? result : null;
221
+ }
222
+
223
+ /** Rebuild a value wrapper, replacing the string leaf. */
224
+ function writeStringParam(wrapperBytes, text) {
225
+ const outerRecords = parseMessage(wrapperBytes);
226
+ const outer = outerRecords.find((r) => r.field === 1 && r.wire === 2);
227
+ if (!outer) throw new Error('parameter wrapper has no inner message');
228
+ const leaves = parseMessage(outer.bytes);
229
+ const target = leaves.find((l) => l.field === 3 && l.wire === 2);
230
+ if (!target) throw new Error('parameter has no string leaf to replace');
231
+ target.bytes = Buffer.from(text, 'utf8');
232
+ outer.bytes = serializeMessage(leaves);
233
+ return serializeMessage(outerRecords);
234
+ }
235
+
236
+ /** Rebuild a value wrapper, replacing the big-endian double vector. */
237
+ function writeVectorParam(wrapperBytes, values) {
238
+ const outerRecords = parseMessage(wrapperBytes);
239
+ const outer = outerRecords.find((r) => r.field === 1 && r.wire === 2);
240
+ if (!outer) throw new Error('parameter wrapper has no inner message');
241
+ const leaves = parseMessage(outer.bytes);
242
+ const target = leaves.find((l) => l.field === 7 && l.wire === 2);
243
+ if (!target) throw new Error('parameter has no vector leaf to replace');
244
+ if (values.length * 8 !== target.bytes.length) {
245
+ throw new Error(`vector arity mismatch: existing ${target.bytes.length / 8} values, given ${values.length}`);
246
+ }
247
+ const buf = Buffer.alloc(values.length * 8);
248
+ values.forEach((v, i) => buf.writeDoubleBE(v, i * 8));
249
+ target.bytes = buf;
250
+ outer.bytes = serializeMessage(leaves);
251
+ return serializeMessage(outerRecords);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // QFont descriptor
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Parse a Qt QFont::toString() descriptor.
260
+ * Fields after the family are positional; unknown trailing fields are kept
261
+ * verbatim so a re-serialise is lossless.
262
+ */
263
+ function parseFontDescriptor(descriptor) {
264
+ const parts = String(descriptor).split(',');
265
+ return {
266
+ family: parts[0],
267
+ pointSize: parts.length > 1 ? Number(parts[1]) : null,
268
+ pixelSize: parts.length > 2 ? Number(parts[2]) : null,
269
+ styleHint: parts.length > 3 ? Number(parts[3]) : null,
270
+ weight: parts.length > 4 ? Number(parts[4]) : null,
271
+ italic: parts.length > 5 ? parts[5] === '1' : null,
272
+ styleName: parts.length > 10 ? parts[10] : null,
273
+ raw: descriptor,
274
+ _parts: parts,
275
+ };
276
+ }
277
+
278
+ function buildFontDescriptor(parsed, changes) {
279
+ const parts = parsed._parts.slice();
280
+ if (changes.family != null) parts[0] = String(changes.family);
281
+ if (changes.pointSize != null && parts.length > 1) parts[1] = String(changes.pointSize);
282
+ if (changes.weight != null && parts.length > 4) parts[4] = String(changes.weight);
283
+ if (changes.italic != null && parts.length > 5) parts[5] = changes.italic ? '1' : '0';
284
+ if (changes.styleName != null && parts.length > 10) parts[10] = String(changes.styleName);
285
+ return parts.join(',');
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // public API
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * Decode the caption style from an Sm2TiTrack.FieldsBlob.
294
+ *
295
+ * @param {Buffer|string} fieldsBlob - raw blob or hex string
296
+ * @returns {{styled: boolean, font: object|null, position: {x: number, y: number}|null,
297
+ * opaque: Array, effects: Array}}
298
+ * `styled: false` means the track carries no EffectFiltersBA at all (Resolve
299
+ * writes a ~39-byte NumLayers-only stub for an unstyled subtitle track).
300
+ */
301
+ function decodeSubtitleStyle(fieldsBlob) {
302
+ const buf = Buffer.isBuffer(fieldsBlob) ? fieldsBlob : Buffer.from(fieldsBlob, 'hex');
303
+ const dict = decodeKeyedDict(buf);
304
+ const entry = dict.entries.find((e) => e.key === 'EffectFiltersBA');
305
+ if (!entry || !entry.value) {
306
+ return { styled: false, font: null, position: null, opaque: [], effects: [] };
307
+ }
308
+ const { protobuf } = unwrapEffectFilters(Buffer.from(entry.value, 'hex'));
309
+ const effects = parseMessage(protobuf).filter((r) => r.field === 1 && r.wire === 2);
310
+
311
+ let font = null;
312
+ let position = null;
313
+ const opaque = [];
314
+ const summary = [];
315
+
316
+ for (const effectRec of effects) {
317
+ const effect = readEffect(effectRec.bytes);
318
+ const paramSummary = [];
319
+ for (const param of effect.params) {
320
+ const wrapper = param.inner.find((r) => r.field === 3 && r.wire === 2);
321
+ const value = wrapper ? readParamValue(wrapper.bytes) : null;
322
+ paramSummary.push({ id: param.id, value });
323
+ if (effect.effectId === TEXT_STYLE_EFFECT && param.id === PARAM_FONT && value && value.string) {
324
+ font = parseFontDescriptor(value.string);
325
+ } else if (effect.effectId === TEXT_STYLE_EFFECT && param.id === PARAM_POSITION && value && value.vector) {
326
+ position = { x: value.vector[0], y: value.vector[1] };
327
+ } else if (value) {
328
+ opaque.push({ effect: effect.effectId, param: param.id, value });
329
+ }
330
+ }
331
+ summary.push({ effect: effect.effectId, params: paramSummary });
332
+ }
333
+ return { styled: true, font, position, opaque, effects: summary };
334
+ }
335
+
336
+ /**
337
+ * Return a new FieldsBlob with the caption style modified.
338
+ *
339
+ * Only the font descriptor and position are writable; every other parameter and
340
+ * effect is re-emitted untouched. Throws rather than silently no-op'ing when the
341
+ * track has no style blob to patch, or when a requested change has no target
342
+ * parameter — a silent no-op here would read as success.
343
+ *
344
+ * @param {Buffer|string} fieldsBlob
345
+ * @param {{family?: string, pointSize?: number, weight?: number, italic?: boolean,
346
+ * styleName?: string, position?: {x: number, y: number}}} changes
347
+ * @returns {Buffer} new FieldsBlob
348
+ */
349
+ function encodeSubtitleStyle(fieldsBlob, changes = {}) {
350
+ const buf = Buffer.isBuffer(fieldsBlob) ? fieldsBlob : Buffer.from(fieldsBlob, 'hex');
351
+ const dict = decodeKeyedDict(buf);
352
+ const entry = dict.entries.find((e) => e.key === 'EffectFiltersBA');
353
+ if (!entry || !entry.value) {
354
+ throw new Error(
355
+ 'track has no EffectFiltersBA — it is an unstyled subtitle track; ' +
356
+ 'style it once in the Resolve UI so a style blob exists to patch',
357
+ );
358
+ }
359
+ const envelope = unwrapEffectFilters(Buffer.from(entry.value, 'hex'));
360
+ const records = parseMessage(envelope.protobuf);
361
+
362
+ const wantsFont = changes.family != null || changes.pointSize != null ||
363
+ changes.weight != null || changes.italic != null || changes.styleName != null;
364
+ const wantsPosition = changes.position != null;
365
+ let fontDone = false;
366
+ let positionDone = false;
367
+
368
+ for (const effectRec of records) {
369
+ if (effectRec.field !== 1 || effectRec.wire !== 2) continue;
370
+ const effect = readEffect(effectRec.bytes);
371
+ if (effect.effectId !== TEXT_STYLE_EFFECT) continue;
372
+ let touched = false;
373
+ for (const param of effect.params) {
374
+ const wrapper = param.inner.find((r) => r.field === 3 && r.wire === 2);
375
+ if (!wrapper) continue;
376
+ if (wantsFont && param.id === PARAM_FONT) {
377
+ const current = readParamValue(wrapper.bytes);
378
+ if (!current || !current.string) continue;
379
+ const next = buildFontDescriptor(parseFontDescriptor(current.string), changes);
380
+ wrapper.bytes = writeStringParam(wrapper.bytes, next);
381
+ param.record.bytes = serializeMessage(param.inner);
382
+ fontDone = true;
383
+ touched = true;
384
+ } else if (wantsPosition && param.id === PARAM_POSITION) {
385
+ const current = readParamValue(wrapper.bytes);
386
+ if (!current || !current.vector) continue;
387
+ wrapper.bytes = writeVectorParam(wrapper.bytes, [changes.position.x, changes.position.y]);
388
+ param.record.bytes = serializeMessage(param.inner);
389
+ positionDone = true;
390
+ touched = true;
391
+ }
392
+ }
393
+ if (touched) effectRec.bytes = serializeMessage(effect.records);
394
+ }
395
+
396
+ if (wantsFont && !fontDone) throw new Error(`no font parameter (${PARAM_FONT}) found on effect ${TEXT_STYLE_EFFECT}`);
397
+ if (wantsPosition && !positionDone) throw new Error(`no position parameter (${PARAM_POSITION}) found on effect ${TEXT_STYLE_EFFECT}`);
398
+
399
+ entry.value = wrapEffectFilters(serializeMessage(records), envelope.version).toString('hex');
400
+ return encodeKeyedDict(dict);
401
+ }
402
+
403
+ module.exports = {
404
+ decodeSubtitleStyle,
405
+ encodeSubtitleStyle,
406
+ // exposed for tests and for callers needing the lower layers
407
+ parseMessage,
408
+ serializeMessage,
409
+ unwrapEffectFilters,
410
+ wrapEffectFilters,
411
+ parseFontDescriptor,
412
+ buildFontDescriptor,
413
+ TEXT_STYLE_EFFECT,
414
+ PARAM_FONT,
415
+ PARAM_POSITION,
416
+ };
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.81.0"
88
+ VERSION = "2.82.1"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
@@ -1,6 +1,7 @@
1
1
  """Timeline item property, keyframe, and clip-level tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ from src.utils.clip_colors import clip_color_refusal
4
5
 
5
6
  resolve = ResolveProxy()
6
7
 
@@ -1528,7 +1529,32 @@ def ti_set_clip_color(color: str, item_index: int = 0, track_type: str = "video"
1528
1529
  item, err = _get_timeline_item(track_type, track_index, item_index)
1529
1530
  if err:
1530
1531
  return err
1531
- return {"success": bool(item.SetClipColor(color))}
1532
+ returned = bool(item.SetClipColor(color))
1533
+ readback = item.GetClipColor() or ""
1534
+ if not returned:
1535
+ refusal = clip_color_refusal(color)
1536
+ return {
1537
+ "success": False,
1538
+ "color": color,
1539
+ "error": f"SetClipColor refused '{color}'",
1540
+ "reason": refusal["reason"],
1541
+ "remediation": refusal["remediation"],
1542
+ "valid_colors": refusal["state"]["valid_colors"],
1543
+ }
1544
+ if readback != color:
1545
+ # Generator and title items take the call, return True, and drop the
1546
+ # colour (issue #124, Studio 19.1.3.7). Media-backed items persist.
1547
+ return {
1548
+ "success": False,
1549
+ "color": color,
1550
+ "readback": readback,
1551
+ "error": "SetClipColor returned True but the colour did not persist",
1552
+ "remediation": (
1553
+ "Generator and title items accept and discard clip colours; colour "
1554
+ "a media-backed item, or use a timeline marker instead."
1555
+ ),
1556
+ }
1557
+ return {"success": True, "color": color, "readback": readback}
1532
1558
 
1533
1559
 
1534
1560
  @mcp.tool()
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.81.0"
14
+ VERSION = "2.82.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -46,6 +46,7 @@ for p in [current_dir, project_dir]:
46
46
  from src.utils.cdl import normalize_cdl_payload
47
47
  from src.utils.mcp_stdio import run_fastmcp_stdio
48
48
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
+ from src.utils import clip_colors as _clip_colors
49
50
  from src.utils.contracts import validate as _validate_params
50
51
  from src.utils.cut_ir import build_cut_list as _build_cut_list
51
52
  from src.utils.page_lock import (
@@ -9006,6 +9007,66 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
9006
9007
  return parsed
9007
9008
 
9008
9009
 
9010
+ def _set_clip_color_checked(obj, color, *, kind: str):
9011
+ """SetClipColor, with the bare bool turned into something a caller can act on.
9012
+
9013
+ Two measured failures hide behind that bool (issue #124, enumerated live on
9014
+ Studio 19.1.3.7):
9015
+
9016
+ - A name outside the 16-name Edit-page palette is refused with `False` and
9017
+ nothing else. The marker constants are the only colour vocabulary the
9018
+ scripting reference enumerates, so they are what an agent reaches for, and
9019
+ most of them are refused.
9020
+ - On generator and title items the call returns `True` and the colour does
9021
+ not persist — `GetClipColor` still reads empty afterwards. Media-backed
9022
+ items on the same timeline persist correctly, so the bool is honest for
9023
+ some items and a lie for others.
9024
+
9025
+ So the return value is never trusted on its own: the colour is read back and
9026
+ the response reports what actually stuck.
9027
+ """
9028
+ returned = bool(obj.SetClipColor(color))
9029
+ readback = None
9030
+ if _has_method(obj, "GetClipColor"):
9031
+ try:
9032
+ readback = obj.GetClipColor()
9033
+ except Exception: # pragma: no cover - defensive; getter is documented
9034
+ readback = None
9035
+ persisted = (readback == color) if readback is not None else None
9036
+
9037
+ if not returned:
9038
+ refusal = _clip_colors.clip_color_refusal(color)
9039
+ return _err(
9040
+ f"SetClipColor refused '{color}' on this {kind}",
9041
+ code="CLIP_COLOR_REJECTED",
9042
+ category="invalid_input",
9043
+ reason=refusal["reason"],
9044
+ remediation=refusal["remediation"],
9045
+ state=refusal["state"],
9046
+ )
9047
+ if persisted is False:
9048
+ # The generator/title case. Reporting success here is the silent lie.
9049
+ return {
9050
+ "success": False,
9051
+ "color": color,
9052
+ "readback": readback,
9053
+ "warnings": [{
9054
+ "code": "CLIP_COLOR_NOT_PERSISTED",
9055
+ "message": (
9056
+ f"SetClipColor returned True on this {kind} but the colour did "
9057
+ f"not persist — GetClipColor reads {readback!r}. Measured on "
9058
+ "generator and title items, which take the call and drop it; "
9059
+ "media-backed items on the same timeline persist correctly."
9060
+ ),
9061
+ "remediation": (
9062
+ "Colour a media-backed item, or mark the generator another way "
9063
+ "(a timeline marker at its start reads back reliably)."
9064
+ ),
9065
+ }],
9066
+ }
9067
+ return {"success": True, "color": color, "readback": readback}
9068
+
9069
+
9009
9070
  def _filter_to_keys(settings: Any, allowed) -> Tuple[Dict[str, Any], list]:
9010
9071
  """Whitelist a settings/options dict to the keys a Resolve API documents.
9011
9072
 
@@ -17783,7 +17844,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17783
17844
  elif action == "get_clip_color":
17784
17845
  return {"color": clip.GetClipColor()}
17785
17846
  elif action == "set_clip_color":
17786
- return {"success": bool(clip.SetClipColor(p["color"]))}
17847
+ return _set_clip_color_checked(clip, p["color"], kind="media pool item")
17787
17848
  elif action == "clear_clip_color":
17788
17849
  return {"success": bool(clip.ClearClipColor())}
17789
17850
  elif action == "link_proxy":
@@ -22251,7 +22312,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
22251
22312
  elif action == "get_clip_color":
22252
22313
  return {"color": item.GetClipColor()}
22253
22314
  elif action == "set_clip_color":
22254
- return {"success": bool(item.SetClipColor(p["color"]))}
22315
+ return _set_clip_color_checked(item, p["color"], kind="timeline item")
22255
22316
  elif action == "clear_clip_color":
22256
22317
  return {"success": bool(item.ClearClipColor())}
22257
22318
  return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","get_clip_color","set_clip_color","clear_clip_color"])
@@ -858,12 +858,49 @@ API_TRUTH: List[Dict[str, Any]] = [
858
858
  "isolation). Verified via dir() + SetProperty docs AND by live "
859
859
  "mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'"
860
860
  "|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO "
861
- "transform key, not audio pan, so it misleadingly succeeds).",
862
- "recommended": "Mix in the Fairlight UI; only voice-isolation state and "
863
- "channel-mapping reads are scriptable.",
861
+ "transform key, not audio pan, so it misleadingly succeeds). "
862
+ "The gap is PER-PARAMETER control specifically: a whole saved "
863
+ "mix CAN be applied wholesale via "
864
+ "Project.ApplyFairlightPresetToCurrentTimeline(name), with the "
865
+ "available names from Resolve.GetFairlightPresets() — so "
866
+ "'no Fairlight write path exists' would be too strong. Both "
867
+ "methods require Resolve 20.2.2+; on 19.1.3 they are absent "
868
+ "(confirmed live 2026-08-06), so on older builds the "
869
+ "per-parameter gap really is the whole story.",
870
+ "recommended": "To reapply a known mix, save it once as a Fairlight preset "
871
+ "in the UI and apply it per-timeline with "
872
+ "ApplyFairlightPresetToCurrentTimeline (exposed as "
873
+ "resolve_control get_fairlight_presets + project_settings "
874
+ "apply_fairlight_preset). Dial individual levels/pan/EQ/"
875
+ "automation/FairlightFX in the Fairlight UI; beyond presets, "
876
+ "only voice-isolation state and channel-mapping reads are "
877
+ "scriptable.",
864
878
  "tags": ["missing-method", "audio", "fairlight"],
865
879
  "submit": "missing",
866
880
  },
881
+ {
882
+ "symbol": "AI Audio Assistant (one-click timeline auto-mix)",
883
+ "object": "Timeline / Project",
884
+ "reality": "The Fairlight AI Audio Assistant — which analyses a timeline "
885
+ "and generates a balanced dialogue/music/effects mix — has no "
886
+ "scripting method. Nothing matching it appears in the Resolve "
887
+ "scripting API reference or in a dir() audit of Resolve, "
888
+ "Project, Timeline or TimelineItem. Note this is NOT because it "
889
+ "is a menu command: the API has no generic menu-invocation hook "
890
+ "at all, so scriptability is per-feature, and plenty of menu "
891
+ "commands DO have methods (DetectSceneCuts, Stabilize, "
892
+ "SmartReframe, CreateMagicMask, TranscribeAudio, "
893
+ "RemoveMotionBlur, AnalyzeForIntellisearch). It is compounded by "
894
+ "the per-parameter Fairlight gap above: even the mix it produces "
895
+ "cannot be read back or reconstructed clip-by-clip.",
896
+ "recommended": "No way to trigger it from a script. For a repeatable mix, "
897
+ "run the Assistant once in the UI, save the result as a "
898
+ "Fairlight preset, then apply that preset per-timeline with "
899
+ "project_settings apply_fairlight_preset — content-adaptive "
900
+ "per run is not achievable, a consistent template mix is.",
901
+ "tags": ["missing-method", "audio", "fairlight", "ai", "auto-mix"],
902
+ "submit": "missing",
903
+ },
867
904
  {
868
905
  "symbol": "Proxy / optimized-media generation",
869
906
  "object": "MediaPoolItem",
@@ -964,9 +1001,23 @@ API_TRUTH: List[Dict[str, Any]] = [
964
1001
  "'subtitlePosition', 'subtitleAlignment', "
965
1002
  "'subtitlePreset', 'subtitleStyle'). Verified via dir(), "
966
1003
  "GetProperty(), and GetSetting() on Resolve 21.0.0.48.",
967
- "recommended": "No workaround exists subtitle styling is UI-only. "
968
- "Burn-in overlays via Fusion titles are a visual "
969
- "alternative but do not produce proper subtitle tracks.",
1004
+ "recommended": "No API workaround exists, but the style IS reachable "
1005
+ "below the API: it lives in Sm2TiTrack.FieldsBlob for "
1006
+ "Type=2 tracks, as an EffectFiltersBA payload whose "
1007
+ "effect 136 carries a Qt QFont descriptor (param 18) and "
1008
+ "a normalised position vector (param 17). Exposed as "
1009
+ "project_db list_subtitle_styles / set_subtitle_style "
1010
+ "(font family/size/weight/italic + position). Confirmed "
1011
+ "live on Studio 19.1.3 (2026-08-06): Resolve opens a patched track "
1012
+ "and re-serialises it back to its own zstd form with the "
1013
+ "patched values intact, so it genuinely parses the write. "
1014
+ "Caveats: whole-TRACK style not per-caption, project must "
1015
+ "be CLOSED, Resolve must be fully quit and relaunched "
1016
+ "afterwards, and the track must already carry a style "
1017
+ "blob (a freshly added subtitle track has none until it "
1018
+ "is styled once in the UI). Burn-in overlays via Fusion "
1019
+ "titles remain a visual alternative but do not produce "
1020
+ "subtitle tracks.",
970
1021
  "tags": ["missing-method", "subtitle", "style", "preset"],
971
1022
  "submit": "missing",
972
1023
  },
@@ -1373,6 +1424,60 @@ API_TRUTH: List[Dict[str, Any]] = [
1373
1424
  "issue": 59,
1374
1425
  "mitigation": ["_render_format_id", "_render_codec_id"],
1375
1426
  },
1427
+ {
1428
+ "symbol": "SetClipColor (undocumented value space; marker constants are a decoy)",
1429
+ "object": "TimelineItem / MediaPoolItem",
1430
+ "signature": "(colorName) -> bool",
1431
+ "reality": "SetClipColor accepts exactly 16 names — the Edit-page clip-colour "
1432
+ "palette — and refuses everything else with a bare False, no "
1433
+ "exception and no other signal. Enumerated live on Studio "
1434
+ "19.1.3.7 (2026-08-06) against both objects; the accepted set is "
1435
+ "IDENTICAL on TimelineItem and MediaPoolItem: Orange, Apricot, "
1436
+ "Yellow, Lime, Olive, Green, Teal, Navy, Blue, Purple, Violet, "
1437
+ "Pink, Tan, Beige, Brown, Chocolate. The empty string is refused "
1438
+ "too. The trap is that the scripting reference documents "
1439
+ "colorName as a bare string with no enumerated values, while "
1440
+ "exporting the MARKER palette as constants (resolve.MARKER_ROSE, "
1441
+ "resolve.MARKER_FUCHSIA, ...) — so the only colour vocabulary "
1442
+ "reachable from the API surface is the wrong one. Cyan, Red, "
1443
+ "Fuchsia, Rose, Lavender, Sky, Mint, Lemon, Sand, Cocoa and Cream "
1444
+ "are all marker-only and all refused. Five names overlap both "
1445
+ "palettes (Blue, Green, Yellow, Pink, Purple), which is why the "
1446
+ "decoy survives: reasoning from the marker constants scores 5 of "
1447
+ "16 and looks like the right vocabulary with a few gaps.",
1448
+ "recommended": "Pass only the 16 clip-colour names; on False, treat it as an "
1449
+ "invalid name rather than an item/lock/page problem. The set "
1450
+ "is pinned in utils/clip_colors.py and named in the refusal "
1451
+ "remediation, but deliberately NOT enforced — it was measured "
1452
+ "on one build and a later Resolve could extend it.",
1453
+ "tags": ["timeline-item", "media-pool", "silent-failure", "undocumented-enum"],
1454
+ "submit": "missing",
1455
+ "issue": 124,
1456
+ "mitigation": ["_set_clip_color_checked", "ti_set_clip_color"],
1457
+ },
1458
+ {
1459
+ "symbol": "TimelineItem.SetClipColor on generator/title items",
1460
+ "object": "TimelineItem",
1461
+ "signature": "(colorName) -> bool",
1462
+ "reality": "On a generator or title item SetClipColor returns True with a "
1463
+ "VALID colour name and the colour does not persist — GetClipColor "
1464
+ "still reads '' immediately afterwards, and ClearClipColor "
1465
+ "changes nothing because there is nothing to clear. Measured on "
1466
+ "Studio 19.1.3.7 (2026-08-06) on a Solid Color generator. A "
1467
+ "media-backed item on the SAME timeline in the same session "
1468
+ "persists correctly (SetClipColor('Teal') -> True, GetClipColor "
1469
+ "-> 'Teal'), so the bool is honest for some items and a lie for "
1470
+ "others, with nothing in the return value to tell them apart. A "
1471
+ "timeline item's colour is also independent of its backing "
1472
+ "MediaPoolItem's: colouring the item leaves the pool clip at ''.",
1473
+ "recommended": "Never trust the bool alone — read GetClipColor back and "
1474
+ "compare. To mark a generator or title, use a timeline marker "
1475
+ "at its start instead; markers read back reliably.",
1476
+ "tags": ["timeline-item", "silent-failure", "generator", "readback-lies"],
1477
+ "submit": "bug",
1478
+ "issue": 124,
1479
+ "mitigation": ["_set_clip_color_checked", "ti_set_clip_color"],
1480
+ },
1376
1481
  {
1377
1482
  "symbol": "Project.SetRenderSettings (inherits the loaded preset)",
1378
1483
  "object": "Project",
@@ -0,0 +1,94 @@
1
+ """The value space of SetClipColor, and an honest refusal when a name misses.
2
+
3
+ `SetClipColor(colorName) --> Bool` is documented with `colorName` as a bare
4
+ string and no enumerated values anywhere in the scripting reference. The same
5
+ reference *does* export a discoverable colour vocabulary — the marker constants
6
+ (`resolve.MARKER_ROSE`, `resolve.MARKER_FUCHSIA`, …) — so the only colour names
7
+ reachable from the API surface come from the marker palette, and those are
8
+ largely the ones `SetClipColor` refuses. Issue #124 named this a decoy
9
+ vocabulary, which is exactly right.
10
+
11
+ Enumerated live on **DaVinci Resolve Studio 19.1.3.7 (macOS, 2026-08-06)**
12
+ against both objects that expose the method. Both accept the same 16 names —
13
+ the Edit-page clip-colour palette — and refuse everything else with a bare
14
+ `False`, including every marker-only name and the empty string.
15
+
16
+ The five names that overlap the two palettes (Blue, Green, Yellow, Pink,
17
+ Purple) are what makes this trap so durable: an agent reasoning from the marker
18
+ constants gets five hits out of sixteen and concludes the vocabulary is right.
19
+
20
+ This set is deliberately NOT enforced. It was measured on one build, and a
21
+ future Resolve could extend it; hard-rejecting an unlisted name would turn a
22
+ working call into a failure on a build we have not seen. Callers pass the name
23
+ through, and when Resolve refuses, the remediation names the measured-valid set.
24
+ """
25
+
26
+ VERIFIED_ON = "DaVinci Resolve Studio 19.1.3.7 (macOS, 2026-08-06)"
27
+
28
+ # Accepted by BOTH TimelineItem.SetClipColor and MediaPoolItem.SetClipColor.
29
+ CLIP_COLORS = (
30
+ "Orange", "Apricot", "Yellow", "Lime", "Olive", "Green", "Teal", "Navy",
31
+ "Blue", "Purple", "Violet", "Pink", "Tan", "Beige", "Brown", "Chocolate",
32
+ )
33
+
34
+ # Measured refused. Kept explicit because these are the names an agent actually
35
+ # reaches for: every one is either a marker constant or a common colour word.
36
+ REFUSED_NAMES = (
37
+ "Cyan", "Red", "Fuchsia", "Rose", "Lavender", "Sky", "Mint", "Lemon",
38
+ "Sand", "Cocoa", "Cream", "Apple", "Magenta", "White", "Black", "Gray",
39
+ "Grey", "",
40
+ )
41
+
42
+ # The marker-palette names that are NOT clip colours — the decoy set proper.
43
+ MARKER_ONLY_NAMES = (
44
+ "Cyan", "Red", "Fuchsia", "Rose", "Lavender", "Sky", "Mint", "Lemon",
45
+ "Sand", "Cocoa", "Cream",
46
+ )
47
+
48
+
49
+ def is_known_clip_color(color) -> bool:
50
+ """True if `color` is in the measured-valid set. Case-sensitive, as Resolve is."""
51
+ return isinstance(color, str) and color in CLIP_COLORS
52
+
53
+
54
+ def clip_color_refusal(color) -> dict:
55
+ """Error payload for a SetClipColor that came back False.
56
+
57
+ Separates 'you used a marker name' from 'Resolve refused for another
58
+ reason', because the caller cannot tell them apart from the bare bool.
59
+ """
60
+ marker_decoy = isinstance(color, str) and color in MARKER_ONLY_NAMES
61
+ if marker_decoy:
62
+ reason = (
63
+ f"'{color}' is a MARKER colour, not a clip colour. The scripting "
64
+ "reference enumerates the marker constants and nothing else, so it "
65
+ "is the vocabulary an agent finds first — and SetClipColor refuses "
66
+ "most of it with a bare False."
67
+ )
68
+ elif is_known_clip_color(color):
69
+ reason = (
70
+ f"'{color}' is a valid clip colour on {VERIFIED_ON}, so this refusal "
71
+ "is about the item or the session, not the name. Note that "
72
+ "SetClipColor returns True WITHOUT persisting on generator and title "
73
+ "items — check GetClipColor rather than the bool."
74
+ )
75
+ else:
76
+ reason = (
77
+ f"'{color}' is not in the clip-colour palette. SetClipColor refuses "
78
+ "undocumented names with a bare False and no other signal."
79
+ )
80
+ return {
81
+ "reason": reason,
82
+ "remediation": (
83
+ "Use one of the 16 Edit-page clip colours: "
84
+ + ", ".join(CLIP_COLORS)
85
+ + f". Measured on {VERIFIED_ON}; the set is not enforced here because "
86
+ "a later build could extend it."
87
+ ),
88
+ "state": {
89
+ "requested_color": color,
90
+ "valid_colors": list(CLIP_COLORS),
91
+ "is_marker_only_name": marker_decoy,
92
+ "verified_on": VERIFIED_ON,
93
+ },
94
+ }