davinci-resolve-mcp 4.13.6 → 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 CHANGED
@@ -2,6 +2,94 @@
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
+
54
+ ## What's New in v4.13.7 — Missing-media relink works for paths written on another OS
55
+
56
+ Lands #294, the follow-up to #293 for the Windows/macOS path work tracked in
57
+ #278. A project moved between macOS and Windows keeps the paths the other OS
58
+ wrote, so the missing-media diagnosis and relink plan now go by the path's own
59
+ form.
60
+
61
+ ### Fixed
62
+
63
+ - **Wanted basename (`src/server.py`, `_missing_media_diagnosis` and
64
+ `_build_relink_plan`)**: both used `os.path.basename`, which splits on the
65
+ running OS's separator only. On macOS the basename of `E:\DCIM\P1047043.MOV`
66
+ was the whole string, so a bounded search could never match. Both now split
67
+ on `/` and `\`.
68
+ - **Volumes from another OS**: after #293, `E:\` on macOS (or `/Volumes/X` on
69
+ Windows) counted as an unmounted volume. That skipped the relink search and
70
+ advised mounting a volume this OS can never mount. Such roots are now flagged
71
+ `foreign_os_path` on volume, folder, item and candidate rows, and are never
72
+ probed with `isdir`. They report the new
73
+ `primary_cause="foreign_os_path"`, which advises searching the approved roots,
74
+ and they no longer trigger `skip_search_when_volume_missing`. A same-OS
75
+ unmounted volume behaves as before. When both kinds are present, the advice
76
+ names both.
77
+ - **Sanitized relink plans**: with `sanitized=true`, `file_path` is dropped, so
78
+ the plan searched by the Resolve clip name. It now takes the basename from
79
+ `file_path_sanitized`, which keeps the filename.
80
+
81
+ ### Documentation
82
+
83
+ - `docs/SKILL.md` documents `foreign_os_path`.
84
+
85
+ ### Validation
86
+
87
+ - New `ForeignOsMediaPathTests` and basename/form tables pin the host OS, so
88
+ Linux, macOS and Windows CI all run both directions. Linux CI green on the PR.
89
+ - Full offline suite and release static checks pass on macOS.
90
+ - Diagnosis only, with no Resolve API call changed, so a live test is not
91
+ required.
92
+
5
93
  ## What's New in v4.13.6 — `drt` downgrade stamps the real Resolve build
6
94
 
7
95
  ### Fixed
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.6-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.14.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-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.6-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.14.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-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.6 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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/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.
@@ -1806,7 +1808,11 @@ missing volume roots, sample basenames, and a recommended next step. If a source
1806
1808
  volume such as a camera card is not mounted, `build_relink_plan` skips broad
1807
1809
  search by default and reports `skip_reason="missing_source_volume_not_mounted"`;
1808
1810
  mount the volume or pass `skip_search_when_volume_missing=false` only when a
1809
- bounded scan of approved roots is intentional.
1811
+ bounded scan of approved roots is intentional. Paths written on another OS
1812
+ (`E:\...` on macOS, `/Volumes/...` on Windows, after a project moves between
1813
+ machines) are flagged `foreign_os_path`: their volume can never be mounted
1814
+ here, so they report `primary_cause="foreign_os_path"`, do not hold back the
1815
+ search, and are matched by their own basename under the approved roots.
1810
1816
 
1811
1817
  Audio / Fairlight kernel actions (v2.14.0+) add live-tested audio state,
1812
1818
  mapping, voice-isolation, sync, transcription, subtitle, and Fairlight boundary
@@ -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.6"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.13.6",
3
+ "version": "4.14.0",
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) {
@@ -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.6"
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.13.6"
14
+ VERSION = "4.14.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -21,6 +21,7 @@ import inspect
21
21
  import json
22
22
  import logging
23
23
  import math
24
+ import ntpath
24
25
  import platform
25
26
  import re
26
27
  import shutil
@@ -8465,6 +8466,43 @@ def _media_path_parts(text: str) -> List[str]:
8465
8466
  return [part for part in re.split(r"[\\/]", text) if part]
8466
8467
 
8467
8468
 
8469
+ def _media_path_basename(path: Any) -> str:
8470
+ # `os.path.basename` splits on the running OS's separator only, so on macOS
8471
+ # the basename of `E:\DCIM\P1047043.MOV` is the whole string and a search
8472
+ # for it can never match. Go by the path's own form instead.
8473
+ parts = _media_path_parts(str(path or ""))
8474
+ return parts[-1] if parts else ""
8475
+
8476
+
8477
+ def _media_path_form(path: Any) -> Optional[str]:
8478
+ # "windows" for a drive letter or UNC share, "posix" for a /-rooted path,
8479
+ # None for a relative path, which says nothing about the OS that wrote it.
8480
+ text = str(path or "")
8481
+ if text.startswith("\\\\") or _WINDOWS_DRIVE_PATH.match(text):
8482
+ return "windows"
8483
+ if text.startswith("/"):
8484
+ return "posix"
8485
+ return None
8486
+
8487
+
8488
+ def _host_media_path_form() -> str:
8489
+ return "windows" if os.name == "nt" else "posix"
8490
+
8491
+
8492
+ def _media_path_is_foreign(path: Any) -> bool:
8493
+ # A path another OS wrote: `E:\` on macOS, `/Volumes/X` on Windows. Its
8494
+ # volume can never be mounted on this machine, so "mount it" is the wrong
8495
+ # advice; the media has to be found under a root this OS can see.
8496
+ form = _media_path_form(path)
8497
+ return form is not None and form != _host_media_path_form()
8498
+
8499
+
8500
+ def _media_path_dirname(text: str) -> str:
8501
+ # ntpath understands drive letters, UNC shares and both separators on any
8502
+ # OS; posix-form paths keep the running OS's own dirname.
8503
+ return ntpath.dirname(text) if _media_path_form(text) == "windows" else os.path.dirname(text)
8504
+
8505
+
8468
8506
  def _sanitize_media_path(path: Any, *, keep_filename: bool = True) -> Optional[str]:
8469
8507
  if not path:
8470
8508
  return None
@@ -8508,7 +8546,8 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8508
8546
  for row in missing_rows:
8509
8547
  file_path = row.get("file_path")
8510
8548
  media_pool_item_id = row.get("media_pool_item_id") or file_path or row.get("name")
8511
- basename = os.path.basename(str(file_path or row.get("media_pool_item_name") or row.get("name") or ""))
8549
+ basename = _media_path_basename(file_path or row.get("media_pool_item_name") or row.get("name"))
8550
+ foreign = _media_path_is_foreign(file_path)
8512
8551
  item = by_media_pool_item.setdefault(
8513
8552
  str(media_pool_item_id),
8514
8553
  {
@@ -8517,6 +8556,7 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8517
8556
  "wanted_basename": basename,
8518
8557
  "file_path_sanitized": _sanitize_media_path(file_path),
8519
8558
  "volume_root_sanitized": _sanitize_media_path(_media_path_volume_root(file_path), keep_filename=False),
8559
+ "foreign_os_path": foreign,
8520
8560
  "timeline_occurrence_count": 0,
8521
8561
  "track_refs": [],
8522
8562
  },
@@ -8536,7 +8576,11 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8536
8576
  volume_root,
8537
8577
  {
8538
8578
  "volume_root_sanitized": _sanitize_media_path(volume_root, keep_filename=False),
8539
- "mounted": os.path.isdir(volume_root),
8579
+ # Never probed: this OS reads the name as something else
8580
+ # (`/Volumes/X` is a folder on the current Windows drive,
8581
+ # `E:\` a relative name on macOS).
8582
+ "mounted": False if foreign else os.path.isdir(volume_root),
8583
+ "foreign_os_path": foreign,
8540
8584
  "clip_count": 0,
8541
8585
  "sample_basenames": [],
8542
8586
  },
@@ -8545,13 +8589,14 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8545
8589
  if basename and basename not in volume_row["sample_basenames"] and len(volume_row["sample_basenames"]) < 8:
8546
8590
  volume_row["sample_basenames"].append(basename)
8547
8591
 
8548
- folder = os.path.dirname(str(file_path)) if file_path else ""
8592
+ folder = _media_path_dirname(str(file_path)) if file_path else ""
8549
8593
  if folder:
8550
8594
  folder_row = missing_folders.setdefault(
8551
8595
  folder,
8552
8596
  {
8553
8597
  "folder_sanitized": _sanitize_media_path(folder, keep_filename=False),
8554
- "exists": os.path.isdir(folder),
8598
+ "exists": False if foreign else os.path.isdir(folder),
8599
+ "foreign_os_path": foreign,
8555
8600
  "clip_count": 0,
8556
8601
  "sample_basenames": [],
8557
8602
  },
@@ -8563,14 +8608,28 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8563
8608
  unique_items = list(by_media_pool_item.values())
8564
8609
  missing_volume_rows = list(missing_volumes.values())
8565
8610
  missing_folder_rows = list(missing_folders.values())
8566
- absent_volumes = [row for row in missing_volume_rows if not row.get("mounted")]
8611
+ foreign_volumes = [row for row in missing_volume_rows if row.get("foreign_os_path")]
8612
+ absent_volumes = [row for row in missing_volume_rows if not row.get("mounted") and not row.get("foreign_os_path")]
8567
8613
  absent_folders = [row for row in missing_folder_rows if not row.get("exists")]
8614
+ foreign_labels = ", ".join(row["volume_root_sanitized"] for row in foreign_volumes[:3])
8568
8615
  primary_cause = "none"
8569
8616
  recommended_next_step = "No offline media detected."
8570
8617
  if absent_volumes:
8571
8618
  primary_cause = "volume_not_mounted"
8572
8619
  labels = ", ".join(row["volume_root_sanitized"] for row in absent_volumes[:3])
8573
8620
  recommended_next_step = f"Mount the missing volume(s): {labels}."
8621
+ if foreign_volumes:
8622
+ recommended_next_step += (
8623
+ f" Paths written on another operating system ({foreign_labels}) cannot be mounted here;"
8624
+ " relink those from approved media roots by basename."
8625
+ )
8626
+ elif foreign_volumes:
8627
+ primary_cause = "foreign_os_path"
8628
+ recommended_next_step = (
8629
+ f"These media paths were written on another operating system, so their volume(s)"
8630
+ f" ({foreign_labels}) cannot be mounted here. Search approved media roots for the wanted basenames"
8631
+ " (build_relink_plan), then relink with media_pool.safe_relink."
8632
+ )
8574
8633
  elif absent_folders:
8575
8634
  primary_cause = "folder_not_found"
8576
8635
  labels = ", ".join(row["folder_sanitized"] for row in absent_folders[:3])
@@ -8731,8 +8790,10 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8731
8790
  return _err(f"search_roots must be existing directories: {invalid}")
8732
8791
  missing_report = _detect_missing_media(tl, p)
8733
8792
  diagnosis = missing_report.get("diagnosis") or _missing_media_diagnosis(missing_report.get("missing", []))
8793
+ # A volume another OS wrote can never be mounted here, so it never holds the
8794
+ # search back: approved roots are the only place its media can turn up.
8734
8795
  if _coerce_bool(p.get("skip_search_when_volume_missing"), True) and any(
8735
- not row.get("mounted") for row in diagnosis.get("missing_volumes", [])
8796
+ not row.get("mounted") and not row.get("foreign_os_path") for row in diagnosis.get("missing_volumes", [])
8736
8797
  ):
8737
8798
  return {
8738
8799
  "success": True,
@@ -8749,7 +8810,10 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8749
8810
  candidates = []
8750
8811
  rows_by_basename: Dict[str, List[Dict[str, Any]]] = {}
8751
8812
  for row in missing_report.get("missing", []):
8752
- wanted = os.path.basename(str(row.get("file_path") or row.get("media_pool_item_name") or row.get("name") or ""))
8813
+ # A sanitized report drops `file_path`; its sanitized form keeps the
8814
+ # filename and the root's form, which is all the search needs.
8815
+ path = row.get("file_path") or row.get("file_path_sanitized")
8816
+ wanted = _media_path_basename(path or row.get("media_pool_item_name") or row.get("name"))
8753
8817
  if wanted:
8754
8818
  rows_by_basename.setdefault(wanted, []).append(row)
8755
8819
  for wanted, rows in rows_by_basename.items():
@@ -8763,6 +8827,7 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8763
8827
  {
8764
8828
  **row,
8765
8829
  "wanted_basename": wanted,
8830
+ "foreign_os_path": _media_path_is_foreign(row.get("file_path") or row.get("file_path_sanitized")),
8766
8831
  "timeline_occurrence_count": len(rows),
8767
8832
  "candidate_paths": [] if _coerce_bool(p.get("sanitize_paths")) or _coerce_bool(p.get("sanitized")) else matches,
8768
8833
  "candidate_paths_sanitized": sanitized_matches,
@@ -15234,6 +15299,18 @@ def _render_target_dir(proj, teardown: List[str]) -> Optional[str]:
15234
15299
  return target if isinstance(target, str) and target else None
15235
15300
 
15236
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
+
15237
15314
  def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15238
15315
  teardown: Optional[List[str]] = None):
15239
15316
  """Render exactly one frame — the only frame-accurate capture route.
@@ -15258,7 +15335,9 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15258
15335
  - TargetDir is read off a throwaway render job before the capture changes
15259
15336
  it (_render_target_dir) and written back afterwards. A project that
15260
15337
  has never had a render target has nothing to read, and Resolve cannot
15261
- 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".
15262
15341
  - CustomName is never written. It can be neither read nor cleared (an
15263
15342
  empty one is refused on 19.1.3.7), so the capture renders under
15264
15343
  whatever name the project already produces, into a folder of its own,
@@ -15411,13 +15490,10 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15411
15490
  # to read it from.
15412
15491
  original_target = _render_target_dir(proj, teardown)
15413
15492
  codecs = proj.GetRenderCodecs("JPEG" if fmt == "jpg" else fmt.upper()) or {}
15414
- codec = list(codecs.values())[0] if codecs else fmt
15415
- if not proj.SetCurrentRenderFormatAndCodec(fmt, codec):
15416
- return _err(
15417
- f"Resolve refused render format {fmt!r} with codec {codec!r}",
15418
- code="RENDER_FORMAT_REFUSED", category="api_error",
15419
- state={"format": fmt, "codec": codec},
15420
- )
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).
15421
15497
  applied = proj.SetRenderSettings({
15422
15498
  "TargetDir": folder,
15423
15499
  "MarkIn": frame,
@@ -15433,6 +15509,13 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15433
15509
  state={"frame": frame},
15434
15510
  )
15435
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
+ )
15436
15519
  job = proj.AddRenderJob()
15437
15520
  if not job:
15438
15521
  return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
@@ -15485,6 +15568,14 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15485
15568
  else:
15486
15569
  with open(src_path, "rb") as handle:
15487
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
+ )
15488
15579
  return Image(data=data, format=out_format)
15489
15580
  finally:
15490
15581
  if job:
@@ -15513,9 +15604,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15513
15604
  # A failed restore leaves the Deliver page on the capture's format
15514
15605
  # and codec, which the user's next render would silently inherit.
15515
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"))
15516
15611
  try:
15517
- restored_fc = proj.SetCurrentRenderFormatAndCodec(
15518
- original_fc.get("format"), original_fc.get("codec"))
15612
+ restored_fc = proj.SetCurrentRenderFormatAndCodec(restore_fmt, restore_codec)
15519
15613
  except Exception as exc:
15520
15614
  restored_fc, restore_exc = False, exc
15521
15615
  else:
@@ -15523,12 +15617,12 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15523
15617
  if not restored_fc:
15524
15618
  logger.warning(
15525
15619
  "frame capture could not restore the render format/codec to %s/%s: %s",
15526
- original_fc.get("format"), original_fc.get("codec"),
15620
+ restore_fmt, restore_codec,
15527
15621
  restore_exc or "SetCurrentRenderFormatAndCodec returned False",
15528
15622
  )
15529
15623
  teardown.append(
15530
15624
  "The render format/codec was left on the capture's "
15531
- f"({fmt}); restoring {original_fc.get('format')}/{original_fc.get('codec')} "
15625
+ f"({fmt}); restoring {restore_fmt}/{restore_codec} "
15532
15626
  "failed, so the next render job would inherit it."
15533
15627
  )
15534
15628
  # The mark range: put the user's own range back when they had one, and
@@ -15575,23 +15669,43 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15575
15669
  # The output folder, when there was one to read. Without this the
15576
15670
  # user's next render job inherits a temporary folder that the cleanup
15577
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
15578
15677
  if range_pinned and original_target:
15579
15678
  try:
15580
15679
  target_restored = bool(proj.SetRenderSettings({"TargetDir": original_target}))
15581
15680
  target_exc = None
15582
15681
  except Exception as exc:
15583
15682
  target_restored, target_exc = False, exc
15584
- if not target_restored:
15683
+ if target_restored:
15684
+ target_moved = True
15685
+ else:
15585
15686
  logger.warning(
15586
15687
  "frame capture could not restore the render output folder to %s: %s",
15587
15688
  original_target, target_exc or "SetRenderSettings returned False",
15588
15689
  )
15589
15690
  teardown.append(
15590
15691
  "The render output folder was left on the capture's temporary "
15591
- f"folder; restoring {original_target!r} failed. Set it again with "
15592
- "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': ...}})."
15593
15697
  )
15594
- _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)
15595
15709
  try:
15596
15710
  os.rmdir(base) # only ever succeeds when nothing else is using it
15597
15711
  except OSError:
@@ -20445,6 +20559,25 @@ def _render_codec_id(proj, fmt: str, codec: str, formats: Optional[Dict[str, Any
20445
20559
  return _render_codec_id_from_codecs(codecs, codec)
20446
20560
 
20447
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
+
20448
20581
  def _render_codecs(proj, fmt: str, formats: Optional[Dict[str, Any]] = None):
20449
20582
  try:
20450
20583
  return _ser(proj.GetRenderCodecs(_render_format_id(proj, fmt, formats)) or {})
@@ -27119,7 +27252,9 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
27119
27252
  route switches back and reads the page to confirm it. If any restore does
27120
27253
  not take, the image is followed by {"warnings": [...]} naming what was left
27121
27254
  changed and the call that puts it back (an error result carries the same
27122
- "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.
27123
27258
  """
27124
27259
  p = _params(params)
27125
27260
  if action == "capture":
@@ -27150,9 +27285,11 @@ def timeline_frame(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
27150
27285
  "CustomName": True,
27151
27286
  },
27152
27287
  "render_settings_caveat": (
27153
- "A project that has never had a render TargetDir is left with the "
27154
- "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."
27155
27291
  ),
27292
+ "subtitles_included": False,
27156
27293
  }
27157
27294
  _, tl, err = _get_tl()
27158
27295
  if err:
@@ -27187,7 +27324,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
27187
27324
  set_subtitle_preset(track?, inputs?, preset?, preset_reference?, reference_track?, revision_name?, dry_run?, templates_archive?)
27188
27325
  Apply an installed subtitle preset and common literal font/style, size,
27189
27326
  position and text RGBA controls with Resolve kept open. Word Highlight
27190
- 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
27191
27332
  export -> codec -> native import as a selected revision; original retained.
27192
27333
  Word Highlight and Lollipop have bundled references; other installed
27193
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
- 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)