davinci-resolve-mcp 4.13.7 → 4.14.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,77 @@
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.1 — Setup finds the Mac App Store Studio bundle
6
+
7
+ Fixes #299.
8
+
9
+ ### Fixed
10
+
11
+ - **macOS path detection (`install.py`, `src/utils/resolve_runtime.py`, `src/utils/platform.py`, `scripts/doctor.py`)**:
12
+ the Mac App Store Studio build installs as `/Applications/DaVinci Resolve Studio.app`
13
+ and keeps `Developer/Scripting` inside the bundle. Setup only knew the installer
14
+ layout and the free App Store app, and the process check required a path ending
15
+ in `DaVinci Resolve.app`, so a running Studio app was reported as not running.
16
+ The installer bundle is still preferred when it is present. App Store Studio is
17
+ next, then the free App Store app. Auto-launch and restart use that same order.
18
+
19
+ ### Validation
20
+
21
+ - Path-detection unit tests pass, including the Studio process name, the in-bundle
22
+ scripting directory, and setup's candidate table.
23
+ - Release static checks pass.
24
+ - The Mac App Store Studio bundle was not connected live. It is not installed on
25
+ the release machine. The installer app and the free App Store app are.
26
+
27
+ ## What's New in v4.14.0 — Subtitle presets install, and frame capture puts the render page back
28
+
29
+ Lands #298. Closes #297 and #296. #295 is only the controls the Word Highlight
30
+ macro actually stores.
31
+
32
+ ### Fixed
33
+
34
+ - **`timeline_ai.set_subtitle_preset` (`src/utils/subtitle_live.py`, `install.py`)**:
35
+ a fresh installer or control-panel setup never installed `resolve-advanced`'s
36
+ Node packages, so the first preset call died with `ERR_MODULE_NOT_FOUND`
37
+ for `jszip`. Setup now runs `npm install --omit=dev --omit=optional` in
38
+ that directory, and a missing install returns that command instead of the
39
+ raw Node error.
40
+ - **`timeline_frame` capture (`src/server.py`)**: the single-frame render
41
+ switched format before it had an output folder, then deleted that folder
42
+ even when Deliver was still pointed at it. That left the project on JPEG
43
+ and raised Resolve's "Render Path Inaccessible" dialog. The folder is set
44
+ first, the previous format and codec are restored as ids (`H264`, not the
45
+ Deliver label `H.264`), and the temporary folder is removed only after the
46
+ previous path is back. A project that never had a render path keeps the
47
+ folder. A timeline with subtitles is told the still does not include them:
48
+ `ExportSubtitle` cannot be toggled for one frame and then restored.
49
+
50
+ ### Added
51
+
52
+ - **Word Highlight drop shadow and alignment**: `shadowEnabled`,
53
+ `shadowRed`/`shadowGreen`/`shadowBlue`, `shadowOffset`, `shadowSoftness`
54
+ and the X/Y pair, plus `horizontalAlign`/`verticalAlign`. The shadow is
55
+ shading element 8, a drop shadow, not a stroke on every word. The centred
56
+ bundled reference stores justification `3`. A dry run lists
57
+ `editableInputs`. Per-word scale and cue in/out transitions stay refused.
58
+
59
+ ### Documentation
60
+
61
+ - `docs/guides/subtitle-track-editing.md`, `docs/SKILL.md`, and the control
62
+ panel setup note describe the new controls and the Node install.
63
+
64
+ ### Validation
65
+
66
+ - Subtitle codec suites and the frame-capture, subtitle-gap, and Node-provision
67
+ unit tests pass.
68
+ - Live page-restore harness on Resolve Studio 19.1.3.7 (`--repeat 1`): captures
69
+ from every page restored format, path, mode, and page, with no warnings once
70
+ a render path existed. The open session was 19.1, not 21.1.
71
+ - The shadow and alignment round-trip was checked against the bundled Word
72
+ Highlight export from Studio 21.1. A live dry run on a track created with
73
+ `AddTrack("subtitle")` stops before those controls: that track has no Fusion
74
+ preset holder.
75
+
5
76
  ## What's New in v4.13.7 — Missing-media relink works for paths written on another OS
6
77
 
7
78
  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
