davinci-resolve-mcp 4.13.7 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v4.14.0 — Subtitle presets install, and frame capture puts the render page back
6
+
7
+ Lands #298. Closes #297 and #296. #295 is only the controls the Word Highlight
8
+ macro actually stores.
9
+
10
+ ### Fixed
11
+
12
+ - **`timeline_ai.set_subtitle_preset` (`src/utils/subtitle_live.py`, `install.py`)**:
13
+ a fresh installer or control-panel setup never installed `resolve-advanced`'s
14
+ Node packages, so the first preset call died with `ERR_MODULE_NOT_FOUND`
15
+ for `jszip`. Setup now runs `npm install --omit=dev --omit=optional` in
16
+ that directory, and a missing install returns that command instead of the
17
+ raw Node error.
18
+ - **`timeline_frame` capture (`src/server.py`)**: the single-frame render
19
+ switched format before it had an output folder, then deleted that folder
20
+ even when Deliver was still pointed at it. That left the project on JPEG
21
+ and raised Resolve's "Render Path Inaccessible" dialog. The folder is set
22
+ first, the previous format and codec are restored as ids (`H264`, not the
23
+ Deliver label `H.264`), and the temporary folder is removed only after the
24
+ previous path is back. A project that never had a render path keeps the
25
+ folder. A timeline with subtitles is told the still does not include them:
26
+ `ExportSubtitle` cannot be toggled for one frame and then restored.
27
+
28
+ ### Added
29
+
30
+ - **Word Highlight drop shadow and alignment**: `shadowEnabled`,
31
+ `shadowRed`/`shadowGreen`/`shadowBlue`, `shadowOffset`, `shadowSoftness`
32
+ and the X/Y pair, plus `horizontalAlign`/`verticalAlign`. The shadow is
33
+ shading element 8, a drop shadow, not a stroke on every word. The centred
34
+ bundled reference stores justification `3`. A dry run lists
35
+ `editableInputs`. Per-word scale and cue in/out transitions stay refused.
36
+
37
+ ### Documentation
38
+
39
+ - `docs/guides/subtitle-track-editing.md`, `docs/SKILL.md`, and the control
40
+ panel setup note describe the new controls and the Node install.
41
+
42
+ ### Validation
43
+
44
+ - Subtitle codec suites and the frame-capture, subtitle-gap, and Node-provision
45
+ unit tests pass.
46
+ - Live page-restore harness on Resolve Studio 19.1.3.7 (`--repeat 1`): captures
47
+ from every page restored format, path, mode, and page, with no warnings once
48
+ a render path existed. The open session was 19.1, not 21.1.
49
+ - The shadow and alignment round-trip was checked against the bundled Word
50
+ Highlight export from Studio 21.1. A live dry run on a track created with
51
+ `AddTrack("subtitle")` stops before those controls: that track has no Fusion
52
+ preset holder.
53
+
5
54
  ## What's New in v4.13.7 — Missing-media relink works for paths written on another OS
6
55
 
7
56
  Lands #294, the follow-up to #293 for the Windows/macOS path work tracked in
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![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.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.7-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.7 版 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.
@@ -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.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.7",
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.7"
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.7"
14
+ VERSION = "4.14.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -15299,6 +15299,18 @@ def _render_target_dir(proj, teardown: List[str]) -> Optional[str]:
15299
15299
  return target if isinstance(target, str) and target else None
15300
15300
 
15301
15301
 
15302
+ def _timeline_subtitle_count(tl) -> int:
15303
+ """Subtitle tracks on `tl`, or 0 when the count cannot be read."""
15304
+ try:
15305
+ count = tl.GetTrackCount("subtitle")
15306
+ except Exception:
15307
+ return 0
15308
+ try:
15309
+ return int(count or 0)
15310
+ except (TypeError, ValueError):
15311
+ return 0
15312
+
15313
+
15302
15314
  def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15303
15315
  teardown: Optional[List[str]] = None):
15304
15316
  """Render exactly one frame — the only frame-accurate capture route.
@@ -15323,7 +15335,9 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any],
15323
15335
  - TargetDir is read off a throwaway render job before the capture changes
15324
15336
  it (_render_target_dir) and written back afterwards. A project that
15325
15337
  has never had a render target has nothing to read, and Resolve cannot
15326
- clear one once set, so 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
@@ -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)