davinci-resolve-mcp 2.136.1 → 2.138.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 +56 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/bin/davinci-resolve-advanced-mcp.mjs +21 -0
- package/docs/guides/native-drt-authoring.md +1 -0
- package/install.py +53 -4
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +6 -1
- package/resolve-advanced/server/capabilities.mjs +25 -0
- package/resolve-advanced/server/db-patch.mjs +2 -5
- package/resolve-advanced/server/lineage-db.mjs +2 -5
- package/resolve-advanced/server/offline-ref-db.mjs +2 -5
- package/resolve-advanced/server/project-db.mjs +2 -5
- package/resolve-advanced/server/provenance-audit.mjs +23 -2
- package/resolve-advanced/server/reverse-clip-db.mjs +2 -5
- package/resolve-advanced/server/tools/drt.mjs +1 -1
- package/resolve-advanced/server/tools/fairlight.mjs +12 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +1 -0
- package/resolve-advanced/vendor/drp-format/place-transition.js +17 -1
- package/resolve-advanced/vendor/fairlight/index.js +53 -22
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/brain_edits.py +3 -3
- package/src/utils/page_lock.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
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 v2.138.0 — wipes join the conform
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Wipe transitions authored offline** (`transitions[].type: 'wipe'`;
|
|
10
|
+
EDL `W`-codes route to it automatically through
|
|
11
|
+
`assemble_from_interchange`). Harvested from a live 19.1.3.7 EDL W-code
|
|
12
|
+
import (E61): Resolve stores a wipe as the same Cross Dissolve element
|
|
13
|
+
whose FieldsBlob zlib payload zeroes the style-id field the dissolve
|
|
14
|
+
fills — and its own importer maps EVERY W-code (W001/W002/W005 measured
|
|
15
|
+
identical) to one soft-edge wipe style, so a single style is full parity
|
|
16
|
+
with the host importer. Render-proven both directions: the live wipe's
|
|
17
|
+
midpoint splits spatially (157.2/206.3 left/right vs the dissolve's
|
|
18
|
+
uniform 181.6), the element survives the .drt round-trip bit-exact, and
|
|
19
|
+
the offline-authored wipe through the full EDL → assemble → import →
|
|
20
|
+
render route reproduces the split (158.1/205.0). Audio wipes refuse
|
|
21
|
+
(junctions cross-fade).
|
|
22
|
+
|
|
23
|
+
## What's New in v2.137.0 — the sweep reaches the database tier
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Fairlight DB row selection** (adapted the v2.133 container-pin lesson to
|
|
28
|
+
the vendor layer): `readFromDatabase` took the FIRST `Sm2Sequence` row — in
|
|
29
|
+
any project holding a compound clip that can be a compound's embedded
|
|
30
|
+
sequence with no `FLStudioModelBA`, so bus reads failed while the model sat
|
|
31
|
+
in the next row (measured live). Worse, `applyTemplate` wrote its new blob
|
|
32
|
+
into EVERY blob-bearing sequence row, clobbering compound `SeqRef` links
|
|
33
|
+
project-wide. Reads now pick the model-bearing row; writes scope to exactly
|
|
34
|
+
that `Sm2Sequence_id` and refuse ambiguous multi-timeline projects without
|
|
35
|
+
an explicit target. `read_buses_from_db` also stops dumping the ~430KB
|
|
36
|
+
decompressed model into the tool response.
|
|
37
|
+
- **`provenance.cdl_diff` silently identity-defaulted array-shaped CDLs**:
|
|
38
|
+
`[r,g,b]` slope/offset/power (the common interchange form) read as unity
|
|
39
|
+
through the `{r,g,b}` accessor, so two different grades diffed as
|
|
40
|
+
saturation-only (measured). Both shapes are accepted now; unrecognizable
|
|
41
|
+
shapes refuse loudly.
|
|
42
|
+
- **Node-floor enforcement**: the advanced server refuses to start below
|
|
43
|
+
Node 20.9 with the exact fix named (measured live: a client config's node
|
|
44
|
+
resolved to an nvm v18, where pure-JS tools limp and better-sqlite3 dies
|
|
45
|
+
with a cryptic ABI mismatch). `install.py` now writes an absolute,
|
|
46
|
+
version-checked node path into client configs, and the better-sqlite3
|
|
47
|
+
loader distinguishes "not installed" from "built for a different Node".
|
|
48
|
+
- **Windows UTF-8, rounds two and three** — adapted from
|
|
49
|
+
[PR #175](https://github.com/samuelgursky/davinci-resolve-mcp/pull/175) and
|
|
50
|
+
[PR #176](https://github.com/samuelgursky/davinci-resolve-mcp/pull/176) by
|
|
51
|
+
@Chosen-3: the six unencoded call sites in `src/` (brain-edits registry,
|
|
52
|
+
page lock) and `install.py`'s client-config `read_json`/`write_json`. The
|
|
53
|
+
UTF-8 discipline guard now covers `src/` and `install.py` too.
|
|
54
|
+
|
|
55
|
+
### Verified through the MCP tool layer (E60)
|
|
56
|
+
|
|
57
|
+
`project_read` (introspect/report/audit/timeline_clips), `offline_ref.list_in_project`,
|
|
58
|
+
`color_trace.plan` (6/6 exact-name matches), and both fairlight bus readers,
|
|
59
|
+
all against a live scratch project database.
|
|
60
|
+
|
|
5
61
|
## What's New in v2.136.1 — the changelog catches up
|
|
6
62
|
|
|
7
63
|
### 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
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.138.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -13,6 +13,27 @@
|
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
|
|
16
|
+
// Node floor (package.json engines: >=20.9), enforced at STARTUP rather than
|
|
17
|
+
// discovered per-feature: under an old Node the pure-JS tools limp along
|
|
18
|
+
// while native-dep paths (better-sqlite3: project_read, fairlight DB actions,
|
|
19
|
+
// offline_ref live linking) die with a cryptic NODE_MODULE_VERSION mismatch —
|
|
20
|
+
// measured live under nvm's v18.20.8, which is exactly what a bare "node"
|
|
21
|
+
// command in an MCP registration resolves to on a machine whose shell
|
|
22
|
+
// default lags. Silent degradation is this repo's least favorite failure
|
|
23
|
+
// mode; refuse loudly with the fix instead.
|
|
24
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
25
|
+
if (major < 20 || (major === 20 && minor < 9)) {
|
|
26
|
+
process.stderr.write(
|
|
27
|
+
`[davinci-resolve-advanced-mcp] Node ${process.versions.node} is below the ` +
|
|
28
|
+
`supported floor (>=20.9). This process was started by: ${process.execPath}\n` +
|
|
29
|
+
`Fix: point the MCP registration's command at a Node >=20.9 binary ` +
|
|
30
|
+
`(e.g. the absolute path from \`nvm which 20\`), or update the default ` +
|
|
31
|
+
`node on PATH. Re-running install.py also rewrites client configs with ` +
|
|
32
|
+
`an absolute, version-checked node path.\n`,
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
38
|
const serverEntry = path.resolve(__dirname, '..', 'resolve-advanced', 'server', 'index.mjs');
|
|
18
39
|
|
|
@@ -42,6 +42,7 @@ window. `render.verify_output` covers the container-level checks.
|
|
|
42
42
|
| Media cuts, multi-source | `media: [{mediaFilePath, spec, cuts}]` | v2.106–2.107 |
|
|
43
43
|
| Multi-track video (V2+ stacking) | `cuts[].track` (video-only above V1) | v2.112 |
|
|
44
44
|
| Cross-dissolves | `transitions: [{track, atFrame, durationFrames}]` | v2.111 |
|
|
45
|
+
| Wipes (EDL `W`-codes) | `transitions[].type: 'wipe'` (single soft-edge style — host-importer parity) | v2.138 |
|
|
45
46
|
| Audio cross-fades | `transitions[].trackType: 'audio'` | v2.116 |
|
|
46
47
|
| Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
|
|
47
48
|
| Constant retimes, reverse | `cuts[].reverse` | v2.114 |
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.138.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
|
|
@@ -1091,7 +1091,7 @@ def read_json(path):
|
|
|
1091
1091
|
existing settings (issue #71: Zed's settings.json ships with comments).
|
|
1092
1092
|
"""
|
|
1093
1093
|
try:
|
|
1094
|
-
with open(path, "r") as f:
|
|
1094
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
1095
1095
|
raw = f.read()
|
|
1096
1096
|
except FileNotFoundError:
|
|
1097
1097
|
return {}
|
|
@@ -1120,7 +1120,7 @@ def write_json(path, data):
|
|
|
1120
1120
|
backup = path.with_suffix(path.suffix + ".backup")
|
|
1121
1121
|
shutil.copy2(path, backup)
|
|
1122
1122
|
|
|
1123
|
-
with open(path, "w") as f:
|
|
1123
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1124
1124
|
json.dump(data, f, indent=2)
|
|
1125
1125
|
f.write("\n")
|
|
1126
1126
|
|
|
@@ -1183,12 +1183,61 @@ def build_advanced_entry(server_path, python_path=None):
|
|
|
1183
1183
|
"""
|
|
1184
1184
|
project_dir = Path(server_path).resolve().parents[1] # .../src/server.py -> repo root
|
|
1185
1185
|
advanced_bin = project_dir / "bin" / "davinci-resolve-advanced-mcp.mjs"
|
|
1186
|
-
entry = {"command":
|
|
1186
|
+
entry = {"command": resolve_node_command(), "args": [str(advanced_bin)]}
|
|
1187
1187
|
if python_path:
|
|
1188
1188
|
entry["env"] = {"AAF_PROBE_PYTHON": str(python_path)}
|
|
1189
1189
|
return entry
|
|
1190
1190
|
|
|
1191
1191
|
|
|
1192
|
+
# Node floor for the advanced server (package.json engines). Below it the
|
|
1193
|
+
# pure-JS tools limp along while native-dep paths (better-sqlite3) die with a
|
|
1194
|
+
# cryptic NODE_MODULE_VERSION mismatch — measured live when a client config's
|
|
1195
|
+
# bare "node" resolved to an nvm v18 that a GUI app had on PATH.
|
|
1196
|
+
NODE_MIN = (20, 9)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _node_version(cmd):
|
|
1200
|
+
try:
|
|
1201
|
+
out = subprocess.run([cmd, "--version"], capture_output=True, text=True,
|
|
1202
|
+
encoding="utf-8", timeout=10, check=False).stdout.strip()
|
|
1203
|
+
parts = out.lstrip("v").split(".")
|
|
1204
|
+
return (int(parts[0]), int(parts[1]))
|
|
1205
|
+
except Exception:
|
|
1206
|
+
return None
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def resolve_node_command():
|
|
1210
|
+
"""Absolute path to a Node >= the floor, or bare "node" as a last resort.
|
|
1211
|
+
|
|
1212
|
+
A bare "node" in a client config resolves against WHATEVER PATH the
|
|
1213
|
+
launching GUI app has — which on this machine meant an nvm v18 while the
|
|
1214
|
+
repo floor is 20.9. Pin the binary at install time instead: prefer the
|
|
1215
|
+
node on the installer's PATH if it meets the floor, else the newest
|
|
1216
|
+
nvm-managed node that does.
|
|
1217
|
+
"""
|
|
1218
|
+
on_path = shutil.which("node")
|
|
1219
|
+
if on_path:
|
|
1220
|
+
ver = _node_version(on_path)
|
|
1221
|
+
if ver and ver >= NODE_MIN:
|
|
1222
|
+
return str(Path(on_path).resolve())
|
|
1223
|
+
nvm_dir = Path.home() / ".nvm" / "versions" / "node"
|
|
1224
|
+
if nvm_dir.is_dir():
|
|
1225
|
+
candidates = []
|
|
1226
|
+
for d in nvm_dir.iterdir():
|
|
1227
|
+
node_bin = d / "bin" / "node"
|
|
1228
|
+
if node_bin.exists():
|
|
1229
|
+
ver = _node_version(str(node_bin))
|
|
1230
|
+
if ver and ver >= NODE_MIN:
|
|
1231
|
+
candidates.append((ver, str(node_bin)))
|
|
1232
|
+
if candidates:
|
|
1233
|
+
return max(candidates)[1]
|
|
1234
|
+
print(
|
|
1235
|
+
f" {yellow('Node:')} no Node >= {NODE_MIN[0]}.{NODE_MIN[1]} found; writing a bare 'node' command. "
|
|
1236
|
+
"The advanced server will refuse to start under an older Node and name this fix."
|
|
1237
|
+
)
|
|
1238
|
+
return "node"
|
|
1239
|
+
|
|
1240
|
+
|
|
1192
1241
|
def generate_manual_config(python_path, server_path, api_path, lib_path):
|
|
1193
1242
|
"""Generate config snippets for manual setup."""
|
|
1194
1243
|
entry = build_server_entry(python_path, server_path, api_path, lib_path)
|
package/package.json
CHANGED
|
@@ -425,7 +425,12 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
425
425
|
});
|
|
426
426
|
continue;
|
|
427
427
|
}
|
|
428
|
-
|
|
428
|
+
// EDL W-codes author as a WIPE (Resolve's own EDL importer maps every
|
|
429
|
+
// W-code to its single soft-edge wipe style — W001/W002/W005 measured
|
|
430
|
+
// identical — so one style is host-importer parity; render-verified
|
|
431
|
+
// spatial split at the midpoint). Everything else is a dissolve.
|
|
432
|
+
const kind = /^W/i.test(String(c.type || '')) ? 'wipe' : 'dissolve';
|
|
433
|
+
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, ...(kind === 'wipe' ? { type: 'wipe' } : {}) });
|
|
429
434
|
}
|
|
430
435
|
// Audio cross-fades, same geometry rules (render-verified on 19.1.3.7 via
|
|
431
436
|
// the harvested cross-fade template: the highpass RMS ramps, not steps).
|
|
@@ -49,6 +49,31 @@ export const hasFfprobe = () => onPath('ffprobe');
|
|
|
49
49
|
export const hasSharp = () => nodeModuleAvailable('sharp');
|
|
50
50
|
export const hasBetterSqlite3 = () => nodeModuleAvailable('better-sqlite3');
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Load better-sqlite3 or throw the RIGHT actionable error. Two distinct
|
|
54
|
+
* failures hide behind one require: the module is not installed (install it),
|
|
55
|
+
* or it IS installed but compiled against a different Node ABI — which is
|
|
56
|
+
* what a client config pointing at a different Node than the one that ran
|
|
57
|
+
* `npm install` produces (measured live: server under nvm v18, module built
|
|
58
|
+
* under v20 → cryptic NODE_MODULE_VERSION mismatch). Name the real fix.
|
|
59
|
+
*/
|
|
60
|
+
export function requireBetterSqlite3(featureLabel = 'This feature') {
|
|
61
|
+
try {
|
|
62
|
+
return createRequire(import.meta.url)('better-sqlite3');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg = String(err && err.message);
|
|
65
|
+
if (/NODE_MODULE_VERSION/.test(msg)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${featureLabel} needs better-sqlite3, which is installed but compiled for a DIFFERENT Node ` +
|
|
68
|
+
`than this server is running under (${process.version} at ${process.execPath}). ` +
|
|
69
|
+
`Fix: run \`npm rebuild better-sqlite3\` in resolve-advanced/ using THIS node, ` +
|
|
70
|
+
`or point the MCP registration at the node that built it.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`${featureLabel} needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
/** Throw a clear, actionable error if ffmpeg/ffprobe aren't on PATH. */
|
|
53
78
|
export function requireFfmpeg() {
|
|
54
79
|
if (!hasFfmpeg() || !hasFfprobe()) {
|
|
@@ -15,6 +15,7 @@ import os from 'node:os';
|
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
|
+
import { requireBetterSqlite3 } from './capabilities.mjs';
|
|
18
19
|
|
|
19
20
|
export const DISK_DB_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Disk Database/Resolve Projects');
|
|
20
21
|
|
|
@@ -91,11 +92,7 @@ export async function responsiveRoots(roots = DB_ROOTS, deadlineMs = 3000) {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export function loadSqlite() {
|
|
94
|
-
|
|
95
|
-
return require('better-sqlite3');
|
|
96
|
-
} catch {
|
|
97
|
-
throw new Error("Project.db patching needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3");
|
|
98
|
-
}
|
|
95
|
+
return requireBetterSqlite3('Project.db patching');
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
/**
|
|
@@ -15,6 +15,7 @@ import crypto from 'node:crypto';
|
|
|
15
15
|
import { createRequire } from 'node:module';
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
|
+
import { requireBetterSqlite3 } from './capabilities.mjs';
|
|
18
19
|
const V = '../vendor/conform-qc';
|
|
19
20
|
const parse = require(`${V}/parse/index.js`);
|
|
20
21
|
const resolveTarget = require(`${V}/oracle/resolve.js`);
|
|
@@ -22,11 +23,7 @@ const resolveTarget = require(`${V}/oracle/resolve.js`);
|
|
|
22
23
|
const TICKS_PER_SEC = 254016000000;
|
|
23
24
|
|
|
24
25
|
function loadSqlite() {
|
|
25
|
-
|
|
26
|
-
return require('better-sqlite3');
|
|
27
|
-
} catch {
|
|
28
|
-
throw new Error("lineage store needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3");
|
|
29
|
-
}
|
|
26
|
+
return requireBetterSqlite3('The lineage DB');
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
const SCHEMA = `
|
|
@@ -31,6 +31,7 @@ import os from 'node:os';
|
|
|
31
31
|
import { createRequire } from 'node:module';
|
|
32
32
|
|
|
33
33
|
const require = createRequire(import.meta.url);
|
|
34
|
+
import { requireBetterSqlite3 } from './capabilities.mjs';
|
|
34
35
|
|
|
35
36
|
const DISK_DB_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Disk Database/Resolve Projects');
|
|
36
37
|
// Modern Studio installs keep the local library under "Resolve Project
|
|
@@ -38,11 +39,7 @@ const DISK_DB_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackm
|
|
|
38
39
|
const PROJECT_LIBRARY_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Project Library/Resolve Projects');
|
|
39
40
|
|
|
40
41
|
function loadSqlite() {
|
|
41
|
-
|
|
42
|
-
return require('better-sqlite3');
|
|
43
|
-
} catch {
|
|
44
|
-
throw new Error("offline_ref DB path needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3");
|
|
45
|
-
}
|
|
42
|
+
return requireBetterSqlite3('The live offline-reference DB path');
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
/** Recursively locate <projectName>/Project.db under the Resolve Disk Database. */
|
|
@@ -14,12 +14,9 @@ import crypto from 'node:crypto';
|
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
|
+
import { requireBetterSqlite3 } from './capabilities.mjs';
|
|
17
18
|
function loadSqlite() {
|
|
18
|
-
|
|
19
|
-
return require('better-sqlite3');
|
|
20
|
-
} catch {
|
|
21
|
-
throw new Error("project DB needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3");
|
|
22
|
-
}
|
|
19
|
+
return requireBetterSqlite3('Project.db access');
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
export const ENTITY_KINDS = ['type', 'series', 'episode', 'sequence', 'group', 'deliverable'];
|
|
@@ -120,13 +120,34 @@ export async function cdlExport(ref, opts = {}) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/** Diff two ASC CDL objects (slope/offset/power/saturation) → per-param deltas. */
|
|
123
|
+
// Accept BOTH common CDL shapes — {r,g,b} objects (this repo's internal
|
|
124
|
+
// vocabulary) and [r,g,b] arrays (the common interchange form) — and REFUSE
|
|
125
|
+
// anything else. The old reader indexed .r/.g/.b unconditionally, so an
|
|
126
|
+
// array-shaped CDL silently decayed to identity and cdl_diff reported two
|
|
127
|
+
// different grades as matching everywhere but saturation (measured, E60).
|
|
128
|
+
function cdlChannels(value, grp, side) {
|
|
129
|
+
if (value == null) return { r: undefined, g: undefined, b: undefined };
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
if (value.length !== 3 || value.some((v) => typeof v !== 'number')) {
|
|
132
|
+
throw new Error(`cdlDiff: ${side}.${grp} array must be exactly [r, g, b] numbers`);
|
|
133
|
+
}
|
|
134
|
+
return { r: value[0], g: value[1], b: value[2] };
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === 'object' && ['r', 'g', 'b'].every((k) => value[k] === undefined || typeof value[k] === 'number')) {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`cdlDiff: ${side}.${grp} must be {r,g,b} or [r,g,b] — refusing to silently treat it as identity`);
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
export function cdlDiff(a, b, opts = {}) {
|
|
124
143
|
const tol = opts.tol ?? 1e-4;
|
|
125
144
|
const deltas = [];
|
|
126
145
|
for (const grp of ['slope', 'offset', 'power']) {
|
|
146
|
+
const ac = cdlChannels(a[grp], grp, 'a');
|
|
147
|
+
const bc = cdlChannels(b[grp], grp, 'b');
|
|
127
148
|
for (const k of ['r', 'g', 'b']) {
|
|
128
|
-
const av =
|
|
129
|
-
const bv =
|
|
149
|
+
const av = ac[k] ?? (grp === 'offset' ? 0 : 1);
|
|
150
|
+
const bv = bc[k] ?? (grp === 'offset' ? 0 : 1);
|
|
130
151
|
if (Math.abs(av - bv) > tol) deltas.push({ param: `${grp}.${k}`, a: +av.toFixed(5), b: +bv.toFixed(5), delta: +(bv - av).toFixed(5) });
|
|
131
152
|
}
|
|
132
153
|
}
|
|
@@ -31,13 +31,10 @@ import path from 'node:path';
|
|
|
31
31
|
import { createRequire } from 'node:module';
|
|
32
32
|
|
|
33
33
|
const require = createRequire(import.meta.url);
|
|
34
|
+
import { requireBetterSqlite3 } from './capabilities.mjs';
|
|
34
35
|
|
|
35
36
|
function loadSqlite() {
|
|
36
|
-
|
|
37
|
-
return require('better-sqlite3');
|
|
38
|
-
} catch {
|
|
39
|
-
throw new Error("reverse-clip DB path needs the optional native dep 'better-sqlite3'. Install: npm i better-sqlite3");
|
|
40
|
-
}
|
|
37
|
+
return requireBetterSqlite3('Reversed-clip DB repair');
|
|
41
38
|
}
|
|
42
39
|
function loadPg() {
|
|
43
40
|
try {
|
|
@@ -51,7 +51,7 @@ const assembleSchema = z.object({
|
|
|
51
51
|
spec: z
|
|
52
52
|
.object({})
|
|
53
53
|
.passthrough()
|
|
54
|
-
.describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),freeze? (true = hold source frame srcIn for the whole cut; video-only; render-proven frozen on 19 via freezedetect),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video'
|
|
54
|
+
.describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),freeze? (true = hold source frame srcIn for the whole cut; video-only; render-proven frozen on 19 via freezedetect),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' | 'audio' cross-fade), type? ('dissolve' default | 'wipe' — the single soft-edge wipe style Resolve's own EDL importer maps every W-code to; all render-verified on 19)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), compounds?: [{name, startFrame (parent, absolute), durationFrames, track?, cuts:[{mediaFilePath, startFrame (INNER, 0-based), durationFrames, srcIn?}], compounds?: [same, nested — frames inner-relative]}] (multiple PARALLEL compounds compose AND compounds NEST recursively — depth-2 AND depth-3 playback render-verified on 19 (the old depth-2 black was a missing SequenceSetup key, fixed); inner cuts need captured templates w/ native clips), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
|
|
55
55
|
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
|
|
56
56
|
targetAppVersion: z
|
|
57
57
|
.union([z.string(), z.number()])
|
|
@@ -53,7 +53,18 @@ export const fairlightTool = {
|
|
|
53
53
|
const p = dbReadSchema.parse(args);
|
|
54
54
|
const db = openDb(p.dbPath, false);
|
|
55
55
|
try {
|
|
56
|
-
|
|
56
|
+
// Summarize: the full readFromDatabase result carries the multi-
|
|
57
|
+
// hundred-KB decompressed model + raw blobs — dumped into the tool
|
|
58
|
+
// response as JSON byte arrays (measured E60). Return the facts.
|
|
59
|
+
const m = patcher.readFromDatabase(db);
|
|
60
|
+
return {
|
|
61
|
+
busFormat: m.busFormat,
|
|
62
|
+
buses: m.buses,
|
|
63
|
+
sequenceId: m.sequenceId,
|
|
64
|
+
modelRowCount: m.modelRowCount,
|
|
65
|
+
modelBytes: m.data.length,
|
|
66
|
+
compressedBytes: m.rawBlob.length,
|
|
67
|
+
};
|
|
57
68
|
} finally {
|
|
58
69
|
db.close();
|
|
59
70
|
}
|
|
@@ -211,6 +211,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
211
211
|
({ buffer } = await placeTransition(buffer, {
|
|
212
212
|
track: tr.track, atFrame: tr.atFrame, durationFrames: tr.durationFrames,
|
|
213
213
|
trackType: tr.trackType || 'video',
|
|
214
|
+
type: tr.type || 'dissolve',
|
|
214
215
|
}));
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -55,8 +55,21 @@ const clipDuration = (c) => { const m = c.match(/<Duration>(\d+)<\/Duration>/);
|
|
|
55
55
|
* @returns {Promise<{buffer:Buffer, entry:string, timelineUuid:string, track:number,
|
|
56
56
|
* atFrame:number, start:number, durationFrames:number, transitionDbId:string|null}>}
|
|
57
57
|
*/
|
|
58
|
+
// The WIPE variant (harvested from a live 19.1.3.7 EDL W-code import, E61):
|
|
59
|
+
// Resolve stores a wipe as the SAME Cross Dissolve transition element whose
|
|
60
|
+
// FieldsBlob zlib payload zeroes the style-id field the dissolve fills with
|
|
61
|
+
// 08 c79fc3e9. EffectFiltersBA, alignment, everything else is byte-identical.
|
|
62
|
+
// Render-proven: midpoint splits spatially (left 157.2 / right 206.3 on the
|
|
63
|
+
// standard probe media) vs the dissolve's uniform 181.6, and the element
|
|
64
|
+
// survives the .drt round-trip bit-exact. Resolve's own EDL importer maps
|
|
65
|
+
// EVERY W-code (W001/W002/W005 measured identical) to this single soft-edge
|
|
66
|
+
// wipe style, so one style is full parity with the host importer.
|
|
67
|
+
const WIPE_FIELDS_BLOB = '00000002000000158012120000002c789c636660642016000000da0005';
|
|
68
|
+
|
|
58
69
|
async function placeTransition(drpInput, opts = {}) {
|
|
59
|
-
const { track, atFrame, durationFrames = 24, trackType = 'video', timelineUuid } = opts;
|
|
70
|
+
const { track, atFrame, durationFrames = 24, trackType = 'video', type = 'dissolve', timelineUuid } = opts;
|
|
71
|
+
if (type !== 'dissolve' && type !== 'wipe') throw new Error("placeTransition: type must be 'dissolve' or 'wipe'");
|
|
72
|
+
if (type === 'wipe' && trackType === 'audio') throw new Error('placeTransition: wipes are video-only (audio junctions cross-fade)');
|
|
60
73
|
if (!Number.isInteger(track) || track < 1) throw new TypeError('placeTransition: track must be a positive integer');
|
|
61
74
|
if (!Number.isInteger(atFrame)) throw new TypeError('placeTransition: atFrame must be an integer');
|
|
62
75
|
if (!Number.isInteger(durationFrames) || durationFrames < 2) throw new TypeError('placeTransition: durationFrames must be an integer >= 2');
|
|
@@ -78,6 +91,9 @@ async function placeTransition(drpInput, opts = {}) {
|
|
|
78
91
|
|
|
79
92
|
let trans = fs.readFileSync(trackType === 'audio' ? AUDIO_TEMPLATE_PATH : TEMPLATE_PATH, 'utf8').trim();
|
|
80
93
|
trans = freshDbIds(trans);
|
|
94
|
+
if (type === 'wipe') {
|
|
95
|
+
trans = trans.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, `<FieldsBlob>${WIPE_FIELDS_BLOB}</FieldsBlob>`);
|
|
96
|
+
}
|
|
81
97
|
const start = atFrame - Math.floor(durationFrames / 2); // centered (AlignmentType 2)
|
|
82
98
|
trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
|
|
83
99
|
trans = trans.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
|
|
@@ -216,27 +216,46 @@ function readBusConfig(data) {
|
|
|
216
216
|
* @returns {{ busFormat, buses, data, rawBlob, fieldsBlob, entries }}
|
|
217
217
|
*/
|
|
218
218
|
function readFromDatabase(db) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
// Iterate EVERY blob-bearing sequence row and pick one that actually
|
|
220
|
+
// carries the Fairlight model. "First row wins" is wrong the moment a
|
|
221
|
+
// project holds a COMPOUND CLIP: a compound's embedded Sm2Sequence has a
|
|
222
|
+
// FieldsBlob (SeqRef/SequenceSetup/thumbnail) but no FLStudioModelBA, and
|
|
223
|
+
// whichever row the engine returns first decided whether this function
|
|
224
|
+
// worked at all (measured: a one-timeline project with compounds reported
|
|
225
|
+
// "No FLStudioModelBA found" while the model sat in the next row).
|
|
226
|
+
const rows = db.prepare('SELECT Sm2Sequence_id, FieldsBlob FROM Sm2Sequence WHERE FieldsBlob IS NOT NULL').all();
|
|
227
|
+
if (!rows.length) throw new Error('No Sm2Sequence with FieldsBlob found');
|
|
224
228
|
|
|
225
229
|
let busFormat = null;
|
|
226
230
|
let rawBlob = null;
|
|
227
231
|
let data = null;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
let fieldsBlobBuf = null;
|
|
233
|
+
let entries = null;
|
|
234
|
+
let sequenceId = null;
|
|
235
|
+
let modelRowCount = 0;
|
|
236
|
+
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const buf = Buffer.from(row.FieldsBlob);
|
|
239
|
+
let parsed;
|
|
240
|
+
try {
|
|
241
|
+
parsed = parseFieldsBlob(buf);
|
|
242
|
+
} catch {
|
|
243
|
+
continue; // not a keyed FieldsBlob — not a candidate
|
|
236
244
|
}
|
|
245
|
+
const model = parsed.entries.find(e => e.key === 'FLStudioModelBA');
|
|
246
|
+
if (!model) continue;
|
|
247
|
+
modelRowCount += 1;
|
|
248
|
+
if (data) continue; // keep the first model-bearing row; count the rest
|
|
249
|
+
fieldsBlobBuf = buf;
|
|
250
|
+
entries = parsed.entries;
|
|
251
|
+
sequenceId = row.Sm2Sequence_id;
|
|
252
|
+
const fmt = parsed.entries.find(e => e.key === 'FirstMainBusFormat');
|
|
253
|
+
if (fmt) busFormat = fmt.rawValue.readUInt32BE(0);
|
|
254
|
+
rawBlob = model.rawValue.slice(4); // skip size prefix
|
|
255
|
+
data = decompressFLModel(rawBlob);
|
|
237
256
|
}
|
|
238
257
|
|
|
239
|
-
if (!data) throw new Error('No FLStudioModelBA found in FieldsBlob');
|
|
258
|
+
if (!data) throw new Error('No FLStudioModelBA found in any Sm2Sequence FieldsBlob');
|
|
240
259
|
|
|
241
260
|
const busConfig = readBusConfig(data);
|
|
242
261
|
|
|
@@ -247,6 +266,8 @@ function readFromDatabase(db) {
|
|
|
247
266
|
rawBlob,
|
|
248
267
|
fieldsBlobBuf,
|
|
249
268
|
entries,
|
|
269
|
+
sequenceId,
|
|
270
|
+
modelRowCount,
|
|
250
271
|
busNameOffset: busConfig.busNameOffset,
|
|
251
272
|
};
|
|
252
273
|
}
|
|
@@ -269,12 +290,22 @@ function readFromDatabase(db) {
|
|
|
269
290
|
* @returns {{ success: boolean, details: string }}
|
|
270
291
|
*/
|
|
271
292
|
function applyTemplate(db, templateData, options = {}) {
|
|
272
|
-
// Read
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
293
|
+
// Read the MODEL-BEARING FieldsBlob and remember which row it came from —
|
|
294
|
+
// the write below must target exactly that row. The old blanket
|
|
295
|
+
// `UPDATE ... WHERE FieldsBlob IS NOT NULL` stamped one timeline's blob
|
|
296
|
+
// into EVERY sequence row: compound clips' embedded sequences (whose blob
|
|
297
|
+
// carries their SeqRef container link), other timelines, everything —
|
|
298
|
+
// cross-wiring the project. Scoped by Sm2Sequence_id instead, and refused
|
|
299
|
+
// when several timelines carry models and no target was named.
|
|
300
|
+
const model = readFromDatabase(db);
|
|
301
|
+
if (model.modelRowCount > 1 && options.sequenceId === undefined) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`applyTemplate: ${model.modelRowCount} sequences carry a Fairlight model (multi-timeline project) — ` +
|
|
304
|
+
'pass options.sequenceId (Sm2Sequence_id) to name the target, or use the timeline-scoped actions.',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const targetSequenceId = options.sequenceId !== undefined ? options.sequenceId : model.sequenceId;
|
|
308
|
+
const { entries } = model;
|
|
278
309
|
|
|
279
310
|
let patchedData = Buffer.from(templateData);
|
|
280
311
|
|
|
@@ -313,8 +344,8 @@ function applyTemplate(db, templateData, options = {}) {
|
|
|
313
344
|
// Encode new FieldsBlob
|
|
314
345
|
const newFieldsBlob = encodeFieldsBlob(newEntries);
|
|
315
346
|
|
|
316
|
-
// Write to database
|
|
317
|
-
db.prepare('UPDATE Sm2Sequence SET FieldsBlob = ? WHERE
|
|
347
|
+
// Write to database — ONLY the row the model came from.
|
|
348
|
+
db.prepare('UPDATE Sm2Sequence SET FieldsBlob = ? WHERE Sm2Sequence_id = ?').run(newFieldsBlob, targetSequenceId);
|
|
318
349
|
|
|
319
350
|
return {
|
|
320
351
|
success: true,
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.138.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
package/src/utils/brain_edits.py
CHANGED
|
@@ -348,7 +348,7 @@ def update_brain_edits_registry(
|
|
|
348
348
|
payload: Dict[str, Any]
|
|
349
349
|
if os.path.isfile(path):
|
|
350
350
|
try:
|
|
351
|
-
with open(path, "r") as fh:
|
|
351
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
352
352
|
payload = json.load(fh)
|
|
353
353
|
except (OSError, json.JSONDecodeError):
|
|
354
354
|
payload = {"entries": []}
|
|
@@ -368,7 +368,7 @@ def update_brain_edits_registry(
|
|
|
368
368
|
payload["updated_at"] = _now_iso()
|
|
369
369
|
|
|
370
370
|
tmp_path = path + ".tmp"
|
|
371
|
-
with open(tmp_path, "w") as fh:
|
|
371
|
+
with open(tmp_path, "w", encoding="utf-8") as fh:
|
|
372
372
|
json.dump(payload, fh, indent=2, default=str)
|
|
373
373
|
os.replace(tmp_path, path)
|
|
374
374
|
return {"success": True, "registry_path": path, "entry_count": len(entries)}
|
|
@@ -379,7 +379,7 @@ def read_brain_edits_registry(project_root: str) -> Dict[str, Any]:
|
|
|
379
379
|
if not os.path.isfile(path):
|
|
380
380
|
return {"entries": [], "registry_path": path}
|
|
381
381
|
try:
|
|
382
|
-
with open(path, "r") as fh:
|
|
382
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
383
383
|
payload = json.load(fh)
|
|
384
384
|
except (OSError, json.JSONDecodeError):
|
|
385
385
|
return {"entries": [], "registry_path": path}
|
package/src/utils/page_lock.py
CHANGED
|
@@ -49,7 +49,7 @@ def page_lock():
|
|
|
49
49
|
try:
|
|
50
50
|
if _depth == 1 and _HAS_FCNTL:
|
|
51
51
|
try:
|
|
52
|
-
_fh = open(_LOCKFILE, "w")
|
|
52
|
+
_fh = open(_LOCKFILE, "w", encoding="utf-8")
|
|
53
53
|
fcntl.flock(_fh, fcntl.LOCK_EX)
|
|
54
54
|
except OSError:
|
|
55
55
|
# Advisory lock is best-effort; never block real work on it.
|