- [![Version](https://img.shields.io/badge/version-4.13.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.14.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.13.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.14.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.13.7 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.14.1 版 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/outline controls are Word Highlight-specific.
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. Connected/animated controls, different layouts and
57
- unmapped effect controls are refused. Do not guess their meanings. The live
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` is 0 or 1, `size` is Fusion's
242
- normalised text size (not points), `position` is Fusion Center `[x,y]`, and
243
- `thickness` is the template's HOutlineThickness value. Parameter editing is
244
- based on common literal Template TextPlus inputs for subtitle templates;
245
- highlight and outline controls are specific to Word Highlight.
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.13.7"
40
+ VERSION = "4.14.1"
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
@@ -225,14 +225,25 @@ def require_current_python(label="Python"):
225
225
 
226
226
  RESOLVE_PATHS = {
227
227
  "Darwin": {
228
+ # The Library scripting dir is what the installer writes. The Mac App
229
+ # Store builds never create it; their API lives inside the bundle.
230
+ # Installer first, then App Store Studio, then the free App Store app,
231
+ # so a machine with more than one keeps the installer.
228
232
  "api": [
229
233
  "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
234
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Resources/Developer/Scripting",
235
+ "/Applications/DaVinci Resolve Studio.app/Contents/Resources/Developer/Scripting",
236
+ "/Applications/DaVinci Resolve.app/Contents/Resources/Developer/Scripting",
230
237
  ],
231
238
  "lib": [
232
239
  "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
240
+ "/Applications/DaVinci Resolve Studio.app/Contents/Libraries/Fusion/fusionscript.so",
241
+ "/Applications/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
233
242
  ],
234
243
  "app": [
235
244
  "/Applications/DaVinci Resolve/DaVinci Resolve.app",
245
+ "/Applications/DaVinci Resolve Studio.app",
246
+ "/Applications/DaVinci Resolve.app",
236
247
  ],
237
248
  },
238
249
  "Windows": {
@@ -1232,6 +1243,42 @@ def advanced_is_bootable(project_dir):
1232
1243
  return True
1233
1244
 
1234
1245
 
1246
+ def provision_advanced_node_deps(project_dir, dry_run=False):
1247
+ """Install resolve-advanced runtime deps when the tree is present and they are not.
1248
+
1249
+ The live subtitle path imports those packages from this checkout. Registering
1250
+ the advanced server as an npx fallback does not install them for that path
1251
+ (issue #297). Returns a short status string and never raises: a missing npm
1252
+ must not abort the Python install.
1253
+ """
1254
+ project_dir = Path(project_dir)
1255
+ advanced = project_dir / "resolve-advanced"
1256
+ if not (advanced / "package.json").is_file():
1257
+ return "advanced server tree is not present"
1258
+ if advanced_is_bootable(project_dir):
1259
+ return "already installed"
1260
+ command = "npm install --omit=dev --omit=optional"
1261
+ if dry_run:
1262
+ return f"would run {command}"
1263
+ npm = "npm.cmd" if platform.system() == "Windows" else "npm"
1264
+ try:
1265
+ result = subprocess.run(
1266
+ [npm, "install", "--omit=dev", "--omit=optional", "--no-audit", "--no-fund"],
1267
+ cwd=str(advanced),
1268
+ capture_output=True,
1269
+ text=True,
1270
+ )
1271
+ except FileNotFoundError:
1272
+ return f"npm was not found; from {advanced} run: {command}"
1273
+ if result.returncode != 0:
1274
+ detail = (result.stderr or result.stdout or "").strip().splitlines()
1275
+ tail = detail[-1] if detail else f"exit {result.returncode}"
1276
+ return f"npm install failed: {tail}. From {advanced} run: {command}"
1277
+ if not advanced_is_bootable(project_dir):
1278
+ return f"npm install finished but Node dependencies are still missing. From {advanced} run: {command}"
1279
+ return "installed"
1280
+
1281
+
1235
1282
  def build_advanced_npx_entry(python_path=None):
1236
1283
  """Fallback advanced entry that runs from the npm package, not this tree.
1237
1284
 
@@ -2278,6 +2325,15 @@ def main():
2278
2325
  else:
2279
2326
  print(f" Server: {yellow(str(server_path))} {dim('(file not found — check path)')}")
2280
2327
 
2328
+ # The live subtitle preset path imports jszip from this tree. The advanced
2329
+ # server entry can fall back to npx; that fallback does not install the
2330
+ # packages this checkout's timeline_ai call actually loads (issue #297).
2331
+ node_deps = provision_advanced_node_deps(project_dir, dry_run=args.dry_run)
2332
+ if node_deps in ("installed", "already installed"):
2333
+ print(f" Node deps: {green(node_deps)}")
2334
+ else:
2335
+ print(f" Node deps: {yellow(node_deps)}")
2336
+
2281
2337
  # ══════════════════════════════════════════════════════════════════════
2282
2338
  # STEP 4: Configure MCP Clients
2283
2339
  # ══════════════════════════════════════════════════════════════════════
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.13.7",
3
+ "version": "4.14.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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
- return { output, templateId: chosen.decoded.templateId, before: before?.decoded.inputs ?? null, after: decodePreset(patched).inputs,
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 = INPUTS[key];
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) {
package/scripts/doctor.py CHANGED
@@ -59,9 +59,22 @@ CODEX_CONFIG = CODEX_HOME / "config.toml"
59
59
  #: what made it so confusing to diagnose (issue #106). Linux had the same bug.
60
60
  _RESOLVE_PATH_CANDIDATES: dict[str, dict[str, tuple[str, ...]]] = {
61
61
  "darwin": {
62
- "app": ("/Applications/DaVinci Resolve/DaVinci Resolve.app",),
63
- "api": ("/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",),
64
- "lib": ("/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",),
62
+ "app": (
63
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app",
64
+ "/Applications/DaVinci Resolve Studio.app",
65
+ "/Applications/DaVinci Resolve.app",
66
+ ),
67
+ "api": (
68
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
69
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Resources/Developer/Scripting",
70
+ "/Applications/DaVinci Resolve Studio.app/Contents/Resources/Developer/Scripting",
71
+ "/Applications/DaVinci Resolve.app/Contents/Resources/Developer/Scripting",
72
+ ),
73
+ "lib": (
74
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
75
+ "/Applications/DaVinci Resolve Studio.app/Contents/Libraries/Fusion/fusionscript.so",
76
+ "/Applications/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
77
+ ),
65
78
  },
66
79
  "win32": {
67
80
  "app": (r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe",),
@@ -469,7 +469,10 @@ def resolve_process_argv() -> List[str]:
469
469
  try:
470
470
  out = subprocess.run(["ps", "-Ao", "pid=,command="], capture_output=True, text=True, timeout=10)
471
471
  for line in out.stdout.splitlines():
472
- if "DaVinci Resolve.app/Contents/MacOS/Resolve" in line:
472
+ if (
473
+ "DaVinci Resolve Studio.app/Contents/MacOS/Resolve" in line
474
+ or "DaVinci Resolve.app/Contents/MacOS/Resolve" in line
475
+ ):
473
476
  return line.split()
474
477
  except Exception:
475
478
  pass
@@ -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
- return {"success": bool(ok), "message": message, "client_id": client_id}
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]:
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.13.7"
94
+ VERSION = "4.14.1"
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()}")
@@ -422,8 +422,9 @@ def _launch_resolve():
422
422
  """Launch DaVinci Resolve and wait for it to become available."""
423
423
  sys_name = platform.system().lower()
424
424
  if sys_name == "darwin":
425
- app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
426
- if not os.path.exists(app_path):
425
+ from src.utils.resolve_runtime import MACOS_RESOLVE_APPS
426
+ app_path = next((p for p in MACOS_RESOLVE_APPS if os.path.exists(p)), None)
427
+ if not app_path:
427
428
  return False
428
429
  subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
429
430
  elif sys_name == "windows":
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.13.7"
14
+ VERSION = "4.14.1"
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 there it is left on the capture's folder.
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
- if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
15481
- return _err(
15482
- f"Resolve refused render format {fmt!r} with codec {codec!r}",
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
- original_fc.get("format"), original_fc.get("codec"),
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 {original_fc.get('format')}/{original_fc.get('codec')} "
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 not target_restored:
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. Set it again with "
15657
- "render(action='set_settings', params={'settings': {'TargetDir': ...}})."
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
- _discard_still_staging(folder)
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 is left with the "
27219
- "capture's temporary folder as its TargetDir."
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. Native
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
@@ -236,7 +236,10 @@ def restart_resolve_app(resolve_obj, wait_seconds: int = 5) -> bool:
236
236
  try:
237
237
  # Get Resolve executable path for restart
238
238
  if platform.system().lower() == 'darwin':
239
- resolve_path = '/Applications/DaVinci Resolve/DaVinci Resolve.app'
239
+ from src.utils.resolve_runtime import MACOS_RESOLVE_APPS
240
+ resolve_path = next((p for p in MACOS_RESOLVE_APPS if os.path.exists(p)), None)
241
+ if resolve_path is None:
242
+ return False
240
243
  elif platform.system().lower() == 'windows':
241
244
  # Default path, may need to be customized
242
245
  resolve_path = r'C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe'
@@ -61,6 +61,14 @@ def get_resolve_paths():
61
61
  if env_api and os.path.isdir(env_api):
62
62
  api_path = env_api
63
63
  modules_path = os.path.join(api_path, "Modules")
64
+ elif platform_name == "darwin" and not os.path.isdir(api_path):
65
+ # The Mac App Store builds keep Developer/Scripting inside the bundle
66
+ # and never create the /Library path above.
67
+ for candidate in _macos_api_candidates():
68
+ if os.path.isdir(candidate):
69
+ api_path = candidate
70
+ modules_path = os.path.join(api_path, "Modules")
71
+ break
64
72
  env_lib = os.environ.get("RESOLVE_SCRIPT_LIB")
65
73
  if env_lib and os.path.isfile(env_lib):
66
74
  lib_path = env_lib
@@ -110,20 +118,35 @@ def _windows_lib_candidates():
110
118
  return candidates
111
119
 
112
120
 
121
+ def _macos_bundles():
122
+ """Installer, App Store Studio, then the free App Store app.
123
+
124
+ Imported lazily: this module is loaded while Resolve support is still
125
+ being located, and `resolve_runtime` is the list launch already uses.
126
+ """
127
+ from .resolve_runtime import MACOS_RESOLVE_APPS
128
+ return MACOS_RESOLVE_APPS
129
+
130
+
113
131
  def _macos_lib_candidates():
114
- """The App Store bundle as well as the installer one.
132
+ """Every macOS bundle's fusionscript, in launch order.
115
133
 
116
134
  The default above names `/Applications/DaVinci Resolve/DaVinci Resolve.app`.
117
- The App Store build installs to `/Applications/DaVinci Resolve.app` instead —
118
- the same class of miss as a Windows install on another drive, and
119
- `resolve_runtime.MACOS_RESOLVE_APPS` already records both.
135
+ The free App Store build installs to `/Applications/DaVinci Resolve.app`,
136
+ and App Store Studio to `/Applications/DaVinci Resolve Studio.app`.
120
137
  """
121
138
  inside_bundle = os.path.join("Contents", "Libraries", "Fusion", "fusionscript.so")
122
- bundles = (
123
- "/Applications/DaVinci Resolve/DaVinci Resolve.app",
124
- "/Applications/DaVinci Resolve.app",
125
- )
126
- return [os.path.join(bundle, inside_bundle) for bundle in bundles]
139
+ return [os.path.join(bundle, inside_bundle) for bundle in _macos_bundles()]
140
+
141
+
142
+ def _macos_api_candidates():
143
+ """In-bundle scripting dirs, for builds that never write /Library.
144
+
145
+ Checked only when the system scripting directory is absent. The installer
146
+ build's /Library path stays the default when it exists.
147
+ """
148
+ inside_bundle = os.path.join("Contents", "Resources", "Developer", "Scripting")
149
+ return [os.path.join(bundle, inside_bundle) for bundle in _macos_bundles()]
127
150
 
128
151
 
129
152
  def _linux_lib_candidates():
@@ -34,13 +34,16 @@ import subprocess
34
34
  from typing import Any, Dict, List, Optional
35
35
 
36
36
  #: Process paths that mean a DaVinci Resolve application is running. Not the
37
- #: full path: the App Store build lives at `/Applications/DaVinci Resolve.app`
38
- #: and the installer build inside `/Applications/DaVinci Resolve/`, and either
39
- #: one counts.
37
+ #: full path: the free App Store build lives at `/Applications/DaVinci
38
+ #: Resolve.app`, App Store Studio at `/Applications/DaVinci Resolve Studio.app`,
39
+ #: and the installer build inside `/Applications/DaVinci Resolve/`. The Studio
40
+ #: name is its own pattern — `DaVinci Resolve Studio.app` does not end with
41
+ #: `DaVinci Resolve.app`.
40
42
  RESOLVE_PROCESS_PATTERNS = (
41
- "DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
42
- "Resolve.exe", # Windows
43
- "/opt/resolve/bin/resolve", # Linux
43
+ "DaVinci Resolve Studio.app/Contents/MacOS/Resolve", # macOS, App Store Studio
44
+ "DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, installer and free App Store
45
+ "Resolve.exe", # Windows
46
+ "/opt/resolve/bin/resolve", # Linux
44
47
  )
45
48
 
46
49
  #: The documented headless switch. Blackmagic spells it exactly this way on all
@@ -50,10 +53,12 @@ HEADLESS_FLAG = "-nogui"
50
53
  #: Set to 1 to make this server's auto-launch start Resolve without a UI.
51
54
  ENV_PREFER_HEADLESS = "DAVINCI_RESOLVE_HEADLESS"
52
55
 
53
- #: macOS puts the two editions in different places. Installer/Studio first, to
54
- #: preserve the behaviour that a machine with both starts Studio.
56
+ #: macOS puts the editions in different places. Installer first, then App Store
57
+ #: Studio, then the free App Store app: a machine with the installer still
58
+ #: starts that one, and a machine with both App Store editions starts Studio.
55
59
  MACOS_RESOLVE_APPS = (
56
60
  "/Applications/DaVinci Resolve/DaVinci Resolve.app",
61
+ "/Applications/DaVinci Resolve Studio.app",
57
62
  "/Applications/DaVinci Resolve.app",
58
63
  )
59
64
 
@@ -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
- raise RuntimeError(completed.stderr.strip() or "Subtitle codec failed")
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)