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 +88 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +8 -2
- package/docs/guides/control-panel.md +3 -1
- package/docs/guides/subtitle-track-editing.md +18 -7
- package/install.py +46 -1
- package/package.json +1 -1
- package/resolve-advanced/server/subtitle-db.mjs +11 -0
- package/resolve-advanced/server/subtitle-timeline.mjs +3 -1
- package/resolve-advanced/server/tools/project_db.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/subtitle-preset.js +9 -1
- package/src/analysis_dashboard.py +9 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +169 -28
- package/src/utils/subtitle_live.py +37 -1
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.
|
|
15
|
+
> 本翻译对应 v4.14.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/docs/SKILL.md
CHANGED
|
@@ -422,7 +422,9 @@ Operating rules an agent must know:
|
|
|
422
422
|
open. `timeline_ai.list_title_presets` discovers installed title/subtitle names
|
|
423
423
|
and full IDs. `preset` accepts a bundled or installed subtitle preset; omit it
|
|
424
424
|
to adjust the existing effect. `inputs` accepts common literal font/style,
|
|
425
|
-
size/position and text RGBA; highlight
|
|
425
|
+
size/position and text RGBA; highlight, outline, drop-shadow and justification
|
|
426
|
+
integers are Word Highlight-specific. The centred reference stores alignment
|
|
427
|
+
`3`. Per-word scale and cue transitions are not literal inputs and are refused.
|
|
426
428
|
Native DRT export/edit/import creates and selects a recoverable revision,
|
|
427
429
|
preserving the original. Do not ask the user to quit or relaunch for effects.
|
|
428
430
|
Render frames to verify appearance; inventory/readback alone is insufficient.
|
|
@@ -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.
|
|
57
|
-
|
|
56
|
+
specific to Word Highlight. Word Highlight also accepts its shading element 8
|
|
57
|
+
as a drop shadow (`shadowEnabled`, `shadowRed`, `shadowGreen`, `shadowBlue`,
|
|
58
|
+
`shadowOffset`, `shadowSoftness`, `shadowSoftnessX`, `shadowSoftnessY`) and
|
|
59
|
+
`horizontalAlign` / `verticalAlign`, which write the template's justification
|
|
60
|
+
integers. The centred bundled reference stores `3`. That shadow is not a stroke
|
|
61
|
+
on the unhighlighted words; `outlineEnabled` and `thickness` remain the
|
|
62
|
+
spoken-word outline. Per-word scale and cue in/out transitions are not literal
|
|
63
|
+
inputs on this macro and are refused. A dry run's `editableInputs` lists the
|
|
64
|
+
controls the holder actually accepts. Connected/animated controls, different
|
|
65
|
+
layouts and unmapped effect controls are refused. Do not guess their meanings. The live
|
|
58
66
|
matrix is documented in [title library automation](title-library-automation.md).
|
|
59
67
|
An alternate `preset_reference` can copy another decodable native holder.
|
|
60
68
|
For an unbundled installed preset, a mutation-free dry run needs an existing
|
|
@@ -238,11 +246,14 @@ UUIDs. An occupied destination requires `replace:true`; the source is unchanged.
|
|
|
238
246
|
Omit `inputs` to copy the preset bytes unchanged. To adjust an existing preset,
|
|
239
247
|
use `set_subtitle_preset` with target selectors and `inputs`.
|
|
240
248
|
|
|
241
|
-
Colour channels are 0–1, `outlineEnabled`
|
|
242
|
-
normalised text size (not points), `position` is Fusion
|
|
243
|
-
`
|
|
244
|
-
|
|
245
|
-
|
|
249
|
+
Colour channels are 0–1, `outlineEnabled` and `shadowEnabled` are 0 or 1,
|
|
250
|
+
`size` is Fusion's normalised text size (not points), `position` is Fusion
|
|
251
|
+
Center `[x,y]`, `shadowOffset` is an `[x,y]` pair in that same space,
|
|
252
|
+
`thickness` is the template's HOutlineThickness value, and `horizontalAlign`
|
|
253
|
+
/ `verticalAlign` are the template's justification integers (the centred
|
|
254
|
+
reference stores 3). Parameter editing is based on common literal Template
|
|
255
|
+
TextPlus inputs for subtitle templates; highlight, outline, shadow and
|
|
256
|
+
alignment controls are specific to Word Highlight.
|
|
246
257
|
Connected/animated inputs and unsupported layouts are refused.
|
|
247
258
|
Missing input values in the listing mean Resolve/template defaults, not zero.
|
|
248
259
|
Other supported composition envelopes can be cloned unchanged, but cannot have
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.
|
|
40
|
+
VERSION = "4.14.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
|
@@ -1232,6 +1232,42 @@ def advanced_is_bootable(project_dir):
|
|
|
1232
1232
|
return True
|
|
1233
1233
|
|
|
1234
1234
|
|
|
1235
|
+
def provision_advanced_node_deps(project_dir, dry_run=False):
|
|
1236
|
+
"""Install resolve-advanced runtime deps when the tree is present and they are not.
|
|
1237
|
+
|
|
1238
|
+
The live subtitle path imports those packages from this checkout. Registering
|
|
1239
|
+
the advanced server as an npx fallback does not install them for that path
|
|
1240
|
+
(issue #297). Returns a short status string and never raises: a missing npm
|
|
1241
|
+
must not abort the Python install.
|
|
1242
|
+
"""
|
|
1243
|
+
project_dir = Path(project_dir)
|
|
1244
|
+
advanced = project_dir / "resolve-advanced"
|
|
1245
|
+
if not (advanced / "package.json").is_file():
|
|
1246
|
+
return "advanced server tree is not present"
|
|
1247
|
+
if advanced_is_bootable(project_dir):
|
|
1248
|
+
return "already installed"
|
|
1249
|
+
command = "npm install --omit=dev --omit=optional"
|
|
1250
|
+
if dry_run:
|
|
1251
|
+
return f"would run {command}"
|
|
1252
|
+
npm = "npm.cmd" if platform.system() == "Windows" else "npm"
|
|
1253
|
+
try:
|
|
1254
|
+
result = subprocess.run(
|
|
1255
|
+
[npm, "install", "--omit=dev", "--omit=optional", "--no-audit", "--no-fund"],
|
|
1256
|
+
cwd=str(advanced),
|
|
1257
|
+
capture_output=True,
|
|
1258
|
+
text=True,
|
|
1259
|
+
)
|
|
1260
|
+
except FileNotFoundError:
|
|
1261
|
+
return f"npm was not found; from {advanced} run: {command}"
|
|
1262
|
+
if result.returncode != 0:
|
|
1263
|
+
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
|
1264
|
+
tail = detail[-1] if detail else f"exit {result.returncode}"
|
|
1265
|
+
return f"npm install failed: {tail}. From {advanced} run: {command}"
|
|
1266
|
+
if not advanced_is_bootable(project_dir):
|
|
1267
|
+
return f"npm install finished but Node dependencies are still missing. From {advanced} run: {command}"
|
|
1268
|
+
return "installed"
|
|
1269
|
+
|
|
1270
|
+
|
|
1235
1271
|
def build_advanced_npx_entry(python_path=None):
|
|
1236
1272
|
"""Fallback advanced entry that runs from the npm package, not this tree.
|
|
1237
1273
|
|
|
@@ -2278,6 +2314,15 @@ def main():
|
|
|
2278
2314
|
else:
|
|
2279
2315
|
print(f" Server: {yellow(str(server_path))} {dim('(file not found — check path)')}")
|
|
2280
2316
|
|
|
2317
|
+
# The live subtitle preset path imports jszip from this tree. The advanced
|
|
2318
|
+
# server entry can fall back to npx; that fallback does not install the
|
|
2319
|
+
# packages this checkout's timeline_ai call actually loads (issue #297).
|
|
2320
|
+
node_deps = provision_advanced_node_deps(project_dir, dry_run=args.dry_run)
|
|
2321
|
+
if node_deps in ("installed", "already installed"):
|
|
2322
|
+
print(f" Node deps: {green(node_deps)}")
|
|
2323
|
+
else:
|
|
2324
|
+
print(f" Node deps: {yellow(node_deps)}")
|
|
2325
|
+
|
|
2281
2326
|
# ══════════════════════════════════════════════════════════════════════
|
|
2282
2327
|
# STEP 4: Configure MCP Clients
|
|
2283
2328
|
# ══════════════════════════════════════════════════════════════════════
|
package/package.json
CHANGED
|
@@ -22,6 +22,17 @@ const inputs = z.object({
|
|
|
22
22
|
highlightRed: rgb.optional(), highlightGreen: rgb.optional(), highlightBlue: rgb.optional(),
|
|
23
23
|
outlineRed: rgb.optional(), outlineGreen: rgb.optional(), outlineBlue: rgb.optional(),
|
|
24
24
|
outlineEnabled: z.union([z.literal(0), z.literal(1)]).optional(), thickness: z.number().min(0).max(1).optional(),
|
|
25
|
+
// Word Highlight shading element 8 (a drop shadow) and its justification
|
|
26
|
+
// integers. The centred bundled reference stores 3. Per-word scale and cue
|
|
27
|
+
// in/out are not literal inputs on this macro and stay refused.
|
|
28
|
+
shadowEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
29
|
+
shadowRed: rgb.optional(), shadowGreen: rgb.optional(), shadowBlue: rgb.optional(),
|
|
30
|
+
shadowOffset: z.tuple([z.number().min(-1).max(1), z.number().min(-1).max(1)]).optional(),
|
|
31
|
+
shadowSoftness: z.number().min(0).max(20).optional(),
|
|
32
|
+
shadowSoftnessX: z.number().min(0).max(20).optional(),
|
|
33
|
+
shadowSoftnessY: z.number().min(0).max(20).optional(),
|
|
34
|
+
horizontalAlign: z.number().int().min(0).max(8).optional(),
|
|
35
|
+
verticalAlign: z.number().int().min(0).max(8).optional(),
|
|
25
36
|
}).strict();
|
|
26
37
|
export const subtitleSchemas = {
|
|
27
38
|
list_subtitle_presets: z.object({ ...target, timeline: z.string().optional() }).strict(),
|
|
@@ -76,6 +76,8 @@ export async function editSubtitleTimeline({ source, output, track = 1, inputs =
|
|
|
76
76
|
exported.zip.file(exported.entry, xml);
|
|
77
77
|
const buffer = await exported.zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
78
78
|
await fs.writeFile(output, buffer, { flag: 'wx' });
|
|
79
|
-
|
|
79
|
+
const decoded = decodePreset(patched);
|
|
80
|
+
return { output, templateId: chosen.decoded.templateId, before: before?.decoded.inputs ?? null, after: decoded.inputs,
|
|
81
|
+
editableInputs: decoded.editableInputs,
|
|
80
82
|
verification: 'exported_preset_readback', restart_required: false };
|
|
81
83
|
}
|
|
@@ -108,7 +108,7 @@ export const projectDbTool = {
|
|
|
108
108
|
'Beyond-the-API Project.db patches: list_folders, rename_folder, set_folder_color, list_clips, set_clip_marks, relayout_node_graphs, list_subtitle_styles, set_subtitle_style (basic track font/position, NOT animation presets). ' +
|
|
109
109
|
'Subtitle actions: list_subtitle_presets(projectName|projectDb, timeline?) reads linked Fusion animation presets; ' +
|
|
110
110
|
'copy_subtitle_preset(sourceProject|sourceProjectDb, sourceTimeline, sourceTrack?, projectName|projectDb, timeline, track?, replace?, inputs?); ' +
|
|
111
|
-
'set_subtitle_preset(projectName|projectDb, timeline, track?, inputs) edits Word Highlight controls: font, fontStyle, size, position:[x,y], textRed/Green/Blue/Alpha, highlightRed/Green/Blue, outlineRed/Green/Blue, outlineEnabled:0|1, thickness; RGB 0..1. ' +
|
|
111
|
+
'set_subtitle_preset(projectName|projectDb, timeline, track?, inputs) edits Word Highlight controls: font, fontStyle, size, position:[x,y], textRed/Green/Blue/Alpha, highlightRed/Green/Blue, outlineRed/Green/Blue, outlineEnabled:0|1, thickness, shadowEnabled:0|1, shadowRed/Green/Blue, shadowOffset:[x,y], shadowSoftness/X/Y, horizontalAlign/verticalAlign (justification integers; the centred reference stores 3); RGB 0..1. Shadow is shading element 8, a drop shadow, not a stroke on every word. Per-word scale and cue transitions are not literal inputs and are refused. ' +
|
|
112
112
|
'list_captions(projectName|projectDb, timeline, track?) returns text, start/end and words plus originalWords; ' +
|
|
113
113
|
'write_captions(same selectors, add:[{text,start,end,words:[{text,start,end}]}]?, replace:[{id,text,start,end,words}]?, delete:[id]?, templateCaptionId?) edits/adds/deletes in one transaction; all times are absolute timeline frames, end exclusive, caption frames integers, word frames may be fractional; ' +
|
|
114
114
|
'check_captions(same selectors, maxCharacters?:24) flags timing gaps/zero lengths/overflow heuristics. Subtitle track defaults to 1. ' +
|
|
@@ -12,6 +12,14 @@ const INPUTS = {
|
|
|
12
12
|
highlightRed: 'HighlightColorRed', highlightGreen: 'HighlightColorGreen', highlightBlue: 'HighlightColorBlue',
|
|
13
13
|
outlineRed: 'HOutlineR', outlineGreen: 'HOutlineG', outlineBlue: 'HOutlineB',
|
|
14
14
|
outlineEnabled: 'HOutline', thickness: 'HOutlineThickness',
|
|
15
|
+
// Shading element 8 on the bundled Word Highlight is a drop shadow (offset
|
|
16
|
+
// plus softness), not a stroke on the unhighlighted words. HOutline remains
|
|
17
|
+
// the spoken-word outline. The centred reference stores justification 3.
|
|
18
|
+
shadowEnabled: 'Enabled8',
|
|
19
|
+
shadowRed: 'Red8', shadowGreen: 'Green8', shadowBlue: 'Blue8',
|
|
20
|
+
shadowOffset: 'Offset8',
|
|
21
|
+
shadowSoftness: 'Softness8', shadowSoftnessX: 'SoftnessX8', shadowSoftnessY: 'SoftnessY8',
|
|
22
|
+
horizontalAlign: 'HorizontalJustificationNew', verticalAlign: 'VerticalJustificationNew',
|
|
15
23
|
};
|
|
16
24
|
const COMMON_INPUTS = Object.fromEntries(Object.entries(INPUTS).filter(([key]) =>
|
|
17
25
|
['font', 'fontStyle', 'size', 'position', 'textRed', 'textGreen', 'textBlue', 'textAlpha', 'textFillMode'].includes(key)));
|
|
@@ -116,7 +124,7 @@ function setPresetInputs(blob, changes) {
|
|
|
116
124
|
const range = inputTable(u.text);
|
|
117
125
|
let inputs = u.text.slice(range.start, range.end);
|
|
118
126
|
for (const [key, value] of Object.entries(changes)) {
|
|
119
|
-
const name =
|
|
127
|
+
const name = mapping[key];
|
|
120
128
|
if (!name) throw new Error(`unknown preset input ${key}`);
|
|
121
129
|
const r = inputRange(inputs, name), replacement = `${name} = Input { Value = ${literal(key === 'textFillMode' ? 0 : value)}, }, `;
|
|
122
130
|
if (r) {
|
|
@@ -15025,7 +15025,15 @@ def _mcp_install_payload(client_id: str) -> Dict[str, Any]:
|
|
|
15025
15025
|
)
|
|
15026
15026
|
except Exception as exc:
|
|
15027
15027
|
return {"success": False, "error": str(exc)}
|
|
15028
|
-
|
|
15028
|
+
# Same gap as install.py: writing the client entry does not install the
|
|
15029
|
+
# Node packages the live subtitle preset path imports (issue #297).
|
|
15030
|
+
node_deps = "not provisioned"
|
|
15031
|
+
try:
|
|
15032
|
+
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(paths["server_path"])))
|
|
15033
|
+
node_deps = installer.provision_advanced_node_deps(project_dir)
|
|
15034
|
+
except Exception as exc:
|
|
15035
|
+
node_deps = f"npm install failed: {exc}"
|
|
15036
|
+
return {"success": bool(ok), "message": message, "client_id": client_id, "node_deps": node_deps}
|
|
15029
15037
|
|
|
15030
15038
|
|
|
15031
15039
|
def _mcp_uninstall_payload(client_id: str) -> Dict[str, Any]:
|
package/src/granular/common.py
CHANGED
|
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
|
|
|
91
91
|
handlers=[logging.StreamHandler()],
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
-
VERSION = "4.
|
|
94
|
+
VERSION = "4.14.0"
|
|
95
95
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
96
96
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
97
97
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "4.
|
|
14
|
+
VERSION = "4.14.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
15416
|
-
|
|
15417
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
15592
|
-
"
|
|
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
|
-
|
|
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
|
|
27154
|
-
"
|
|
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
|
|
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
|
-
|
|
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)
|