davinci-resolve-mcp 4.13.7 → 4.14.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 +49 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +3 -1
- package/docs/guides/control-panel.md +3 -1
- package/docs/guides/subtitle-track-editing.md +18 -7
- package/install.py +46 -1
- package/package.json +1 -1
- package/resolve-advanced/server/subtitle-db.mjs +11 -0
- package/resolve-advanced/server/subtitle-timeline.mjs +3 -1
- package/resolve-advanced/server/tools/project_db.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/subtitle-preset.js +9 -1
- package/src/analysis_dashboard.py +9 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +97 -21
- package/src/utils/subtitle_live.py +37 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,55 @@
|
|
|
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 v4.14.0 — Subtitle presets install, and frame capture puts the render page back
|
|
6
|
+
|
|
7
|
+
Lands #298. Closes #297 and #296. #295 is only the controls the Word Highlight
|
|
8
|
+
macro actually stores.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`timeline_ai.set_subtitle_preset` (`src/utils/subtitle_live.py`, `install.py`)**:
|
|
13
|
+
a fresh installer or control-panel setup never installed `resolve-advanced`'s
|
|
14
|
+
Node packages, so the first preset call died with `ERR_MODULE_NOT_FOUND`
|
|
15
|
+
for `jszip`. Setup now runs `npm install --omit=dev --omit=optional` in
|
|
16
|
+
that directory, and a missing install returns that command instead of the
|
|
17
|
+
raw Node error.
|
|
18
|
+
- **`timeline_frame` capture (`src/server.py`)**: the single-frame render
|
|
19
|
+
switched format before it had an output folder, then deleted that folder
|
|
20
|
+
even when Deliver was still pointed at it. That left the project on JPEG
|
|
21
|
+
and raised Resolve's "Render Path Inaccessible" dialog. The folder is set
|
|
22
|
+
first, the previous format and codec are restored as ids (`H264`, not the
|
|
23
|
+
Deliver label `H.264`), and the temporary folder is removed only after the
|
|
24
|
+
previous path is back. A project that never had a render path keeps the
|
|
25
|
+
folder. A timeline with subtitles is told the still does not include them:
|
|
26
|
+
`ExportSubtitle` cannot be toggled for one frame and then restored.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Word Highlight drop shadow and alignment**: `shadowEnabled`,
|
|
31
|
+
`shadowRed`/`shadowGreen`/`shadowBlue`, `shadowOffset`, `shadowSoftness`
|
|
32
|
+
and the X/Y pair, plus `horizontalAlign`/`verticalAlign`. The shadow is
|
|
33
|
+
shading element 8, a drop shadow, not a stroke on every word. The centred
|
|
34
|
+
bundled reference stores justification `3`. A dry run lists
|
|
35
|
+
`editableInputs`. Per-word scale and cue in/out transitions stay refused.
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
- `docs/guides/subtitle-track-editing.md`, `docs/SKILL.md`, and the control
|
|
40
|
+
panel setup note describe the new controls and the Node install.
|
|
41
|
+
|
|
42
|
+
### Validation
|
|
43
|
+
|
|
44
|
+
- Subtitle codec suites and the frame-capture, subtitle-gap, and Node-provision
|
|
45
|
+
unit tests pass.
|
|
46
|
+
- Live page-restore harness on Resolve Studio 19.1.3.7 (`--repeat 1`): captures
|
|
47
|
+
from every page restored format, path, mode, and page, with no warnings once
|
|
48
|
+
a render path existed. The open session was 19.1, not 21.1.
|
|
49
|
+
- The shadow and alignment round-trip was checked against the bundled Word
|
|
50
|
+
Highlight export from Studio 21.1. A live dry run on a track created with
|
|
51
|
+
`AddTrack("subtitle")` stops before those controls: that track has no Fusion
|
|
52
|
+
preset holder.
|
|
53
|
+
|
|
5
54
|
## What's New in v4.13.7 — Missing-media relink works for paths written on another OS
|
|
6
55
|
|
|
7
56
|
Lands #294, the follow-up to #293 for the Windows/macOS path work tracked in
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.
|
|
15
|
+
> 本翻译对应 v4.14.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
|
@@ -422,7 +422,9 @@ Operating rules an agent must know:
|
|
|
422
422
|
open. `timeline_ai.list_title_presets` discovers installed title/subtitle names
|
|
423
423
|
and full IDs. `preset` accepts a bundled or installed subtitle preset; omit it
|
|
424
424
|
to adjust the existing effect. `inputs` accepts common literal font/style,
|
|
425
|
-
size/position and text RGBA; highlight
|
|
425
|
+
size/position and text RGBA; highlight, outline, drop-shadow and justification
|
|
426
|
+
integers are Word Highlight-specific. The centred reference stores alignment
|
|
427
|
+
`3`. Per-word scale and cue transitions are not literal inputs and are refused.
|
|
426
428
|
Native DRT export/edit/import creates and selects a recoverable revision,
|
|
427
429
|
preserving the original. Do not ask the user to quit or relaunch for effects.
|
|
428
430
|
Render frames to verify appearance; inventory/readback alone is insufficient.
|
|
@@ -205,7 +205,9 @@ created, and render time against the active tier, and the AI-ops ledger
|
|
|
205
205
|
identity, and Media Pool inventory.
|
|
206
206
|
- **MCP** — server identity, detected Resolve scripting paths, transport
|
|
207
207
|
status (with a start button for the networked mode), and per-client install
|
|
208
|
-
status with one-click install/repair for every supported harness.
|
|
208
|
+
status with one-click install/repair for every supported harness. That
|
|
209
|
+
install also provisions the `resolve-advanced` Node packages the live
|
|
210
|
+
subtitle preset path imports.
|
|
209
211
|
- **Storage** — the analysis root, search index, and jobs database paths and
|
|
210
212
|
sizes for the active project.
|
|
211
213
|
- **Tools** — runtime helpers (ffprobe, ffmpeg, Whisper backends, OpenCV)
|
|
@@ -53,8 +53,16 @@ Full IDs disambiguate names such as the ordinary and animated `Statement`.
|
|
|
53
53
|
All subtitle templates with the inspected `Template = TextPlus` layout accept
|
|
54
54
|
the common literal `font`, `fontStyle`, `size`, `position`, `textRed`, `textGreen`,
|
|
55
55
|
`textBlue`, and `textAlpha` controls. Highlight and outline mappings remain
|
|
56
|
-
specific to Word Highlight.
|
|
57
|
-
|
|
56
|
+
specific to Word Highlight. Word Highlight also accepts its shading element 8
|
|
57
|
+
as a drop shadow (`shadowEnabled`, `shadowRed`, `shadowGreen`, `shadowBlue`,
|
|
58
|
+
`shadowOffset`, `shadowSoftness`, `shadowSoftnessX`, `shadowSoftnessY`) and
|
|
59
|
+
`horizontalAlign` / `verticalAlign`, which write the template's justification
|
|
60
|
+
integers. The centred bundled reference stores `3`. That shadow is not a stroke
|
|
61
|
+
on the unhighlighted words; `outlineEnabled` and `thickness` remain the
|
|
62
|
+
spoken-word outline. Per-word scale and cue in/out transitions are not literal
|
|
63
|
+
inputs on this macro and are refused. A dry run's `editableInputs` lists the
|
|
64
|
+
controls the holder actually accepts. Connected/animated controls, different
|
|
65
|
+
layouts and unmapped effect controls are refused. Do not guess their meanings. The live
|
|
58
66
|
matrix is documented in [title library automation](title-library-automation.md).
|
|
59
67
|
An alternate `preset_reference` can copy another decodable native holder.
|
|
60
68
|
For an unbundled installed preset, a mutation-free dry run needs an existing
|
|
@@ -238,11 +246,14 @@ UUIDs. An occupied destination requires `replace:true`; the source is unchanged.
|
|
|
238
246
|
Omit `inputs` to copy the preset bytes unchanged. To adjust an existing preset,
|
|
239
247
|
use `set_subtitle_preset` with target selectors and `inputs`.
|
|
240
248
|
|
|
241
|
-
Colour channels are 0–1, `outlineEnabled`
|
|
242
|
-
normalised text size (not points), `position` is Fusion
|
|
243
|
-
`
|
|
244
|
-
|
|
245
|
-
|
|
249
|
+
Colour channels are 0–1, `outlineEnabled` and `shadowEnabled` are 0 or 1,
|
|
250
|
+
`size` is Fusion's normalised text size (not points), `position` is Fusion
|
|
251
|
+
Center `[x,y]`, `shadowOffset` is an `[x,y]` pair in that same space,
|
|
252
|
+
`thickness` is the template's HOutlineThickness value, and `horizontalAlign`
|
|
253
|
+
/ `verticalAlign` are the template's justification integers (the centred
|
|
254
|
+
reference stores 3). Parameter editing is based on common literal Template
|
|
255
|
+
TextPlus inputs for subtitle templates; highlight, outline, shadow and
|
|
256
|
+
alignment controls are specific to Word Highlight.
|
|
246
257
|
Connected/animated inputs and unsupported layouts are refused.
|
|
247
258
|
Missing input values in the listing mean Resolve/template defaults, not zero.
|
|
248
259
|
Other supported composition envelopes can be cloned unchanged, but cannot have
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.
|
|
40
|
+
VERSION = "4.14.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
|
|
@@ -1232,6 +1232,42 @@ def advanced_is_bootable(project_dir):
|
|
|
1232
1232
|
return True
|
|
1233
1233
|
|
|
1234
1234
|
|
|
1235
|
+
def provision_advanced_node_deps(project_dir, dry_run=False):
|
|
1236
|
+
"""Install resolve-advanced runtime deps when the tree is present and they are not.
|
|
1237
|
+
|
|
1238
|
+
The live subtitle path imports those packages from this checkout. Registering
|
|
1239
|
+
the advanced server as an npx fallback does not install them for that path
|
|
1240
|
+
(issue #297). Returns a short status string and never raises: a missing npm
|
|
1241
|
+
must not abort the Python install.
|
|
1242
|
+
"""
|
|
1243
|
+
project_dir = Path(project_dir)
|
|
1244
|
+
advanced = project_dir / "resolve-advanced"
|
|
1245
|
+
if not (advanced / "package.json").is_file():
|
|
1246
|
+
return "advanced server tree is not present"
|
|
1247
|
+
if advanced_is_bootable(project_dir):
|
|
1248
|
+
return "already installed"
|
|
1249
|
+
command = "npm install --omit=dev --omit=optional"
|
|
1250
|
+
if dry_run:
|
|
1251
|
+
return f"would run {command}"
|
|
1252
|
+
npm = "npm.cmd" if platform.system() == "Windows" else "npm"
|
|
1253
|
+
try:
|
|
1254
|
+
result = subprocess.run(
|
|
1255
|
+
[npm, "install", "--omit=dev", "--omit=optional", "--no-audit", "--no-fund"],
|
|
1256
|
+
cwd=str(advanced),
|
|
1257
|
+
capture_output=True,
|
|
1258
|
+
text=True,
|
|
1259
|
+
)
|
|
1260
|
+
except FileNotFoundError:
|
|
1261
|
+
return f"npm was not found; from {advanced} run: {command}"
|
|
1262
|
+
if result.returncode != 0:
|
|
1263
|
+
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
|
1264
|
+
tail = detail[-1] if detail else f"exit {result.returncode}"
|
|
1265
|
+
return f"npm install failed: {tail}. From {advanced} run: {command}"
|
|
1266
|
+
if not advanced_is_bootable(project_dir):
|
|
1267
|
+
return f"npm install finished but Node dependencies are still missing. From {advanced} run: {command}"
|
|
1268
|
+
return "installed"
|
|
1269
|
+
|
|
1270
|
+
|
|
1235
1271
|
def build_advanced_npx_entry(python_path=None):
|
|
1236
1272
|
"""Fallback advanced entry that runs from the npm package, not this tree.
|
|
1237
1273
|
|
|
@@ -2278,6 +2314,15 @@ def main():
|
|
|
2278
2314
|
else:
|
|
2279
2315
|
print(f" Server: {yellow(str(server_path))} {dim('(file not found — check path)')}")
|
|
2280
2316
|
|
|
2317
|
+
# The live subtitle preset path imports jszip from this tree. The advanced
|
|
2318
|
+
# server entry can fall back to npx; that fallback does not install the
|
|
2319
|
+
# packages this checkout's timeline_ai call actually loads (issue #297).
|
|
2320
|
+
node_deps = provision_advanced_node_deps(project_dir, dry_run=args.dry_run)
|
|
2321
|
+
if node_deps in ("installed", "already installed"):
|
|
2322
|
+
print(f" Node deps: {green(node_deps)}")
|
|
2323
|
+
else:
|
|
2324
|
+
print(f" Node deps: {yellow(node_deps)}")
|
|
2325
|
+
|
|
2281
2326
|
# ══════════════════════════════════════════════════════════════════════
|
|
2282
2327
|
# STEP 4: Configure MCP Clients
|
|
2283
2328
|
# ══════════════════════════════════════════════════════════════════════
|
package/package.json
CHANGED
|
@@ -22,6 +22,17 @@ const inputs = z.object({
|
|
|
22
22
|
highlightRed: rgb.optional(), highlightGreen: rgb.optional(), highlightBlue: rgb.optional(),
|
|
23
23
|
outlineRed: rgb.optional(), outlineGreen: rgb.optional(), outlineBlue: rgb.optional(),
|
|
24
24
|
outlineEnabled: z.union([z.literal(0), z.literal(1)]).optional(), thickness: z.number().min(0).max(1).optional(),
|
|
25
|
+
// Word Highlight shading element 8 (a drop shadow) and its justification
|
|
26
|
+
// integers. The centred bundled reference stores 3. Per-word scale and cue
|
|
27
|
+
// in/out are not literal inputs on this macro and stay refused.
|
|
28
|
+
shadowEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
29
|
+
shadowRed: rgb.optional(), shadowGreen: rgb.optional(), shadowBlue: rgb.optional(),
|
|
30
|
+
shadowOffset: z.tuple([z.number().min(-1).max(1), z.number().min(-1).max(1)]).optional(),
|
|
31
|
+
shadowSoftness: z.number().min(0).max(20).optional(),
|
|
32
|
+
shadowSoftnessX: z.number().min(0).max(20).optional(),
|
|
33
|
+
shadowSoftnessY: z.number().min(0).max(20).optional(),
|
|
34
|
+
horizontalAlign: z.number().int().min(0).max(8).optional(),
|
|
35
|
+
verticalAlign: z.number().int().min(0).max(8).optional(),
|
|
25
36
|
}).strict();
|
|
26
37
|
export const subtitleSchemas = {
|
|
27
38
|
list_subtitle_presets: z.object({ ...target, timeline: z.string().optional() }).strict(),
|
|
@@ -76,6 +76,8 @@ export async function editSubtitleTimeline({ source, output, track = 1, inputs =
|
|
|
76
76
|
exported.zip.file(exported.entry, xml);
|
|
77
77
|
const buffer = await exported.zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
78
78
|
await fs.writeFile(output, buffer, { flag: 'wx' });
|
|
79
|
-
|
|
79
|
+
const decoded = decodePreset(patched);
|
|
80
|
+
return { output, templateId: chosen.decoded.templateId, before: before?.decoded.inputs ?? null, after: decoded.inputs,
|
|
81
|
+
editableInputs: decoded.editableInputs,
|
|
80
82
|
verification: 'exported_preset_readback', restart_required: false };
|
|
81
83
|
}
|
|
@@ -108,7 +108,7 @@ export const projectDbTool = {
|
|
|
108
108
|
'Beyond-the-API Project.db patches: list_folders, rename_folder, set_folder_color, list_clips, set_clip_marks, relayout_node_graphs, list_subtitle_styles, set_subtitle_style (basic track font/position, NOT animation presets). ' +
|
|
109
109
|
'Subtitle actions: list_subtitle_presets(projectName|projectDb, timeline?) reads linked Fusion animation presets; ' +
|
|
110
110
|
'copy_subtitle_preset(sourceProject|sourceProjectDb, sourceTimeline, sourceTrack?, projectName|projectDb, timeline, track?, replace?, inputs?); ' +
|
|
111
|
-
'set_subtitle_preset(projectName|projectDb, timeline, track?, inputs) edits Word Highlight controls: font, fontStyle, size, position:[x,y], textRed/Green/Blue/Alpha, highlightRed/Green/Blue, outlineRed/Green/Blue, outlineEnabled:0|1, thickness; RGB 0..1. ' +
|
|
111
|
+
'set_subtitle_preset(projectName|projectDb, timeline, track?, inputs) edits Word Highlight controls: font, fontStyle, size, position:[x,y], textRed/Green/Blue/Alpha, highlightRed/Green/Blue, outlineRed/Green/Blue, outlineEnabled:0|1, thickness, shadowEnabled:0|1, shadowRed/Green/Blue, shadowOffset:[x,y], shadowSoftness/X/Y, horizontalAlign/verticalAlign (justification integers; the centred reference stores 3); RGB 0..1. Shadow is shading element 8, a drop shadow, not a stroke on every word. Per-word scale and cue transitions are not literal inputs and are refused. ' +
|
|
112
112
|
'list_captions(projectName|projectDb, timeline, track?) returns text, start/end and words plus originalWords; ' +
|
|
113
113
|
'write_captions(same selectors, add:[{text,start,end,words:[{text,start,end}]}]?, replace:[{id,text,start,end,words}]?, delete:[id]?, templateCaptionId?) edits/adds/deletes in one transaction; all times are absolute timeline frames, end exclusive, caption frames integers, word frames may be fractional; ' +
|
|
114
114
|
'check_captions(same selectors, maxCharacters?:24) flags timing gaps/zero lengths/overflow heuristics. Subtitle track defaults to 1. ' +
|
|
@@ -12,6 +12,14 @@ const INPUTS = {
|
|
|
12
12
|
highlightRed: 'HighlightColorRed', highlightGreen: 'HighlightColorGreen', highlightBlue: 'HighlightColorBlue',
|
|
13
13
|
outlineRed: 'HOutlineR', outlineGreen: 'HOutlineG', outlineBlue: 'HOutlineB',
|
|
14
14
|
outlineEnabled: 'HOutline', thickness: 'HOutlineThickness',
|
|
15
|
+
// Shading element 8 on the bundled Word Highlight is a drop shadow (offset
|
|
16
|
+
// plus softness), not a stroke on the unhighlighted words. HOutline remains
|
|
17
|
+
// the spoken-word outline. The centred reference stores justification 3.
|
|
18
|
+
shadowEnabled: 'Enabled8',
|
|
19
|
+
shadowRed: 'Red8', shadowGreen: 'Green8', shadowBlue: 'Blue8',
|
|
20
|
+
shadowOffset: 'Offset8',
|
|
21
|
+
shadowSoftness: 'Softness8', shadowSoftnessX: 'SoftnessX8', shadowSoftnessY: 'SoftnessY8',
|
|
22
|
+
horizontalAlign: 'HorizontalJustificationNew', verticalAlign: 'VerticalJustificationNew',
|
|
15
23
|
};
|
|
16
24
|
const COMMON_INPUTS = Object.fromEntries(Object.entries(INPUTS).filter(([key]) =>
|
|
17
25
|
['font', 'fontStyle', 'size', 'position', 'textRed', 'textGreen', 'textBlue', 'textAlpha', 'textFillMode'].includes(key)));
|
|
@@ -116,7 +124,7 @@ function setPresetInputs(blob, changes) {
|
|
|
116
124
|
const range = inputTable(u.text);
|
|
117
125
|
let inputs = u.text.slice(range.start, range.end);
|
|
118
126
|
for (const [key, value] of Object.entries(changes)) {
|
|
119
|
-
const name =
|
|
127
|
+
const name = mapping[key];
|
|
120
128
|
if (!name) throw new Error(`unknown preset input ${key}`);
|
|
121
129
|
const r = inputRange(inputs, name), replacement = `${name} = Input { Value = ${literal(key === 'textFillMode' ? 0 : value)}, }, `;
|
|
122
130
|
if (r) {
|
|
@@ -15025,7 +15025,15 @@ def _mcp_install_payload(client_id: str) -> Dict[str, Any]:
|
|
|
15025
15025
|
)
|
|
15026
15026
|
except Exception as exc:
|
|
15027
15027
|
return {"success": False, "error": str(exc)}
|
|
15028
|
-
|
|
15028
|
+
# Same gap as install.py: writing the client entry does not install the
|
|
15029
|
+
# Node packages the live subtitle preset path imports (issue #297).
|
|
15030
|
+
node_deps = "not provisioned"
|
|
15031
|
+
try:
|
|
15032
|
+
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(paths["server_path"])))
|
|
15033
|
+
node_deps = installer.provision_advanced_node_deps(project_dir)
|
|
15034
|
+
except Exception as exc:
|
|
15035
|
+
node_deps = f"npm install failed: {exc}"
|
|
15036
|
+
return {"success": bool(ok), "message": message, "client_id": client_id, "node_deps": node_deps}
|
|
15029
15037
|
|
|
15030
15038
|
|
|
15031
15039
|
def _mcp_uninstall_payload(client_id: str) -> Dict[str, Any]:
|
package/src/granular/common.py
CHANGED
|
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
|
|
|
91
91
|
handlers=[logging.StreamHandler()],
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
-
VERSION = "4.
|
|
94
|
+
VERSION = "4.14.0"
|
|
95
95
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
96
96
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
97
97
|
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 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "4.
|
|
14
|
+
VERSION = "4.14.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -15299,6 +15299,18 @@ def _render_target_dir(proj, teardown: List[str]) -> Optional[str]:
|
|
|
15299
15299
|
return target if isinstance(target, str) and target else None
|
|
15300
15300
|
|
|
15301
15301
|
|
|
15302
|
+
def _timeline_subtitle_count(tl) -> int:
|
|
15303
|
+
"""Subtitle tracks on `tl`, or 0 when the count cannot be read."""
|
|
15304
|
+
try:
|
|
15305
|
+
count = tl.GetTrackCount("subtitle")
|
|
15306
|
+
except Exception:
|
|
15307
|
+
return 0
|
|
15308
|
+
try:
|
|
15309
|
+
return int(count or 0)
|
|
15310
|
+
except (TypeError, ValueError):
|
|
15311
|
+
return 0
|
|
15312
|
+
|
|
15313
|
+
|
|
15302
15314
|
def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
15303
15315
|
teardown: Optional[List[str]] = None):
|
|
15304
15316
|
"""Render exactly one frame — the only frame-accurate capture route.
|
|
@@ -15323,7 +15335,9 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15323
15335
|
- TargetDir is read off a throwaway render job before the capture changes
|
|
15324
15336
|
it (_render_target_dir) and written back afterwards. A project that
|
|
15325
15337
|
has never had a render target has nothing to read, and Resolve cannot
|
|
15326
|
-
clear one once set, so
|
|
15338
|
+
clear one once set, so the capture's folder is left in place. It is
|
|
15339
|
+
not deleted: Deliver pointing at a removed path raises the modal
|
|
15340
|
+
"Render Path Inaccessible".
|
|
15327
15341
|
- CustomName is never written. It can be neither read nor cleared (an
|
|
15328
15342
|
empty one is refused on 19.1.3.7), so the capture renders under
|
|
15329
15343
|
whatever name the project already produces, into a folder of its own,
|
|
@@ -15476,13 +15490,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15476
15490
|
# to read it from.
|
|
15477
15491
|
original_target = _render_target_dir(proj, teardown)
|
|
15478
15492
|
codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
|
|
15479
|
-
codec = list(codecs.values())[0] if codecs else fmt
|
|
15480
|
-
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
code="RENDER_FORMAT_REFUSED", category="api_error",
|
|
15484
|
-
state={"format": fmt, "codec": codec},
|
|
15485
|
-
)
|
|
15493
|
+
codec = list(codecs.values())[0] if isinstance(codecs, dict) and codecs else fmt
|
|
15494
|
+
# The output folder goes in BEFORE the format change. Switching format
|
|
15495
|
+
# while TargetDir is missing raises Resolve's modal "Render Path
|
|
15496
|
+
# Inaccessible — Please select a valid render path." (issue #296).
|
|
15486
15497
|
applied = proj.SetRenderSettings({
|
|
15487
15498
|
"TargetDir": folder,
|
|
15488
15499
|
"MarkIn": frame,
|
|
@@ -15498,6 +15509,13 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15498
15509
|
state={"frame": frame},
|
|
15499
15510
|
)
|
|
15500
15511
|
range_pinned = True
|
|
15512
|
+
fmt_id, codec_id = _canonical_render_format_codec(proj, fmt, codec)
|
|
15513
|
+
if not proj.SetCurrentRenderFormatAndCodec(fmt_id, codec_id):
|
|
15514
|
+
return _err(
|
|
15515
|
+
f"Resolve refused render format {fmt_id!r} with codec {codec_id!r}",
|
|
15516
|
+
code="RENDER_FORMAT_REFUSED", category="api_error",
|
|
15517
|
+
state={"format": fmt_id, "codec": codec_id},
|
|
15518
|
+
)
|
|
15501
15519
|
job = proj.AddRenderJob()
|
|
15502
15520
|
if not job:
|
|
15503
15521
|
return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
|
|
@@ -15550,6 +15568,14 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15550
15568
|
else:
|
|
15551
15569
|
with open(src_path, "rb") as handle:
|
|
15552
15570
|
data = handle.read()
|
|
15571
|
+
if _timeline_subtitle_count(tl):
|
|
15572
|
+
teardown.append(
|
|
15573
|
+
"This still does not include the subtitle track. A capture "
|
|
15574
|
+
"cannot turn subtitle burn-in on for one frame and put the "
|
|
15575
|
+
"previous ExportSubtitle setting back: there is no "
|
|
15576
|
+
"GetRenderSettings, and on Resolve 19 those keys are accepted "
|
|
15577
|
+
"and then ignored."
|
|
15578
|
+
)
|
|
15553
15579
|
return Image(data=data, format=out_format)
|
|
15554
15580
|
finally:
|
|
15555
15581
|
if job:
|
|
@@ -15578,9 +15604,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15578
15604
|
# A failed restore leaves the Deliver page on the capture's format
|
|
15579
15605
|
# and codec, which the user's next render would silently inherit.
|
|
15580
15606
|
# This runs in a teardown path, so it is logged rather than raised.
|
|
15607
|
+
# The getter reports descriptions ('H.264'); the setter wants ids
|
|
15608
|
+
# ('H264') and refuses the description (issue #296).
|
|
15609
|
+
restore_fmt, restore_codec = _canonical_render_format_codec(
|
|
15610
|
+
proj, original_fc.get("format"), original_fc.get("codec"))
|
|
15581
15611
|
try:
|
|
15582
|
-
restored_fc = proj.SetCurrentRenderFormatAndCodec(
|
|
15583
|
-
original_fc.get("format"), original_fc.get("codec"))
|
|
15612
|
+
restored_fc = proj.SetCurrentRenderFormatAndCodec(restore_fmt, restore_codec)
|
|
15584
15613
|
except Exception as exc:
|
|
15585
15614
|
restored_fc, restore_exc = False, exc
|
|
15586
15615
|
else:
|
|
@@ -15588,12 +15617,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15588
15617
|
if not restored_fc:
|
|
15589
15618
|
logger.warning(
|
|
15590
15619
|
"frame capture could not restore the render format/codec to %s/%s: %s",
|
|
15591
|
-
|
|
15620
|
+
restore_fmt, restore_codec,
|
|
15592
15621
|
restore_exc or "SetCurrentRenderFormatAndCodec returned False",
|
|
15593
15622
|
)
|
|
15594
15623
|
teardown.append(
|
|
15595
15624
|
"The render format/codec was left on the capture's "
|
|
15596
|
-
f"({fmt}); restoring {
|
|
15625
|
+
f"({fmt}); restoring {restore_fmt}/{restore_codec} "
|
|
15597
15626
|
"failed, so the next render job would inherit it."
|
|
15598
15627
|
)
|
|
15599
15628
|
# The mark range: put the user's own range back when they had one, and
|
|
@@ -15640,23 +15669,43 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
|
|
|
15640
15669
|
# The output folder, when there was one to read. Without this the
|
|
15641
15670
|
# user's next render job inherits a temporary folder that the cleanup
|
|
15642
15671
|
# just below removes.
|
|
15672
|
+
# Deleting the folder Deliver is still pointed at raises the modal
|
|
15673
|
+
# "Render Path Inaccessible". Remove it only after the user's own path
|
|
15674
|
+
# is back. A project that never had one cannot have it cleared, so the
|
|
15675
|
+
# folder stays (issue #296).
|
|
15676
|
+
target_moved = False
|
|
15643
15677
|
if range_pinned and original_target:
|
|
15644
15678
|
try:
|
|
15645
15679
|
target_restored = bool(proj.SetRenderSettings({"TargetDir": original_target}))
|
|
15646
15680
|
target_exc = None
|
|
15647
15681
|
except Exception as exc:
|
|
15648
15682
|
target_restored, target_exc = False, exc
|
|
15649
|
-
if
|
|
15683
|
+
if target_restored:
|
|
15684
|
+
target_moved = True
|
|
15685
|
+
else:
|
|
15650
15686
|
logger.warning(
|
|
15651
15687
|
"frame capture could not restore the render output folder to %s: %s",
|
|
15652
15688
|
original_target, target_exc or "SetRenderSettings returned False",
|
|
15653
15689
|
)
|
|
15654
15690
|
teardown.append(
|
|
15655
15691
|
"The render output folder was left on the capture's temporary "
|
|
15656
|
-
f"folder; restoring {original_target!r} failed.
|
|
15657
|
-
"
|
|
15692
|
+
f"folder ({folder}); restoring {original_target!r} failed. "
|
|
15693
|
+
"The folder was kept so Deliver is not left pointing at a "
|
|
15694
|
+
"missing path, which raises Resolve's 'Render Path Inaccessible' "
|
|
15695
|
+
"dialog. Set it again with render(action='set_settings', "
|
|
15696
|
+
"params={'settings': {'TargetDir': ...}})."
|
|
15658
15697
|
)
|
|
15659
|
-
|
|
15698
|
+
elif range_pinned:
|
|
15699
|
+
teardown.append(
|
|
15700
|
+
"The render output folder was left on the capture's temporary "
|
|
15701
|
+
f"folder ({folder}). This project had no render path to put back, "
|
|
15702
|
+
"and removing the folder would leave Deliver pointing at a missing "
|
|
15703
|
+
"path, which raises Resolve's 'Render Path Inaccessible' dialog. "
|
|
15704
|
+
"Set a render path with render(action='set_settings', "
|
|
15705
|
+
"params={'settings': {'TargetDir': ...}})."
|
|
15706
|
+
)
|
|
15707
|
+
if target_moved or not range_pinned:
|
|
15708
|
+
_discard_still_staging(folder)
|
|
15660
15709
|
try:
|
|
15661
15710
|
os.rmdir(base) # only ever succeeds when nothing else is using it
|
|
15662
15711
|
except OSError:
|
|
@@ -20510,6 +20559,25 @@ def _render_codec_id(proj, fmt: str, codec: str, formats: Optional[Dict[str, Any
|
|
|
20510
20559
|
return _render_codec_id_from_codecs(codecs, codec)
|
|
20511
20560
|
|
|
20512
20561
|
|
|
20562
|
+
def _canonical_render_format_codec(proj, fmt, codec):
|
|
20563
|
+
"""Ids `SetCurrentRenderFormatAndCodec` accepts.
|
|
20564
|
+
|
|
20565
|
+
GetCurrentRenderFormatAndCodec and the Deliver page labels are descriptions.
|
|
20566
|
+
On 19.1 and 21.0.4, ('mp4', 'H.264') is refused and ('mp4', 'H264') is
|
|
20567
|
+
accepted. An unrecognized value passes through for Resolve to reject.
|
|
20568
|
+
"""
|
|
20569
|
+
if not isinstance(fmt, str) or not isinstance(codec, str) or not fmt or not codec:
|
|
20570
|
+
return fmt, codec
|
|
20571
|
+
try:
|
|
20572
|
+
formats = _render_formats(proj)
|
|
20573
|
+
except Exception:
|
|
20574
|
+
formats = {}
|
|
20575
|
+
if not isinstance(formats, dict):
|
|
20576
|
+
formats = {}
|
|
20577
|
+
format_id = _render_format_id_from_formats(formats, fmt)
|
|
20578
|
+
return format_id, _render_codec_id(proj, format_id, codec, formats)
|
|
20579
|
+
|
|
20580
|
+
|
|
20513
20581
|
def _render_codecs(proj, fmt: str, formats: Optional[Dict[str, Any]] = None):
|
|
20514
20582
|
try:
|
|
20515
20583
|
return _ser(proj.GetRenderCodecs(_render_format_id(proj, fmt, formats)) or {})
|
|
@@ -27184,7 +27252,9 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
27184
27252
|
route switches back and reads the page to confirm it. If any restore does
|
|
27185
27253
|
not take, the image is followed by {"warnings": [...]} naming what was left
|
|
27186
27254
|
changed and the call that puts it back (an error result carries the same
|
|
27187
|
-
"warnings" key). No warnings block means every restore was confirmed.
|
|
27255
|
+
"warnings" key). No warnings block means every restore was confirmed. A
|
|
27256
|
+
timeline with a subtitle track also gets a warning: the still does not
|
|
27257
|
+
include that track.
|
|
27188
27258
|
"""
|
|
27189
27259
|
p = _params(params)
|
|
27190
27260
|
if action == "capture":
|
|
@@ -27215,9 +27285,11 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
|
27215
27285
|
"CustomName": True,
|
|
27216
27286
|
},
|
|
27217
27287
|
"render_settings_caveat": (
|
|
27218
|
-
"A project that has never had a render TargetDir
|
|
27219
|
-
"
|
|
27288
|
+
"A project that has never had a render TargetDir keeps the capture's "
|
|
27289
|
+
"temporary folder. Removing it would leave Deliver pointing at a "
|
|
27290
|
+
"missing path, which raises Resolve's 'Render Path Inaccessible' dialog."
|
|
27220
27291
|
),
|
|
27292
|
+
"subtitles_included": False,
|
|
27221
27293
|
}
|
|
27222
27294
|
_, tl, err = _get_tl()
|
|
27223
27295
|
if err:
|
|
@@ -27252,7 +27324,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
27252
27324
|
set_subtitle_preset(track?, inputs?, preset?, preset_reference?, reference_track?, revision_name?, dry_run?, templates_archive?)
|
|
27253
27325
|
Apply an installed subtitle preset and common literal font/style, size,
|
|
27254
27326
|
position and text RGBA controls with Resolve kept open. Word Highlight
|
|
27255
|
-
also supports mapped highlight/outline colours and thickness
|
|
27327
|
+
also supports mapped highlight/outline colours and thickness, its drop
|
|
27328
|
+
shadow (shadowEnabled, shadowRed/Green/Blue, shadowOffset, shadowSoftness
|
|
27329
|
+
and the X/Y pair) and horizontalAlign/verticalAlign justification
|
|
27330
|
+
integers (the centred reference stores 3). Per-word scale and cue
|
|
27331
|
+
in/out transitions are not literal inputs and are refused. Native
|
|
27256
27332
|
export -> codec -> native import as a selected revision; original retained.
|
|
27257
27333
|
Word Highlight and Lollipop have bundled references; other installed
|
|
27258
27334
|
presets are captured natively in a temporary title timeline. Full IDs
|
|
@@ -16,6 +16,34 @@ from src.utils.title_library import find_subtitle_preset
|
|
|
16
16
|
|
|
17
17
|
ROOT = Path(__file__).resolve().parents[2]
|
|
18
18
|
BRIDGE = ROOT / "scripts" / "edit_subtitle_timeline.mjs"
|
|
19
|
+
_ADVANCED = ROOT / "resolve-advanced"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def subtitle_codec_gap():
|
|
23
|
+
"""Why the live subtitle codec cannot run, or None when its Node deps are present.
|
|
24
|
+
|
|
25
|
+
timeline_ai set_subtitle_preset imports jszip from resolve-advanced/. The
|
|
26
|
+
advanced MCP entry falls back to npx when those packages are missing; this
|
|
27
|
+
path does not, so a fresh installer/control-panel setup that never ran
|
|
28
|
+
npm install fails with ERR_MODULE_NOT_FOUND (issue #297).
|
|
29
|
+
"""
|
|
30
|
+
manifest_path = _ADVANCED / "package.json"
|
|
31
|
+
try:
|
|
32
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return f"The subtitle preset codec is not present at {_ADVANCED}"
|
|
35
|
+
missing = []
|
|
36
|
+
for dep in (manifest.get("dependencies") or {}):
|
|
37
|
+
if not (_ADVANCED / "node_modules" / dep).is_dir() and not (ROOT / "node_modules" / dep).is_dir():
|
|
38
|
+
missing.append(dep)
|
|
39
|
+
if not missing:
|
|
40
|
+
return None
|
|
41
|
+
shown = ", ".join(missing[:8])
|
|
42
|
+
extra = f" (+{len(missing) - 8} more)" if len(missing) > 8 else ""
|
|
43
|
+
return (
|
|
44
|
+
f"Subtitle preset editing needs Node packages that are not installed ({shown}{extra}). "
|
|
45
|
+
f"From {_ADVANCED} run: npm install --omit=dev --omit=optional"
|
|
46
|
+
)
|
|
19
47
|
|
|
20
48
|
|
|
21
49
|
def _capture_installed_preset(resolve, project, preset, output):
|
|
@@ -67,6 +95,9 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
|
|
|
67
95
|
node = shutil.which("node")
|
|
68
96
|
if not node:
|
|
69
97
|
return {"success": False, "error": "Node.js is required for the subtitle preset codec"}
|
|
98
|
+
gap = subtitle_codec_gap()
|
|
99
|
+
if gap:
|
|
100
|
+
return {"success": False, "error": gap}
|
|
70
101
|
# The dispatcher coerces strings before the safety-gated helper is entered.
|
|
71
102
|
dry_run = params.get("dry_run", False)
|
|
72
103
|
if not isinstance(dry_run, bool):
|
|
@@ -116,7 +147,12 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
|
|
|
116
147
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
117
148
|
timeout=60, check=False)
|
|
118
149
|
if completed.returncode:
|
|
119
|
-
|
|
150
|
+
err = completed.stderr.strip() or "Subtitle codec failed"
|
|
151
|
+
if "ERR_MODULE_NOT_FOUND" in err or "Cannot find package" in err:
|
|
152
|
+
err = subtitle_codec_gap() or (
|
|
153
|
+
f"{err} From {_ADVANCED} run: npm install --omit=dev --omit=optional"
|
|
154
|
+
)
|
|
155
|
+
raise RuntimeError(err)
|
|
120
156
|
patch = json.loads(completed.stdout)
|
|
121
157
|
# The codec's output file is private staging, removed after success.
|
|
122
158
|
patch.pop("output", None)
|