davinci-resolve-mcp 2.144.0 → 2.145.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +6 -1
- package/README.zh-CN.md +2 -2
- package/docs/guides/native-drt-authoring.md +1 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/tools/drt.mjs +161 -0
- package/scripts/install_resolve_bridge.py +26 -13
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
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.145.1 — bridge config override, honored end to end
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The free-edition bridge installer now honors
|
|
10
|
+
`DAVINCI_RESOLVE_BRIDGE_CONFIG`** — adapted from
|
|
11
|
+
[PR #177](https://github.com/samuelgursky/davinci-resolve-mcp/pull/177) by
|
|
12
|
+
@altiss. The bridge client already resolved the override, but the installer
|
|
13
|
+
always wrote and embedded the fixed default path, so a client configured
|
|
14
|
+
with an override could look in one place while the in-app bridge was
|
|
15
|
+
installed with another — both sides "successfully" installed yet never
|
|
16
|
+
sharing a token. The installer resolves the effective path at call time
|
|
17
|
+
with the client's exact semantics (env wins, `~` expands, default
|
|
18
|
+
otherwise) across create, report, runtime copy, and launcher embed. One
|
|
19
|
+
correction on top of the PR: the step-4 probe guidance keeps pointing at
|
|
20
|
+
the FIXED default directory — the probe runs inside Resolve, which never
|
|
21
|
+
inherits the shell's environment, so it always writes there.
|
|
22
|
+
|
|
23
|
+
## What's New in v2.145.0 — whole projects, assembled offline
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`drt.assemble_project`: multi-timeline .drp archives** — two or more
|
|
28
|
+
full assemble specs merge into one project file (reel-per-timeline conform
|
|
29
|
+
packages). Import as a project (`safe_project_import`); pull singles with
|
|
30
|
+
`extract_from_drp`. Live-proven: a two-reel .drp imported with both
|
|
31
|
+
timelines materialized and the second reel rendering its exact content.
|
|
32
|
+
|
|
33
|
+
### The five merge laws (each found by a failed import)
|
|
34
|
+
|
|
35
|
+
1. Template-fixed cluster identities collide — the pool clip, its version
|
|
36
|
+
table, container, sequence and track ids must remap; media pool element
|
|
37
|
+
ids are the deliberate exception (fixed by capture, so identical sources
|
|
38
|
+
dedup and MediaRefs keep pointing at the survivor).
|
|
39
|
+
2. Keyed FieldsBlobs carry uuids as UTF-16 (`ActiveVer`, `SeqRef`) —
|
|
40
|
+
invisible to plaintext remaps; patched through the keyed codec.
|
|
41
|
+
3. `project.xml`'s `<TimelineHandleVec>` is THE timeline registry: a pool
|
|
42
|
+
clip absent from it imports as pool furniture and never materializes.
|
|
43
|
+
4. Folder children live INSIDE `<MediaVec>` — an element appended after its
|
|
44
|
+
close parses fine and is silently invisible.
|
|
45
|
+
5. Media pool tags are `Sm2MpVideoClip`/`Sm2MpAudioClip`, not `Sm2MpMedia`
|
|
46
|
+
— an over-narrow tag set let media ids into the remap and every
|
|
47
|
+
`MediaRef` in the merged reel dangled (whole reel offline).
|
|
48
|
+
|
|
5
49
|
## What's New in v2.144.0 — turnover clip markers ride the items
|
|
6
50
|
|
|
7
51
|
### Added
|
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)
|
|
@@ -52,6 +52,11 @@ python scripts/install_resolve_bridge.py
|
|
|
52
52
|
# restart Resolve, open a project, then: Workspace > Scripts > resolve_bridge
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
The installer and MCP client use `~/.config/davinci-resolve-mcp/bridge.json`
|
|
56
|
+
by default. To keep the authenticated bridge config elsewhere, set
|
|
57
|
+
`DAVINCI_RESOLVE_BRIDGE_CONFIG` while running the installer and in the MCP
|
|
58
|
+
client environment; both sides will use that path.
|
|
59
|
+
|
|
55
60
|
Once that listener is running it is used **automatically** whenever external
|
|
56
61
|
scripting is unavailable — no environment variable required. Setting
|
|
57
62
|
`DAVINCI_RESOLVE_BRIDGE=1` *forces* the bridge instead: it becomes the only
|
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.145.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -61,6 +61,7 @@ window. `render.verify_output` covers the container-level checks.
|
|
|
61
61
|
| Compound authoring | `spec.compounds` (multiple compose; nested edits render) | v2.131–2.132 |
|
|
62
62
|
| Nested compounds | `compounds[].compounds` (depth-2 through depth-4 playback render-verified) | v2.134–2.141 |
|
|
63
63
|
| Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
|
|
64
|
+
| Multi-timeline projects | `drt.assemble_project` (reel-per-timeline .drp; import as a project) | v2.145 |
|
|
64
65
|
|
|
65
66
|
`assemble_from_interchange` drives the same engine from an EDL / OTIO /
|
|
66
67
|
FCP7-XML / AAF / **.prproj** (Premiere, read offline — no Premiere needed)
|
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.145.1"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -291,6 +291,167 @@ export const drtTool = {
|
|
|
291
291
|
'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.',
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
|
+
if (action === 'assemble_project') {
|
|
295
|
+
// MULTI-TIMELINE archive (E85): assemble each timeline spec with the
|
|
296
|
+
// full single-timeline engine, then merge the later archives into the
|
|
297
|
+
// first — SeqContainers copied wholesale, MpFolder elements merged with
|
|
298
|
+
// DbId dedup (captured media templates carry FIXED pool DbIds, so the
|
|
299
|
+
// same source assembled twice IS the same element and every clip's
|
|
300
|
+
// MediaRef already points at it), and LocableBlobSet marker blobs
|
|
301
|
+
// carried over. Import the result as a PROJECT
|
|
302
|
+
// (project_manager.import_project / safe_project_import) — the
|
|
303
|
+
// single-timeline .drt import path only takes one timeline per file.
|
|
304
|
+
const p = z.object({
|
|
305
|
+
timelines: z.array(z.object({}).passthrough()).min(2)
|
|
306
|
+
.describe('Two or more assembleTimeline specs (same shape as `assemble` spec); timelineName required and unique per entry'),
|
|
307
|
+
outputPath: z.string().describe('Where the multi-timeline .drp is written'),
|
|
308
|
+
targetAppVersion: z.union([z.string(), z.number()]).optional(),
|
|
309
|
+
}).parse(args);
|
|
310
|
+
const names = p.timelines.map((t, i) => t.timelineName || `Timeline ${i + 1}`);
|
|
311
|
+
if (new Set(names).size !== names.length) throw new Error(`assemble_project: timelineName must be unique per timeline (got: ${names.join(', ')})`);
|
|
312
|
+
const { assembleTimeline } = drp();
|
|
313
|
+
const buffers = [];
|
|
314
|
+
for (const [i, spec] of p.timelines.entries()) {
|
|
315
|
+
const s = { ...spec, timelineName: names[i] };
|
|
316
|
+
if (s.templateVersion === undefined && p.targetAppVersion !== undefined) {
|
|
317
|
+
s.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
|
|
318
|
+
}
|
|
319
|
+
buffers.push((await assembleTimeline(s)).buffer);
|
|
320
|
+
}
|
|
321
|
+
// Every assembly reuses the TEMPLATE's fixed identities across the
|
|
322
|
+
// whole TIMELINE CLUSTER — pool clip element (which nests a version
|
|
323
|
+
// table with back-references: MpTimelineClip, pActive, UniqueSequenceId,
|
|
324
|
+
// UniqueMediaPoolItemId, numeric IDs), the parent container, its shared
|
|
325
|
+
// sequence and track ids — so archives 2..n silently collide with
|
|
326
|
+
// archive 1 (measured twice: the merge imported as ONE timeline, first
|
|
327
|
+
// via container overwrite, then via UniqueMediaPoolItemId dedup).
|
|
328
|
+
// Rather than freshening named fields one by one, remap EVERY uuid the
|
|
329
|
+
// later archive's cluster shares with the first archive's cluster —
|
|
330
|
+
// except media-element ids, which are shared BY DESIGN (captured
|
|
331
|
+
// templates carry fixed pool DbIds so identical sources dedup and
|
|
332
|
+
// MediaRefs keep pointing at the survivor). The keyed SeqRef inside the
|
|
333
|
+
// pool clip's embedded sequence blob is patched through the codec.
|
|
334
|
+
const { createRequire } = await import('node:module');
|
|
335
|
+
const requireCjs = createRequire(import.meta.url);
|
|
336
|
+
const { decodeKeyedDict, encodeKeyedDict } = requireCjs('../../vendor/drp-format/keyed-dict.js');
|
|
337
|
+
const { randomUUID } = await import('node:crypto');
|
|
338
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
|
339
|
+
const TL_EL_RE = /<Element>\s*<Sm2MpTimelineClip DbId="[^"]+">[\s\S]*?<\/Sm2MpTimelineClip>\s*<\/Element>/g;
|
|
340
|
+
// Media pool elements: Sm2MpVideoClip/Sm2MpAudioClip (captured
|
|
341
|
+
// template media — the tag is NOT Sm2MpMedia; measured the hard way
|
|
342
|
+
// when an over-narrow pattern left media ids in the remap set and
|
|
343
|
+
// every item MediaRef in the merged reel went dangling → offline)
|
|
344
|
+
// plus Sm2MpCompoundClip clusters.
|
|
345
|
+
const MEDIA_EL_RE = /<Element>\s*<Sm2Mp(?:VideoClip|AudioClip|Media|CompoundClip) DbId="[^"]+">[\s\S]*?<\/Sm2Mp(?:VideoClip|AudioClip|Media|CompoundClip)>\s*<\/Element>/g;
|
|
346
|
+
const base = await JSZip.loadAsync(buffers[0]);
|
|
347
|
+
const mpP = 'MediaPool/Master/MpFolder.xml';
|
|
348
|
+
let mpXml = await base.file(mpP).async('string');
|
|
349
|
+
let pjXml = await base.file('project.xml').async('string');
|
|
350
|
+
const baseIds = new Set((mpXml + pjXml).match(UUID_RE) || []);
|
|
351
|
+
for (const n of Object.keys(base.files)) {
|
|
352
|
+
if (!base.files[n].dir && /SeqContainer\/.+\.xml$/.test(n)) {
|
|
353
|
+
for (const id of (await base.file(n).async('string')).match(UUID_RE) || []) baseIds.add(id);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let nextPoolId = 101; // template pool clips carry <ID>100</ID>
|
|
357
|
+
for (const buf of buffers.slice(1)) {
|
|
358
|
+
const zip = await JSZip.loadAsync(buf);
|
|
359
|
+
let mp2 = await zip.file(mpP).async('string');
|
|
360
|
+
const mediaEls = mp2.match(MEDIA_EL_RE) || [];
|
|
361
|
+
const mediaIds = new Set(mediaEls.flatMap((el) => el.match(UUID_RE) || []));
|
|
362
|
+
const tlEl = (mp2.match(TL_EL_RE) || [])[0];
|
|
363
|
+
if (!tlEl) throw new Error('assemble_project: merged archive has no timeline pool clip');
|
|
364
|
+
// cluster text: the pool clip element + every SeqContainer entry
|
|
365
|
+
const containers = {};
|
|
366
|
+
for (const n of Object.keys(zip.files)) {
|
|
367
|
+
if (!zip.files[n].dir && /SeqContainer\/.+\.xml$/.test(n)) containers[n] = await zip.file(n).async('string');
|
|
368
|
+
}
|
|
369
|
+
const clusterText = tlEl + Object.values(containers).join('');
|
|
370
|
+
const remap = new Map();
|
|
371
|
+
for (const id of new Set(clusterText.match(UUID_RE) || [])) {
|
|
372
|
+
if (baseIds.has(id) && !mediaIds.has(id)) remap.set(id, randomUUID());
|
|
373
|
+
}
|
|
374
|
+
const apply = (text) => { let t = text; for (const [a, b] of remap) t = t.split(a).join(b); return t; };
|
|
375
|
+
// Keyed FieldsBlobs store uuid VALUES as UTF-16 — invisible to the
|
|
376
|
+
// plaintext remap. (Measured the hard way: the pool clip's top
|
|
377
|
+
// FieldsBlob is keyed {ActiveVer→uuid}; left unpatched it pointed
|
|
378
|
+
// REEL_02's active version at REEL_01's cluster and the timeline
|
|
379
|
+
// silently never materialized.) Decode every keyed blob, remap any
|
|
380
|
+
// string entry matching a remapped uuid, re-encode. Non-keyed blobs
|
|
381
|
+
// (zstd bodies etc.) decode-fail and pass through untouched.
|
|
382
|
+
const applyBlobs = (text) => text.replace(/<FieldsBlob>([0-9a-fA-F]+)<\/FieldsBlob>/g, (whole, hex) => {
|
|
383
|
+
try {
|
|
384
|
+
const dict = decodeKeyedDict(Buffer.from(hex, 'hex'));
|
|
385
|
+
let changed = false;
|
|
386
|
+
for (const e of dict.entries) {
|
|
387
|
+
if (typeof e.value === 'string' && remap.has(e.value)) { e.value = remap.get(e.value); changed = true; }
|
|
388
|
+
}
|
|
389
|
+
if (!changed) return whole;
|
|
390
|
+
return `<FieldsBlob>${encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex')}</FieldsBlob>`;
|
|
391
|
+
} catch { return whole; }
|
|
392
|
+
});
|
|
393
|
+
// pool clip: uuid remap + keyed SeqRef patch + fresh numeric IDs
|
|
394
|
+
let tlNew = applyBlobs(apply(tlEl)).replace(/<ID>\d+<\/ID>/g, `<ID>${nextPoolId}<\/ID>`.replace('<\/ID>', '</ID>'));
|
|
395
|
+
nextPoolId += 1;
|
|
396
|
+
const fbM = tlNew.match(/(<Sm2Sequence DbId="[^"]+">\s*<FieldsBlob>)([0-9a-fA-F]+)(<\/FieldsBlob>)/);
|
|
397
|
+
if (fbM) {
|
|
398
|
+
const dict = decodeKeyedDict(Buffer.from(fbM[2], 'hex'));
|
|
399
|
+
const seqRef = dict.entries.find((e) => e.key === 'SeqRef');
|
|
400
|
+
if (seqRef && remap.has(String(seqRef.value))) {
|
|
401
|
+
seqRef.value = remap.get(String(seqRef.value));
|
|
402
|
+
tlNew = tlNew.replace(fbM[0], `${fbM[1]}${encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex')}${fbM[3]}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const [n, xml] of Object.entries(containers)) {
|
|
406
|
+
const remapped = applyBlobs(apply(xml));
|
|
407
|
+
const cid = (remapped.match(/<Sm2SequenceContainer DbId="([^"]+)"/) || [])[1];
|
|
408
|
+
base.file(cid ? `SeqContainer/${cid}.xml` : n, remapped);
|
|
409
|
+
}
|
|
410
|
+
// Children live INSIDE <MediaVec> — an element appended after its
|
|
411
|
+
// close parses fine and is silently invisible (measured: the whole
|
|
412
|
+
// REEL_02 subtree vanished while every id chain checked out).
|
|
413
|
+
mpXml = mpXml.replace('</MediaVec>', `${tlNew}\n</MediaVec>`);
|
|
414
|
+
for (const el of mediaEls) {
|
|
415
|
+
const id = el.match(/DbId="([^"]+)"/)[1];
|
|
416
|
+
if (!mpXml.includes(`DbId="${id}"`)) mpXml = mpXml.replace('</MediaVec>', `${el}\n</MediaVec>`);
|
|
417
|
+
}
|
|
418
|
+
const pj2 = await zip.file('project.xml').async('string');
|
|
419
|
+
for (const blob of pj2.match(/<Element>\s*<Sm2(?:Sequence|TiItem)LockableBlob DbId="[^"]+">[\s\S]*?<\/Sm2(?:Sequence|TiItem)LockableBlob>\s*<\/Element>/g) || []) {
|
|
420
|
+
pjXml = pjXml.replace('</LocableBlobSet>', `${apply(blob)}</LocableBlobSet>`);
|
|
421
|
+
}
|
|
422
|
+
// THE timeline registry (found the hard way — two invisible merges):
|
|
423
|
+
// project.xml's <TimelineHandleVec> lists each pool clip's embedded
|
|
424
|
+
// Sm2Timeline DbId; a pool clip absent from the vec imports as pool
|
|
425
|
+
// furniture but never materializes as a timeline. Carry the later
|
|
426
|
+
// archive's handles over, remapped with its cluster.
|
|
427
|
+
const vec2 = pj2.match(/<TimelineHandleVec>([\s\S]*?)<\/TimelineHandleVec>/);
|
|
428
|
+
if (vec2) {
|
|
429
|
+
for (const h of vec2[1].match(/<Element>[^<]*<\/Element>/g) || []) {
|
|
430
|
+
pjXml = pjXml.replace('</TimelineHandleVec>', ` ${apply(h)}\n </TimelineHandleVec>`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// future merges must not collide with THIS timeline's fresh ids either
|
|
434
|
+
for (const id of remap.values()) baseIds.add(id);
|
|
435
|
+
for (const id of clusterText.match(UUID_RE) || []) if (!remap.has(id)) baseIds.add(id);
|
|
436
|
+
}
|
|
437
|
+
base.file(mpP, mpXml);
|
|
438
|
+
base.file('project.xml', pjXml);
|
|
439
|
+
let outBuf = await base.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
440
|
+
let stamped = null;
|
|
441
|
+
if (p.targetAppVersion !== undefined) {
|
|
442
|
+
const targetPV = resolveTargetProjectVersion({ targetAppVersion: p.targetAppVersion });
|
|
443
|
+
const appVer = `${p.targetAppVersion}${'.0'.repeat(Math.max(0, 4 - String(p.targetAppVersion).split('.').length))}`;
|
|
444
|
+
const zip = await JSZip.loadAsync(outBuf);
|
|
445
|
+
const { out, elementPatches, stampPatches } = await applyVersionStamps(zip, targetPV, appVer);
|
|
446
|
+
outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
447
|
+
stamped = { targetProjectVersion: targetPV, elementPatches, stampPatches };
|
|
448
|
+
}
|
|
449
|
+
await fs.writeFile(p.outputPath, outBuf);
|
|
450
|
+
return {
|
|
451
|
+
outputPath: p.outputPath, bytes: outBuf.length, timelines: names, stamped,
|
|
452
|
+
note: 'Import as a PROJECT (project_manager.safe_project_import); timeline.import_timeline_checked takes one timeline per file — use drt.extract_from_drp to pull singles.',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
294
455
|
if (action === 'assemble') {
|
|
295
456
|
const p = assembleSchema.parse(args);
|
|
296
457
|
// Native-schema authoring: template-spliced real Resolve structures
|
|
@@ -24,10 +24,17 @@ import sys
|
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
26
|
REPO = Path(__file__).resolve().parent.parent
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
|
|
28
|
+
ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
|
|
29
29
|
DEFAULT_PORT = 49632
|
|
30
30
|
|
|
31
|
+
|
|
32
|
+
def config_path() -> Path:
|
|
33
|
+
"""Bridge config written by this installer and read by the MCP client."""
|
|
34
|
+
override = os.environ.get(ENV_CONFIG_PATH)
|
|
35
|
+
return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
|
|
36
|
+
|
|
37
|
+
|
|
31
38
|
_SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility"
|
|
32
39
|
|
|
33
40
|
#: A sandboxed (App Store) build sees its container as HOME, so Resolve's
|
|
@@ -448,10 +455,11 @@ def python_preflight() -> dict:
|
|
|
448
455
|
|
|
449
456
|
|
|
450
457
|
def ensure_config(port: int, rotate: bool) -> dict:
|
|
458
|
+
path = config_path()
|
|
451
459
|
existing: dict = {}
|
|
452
|
-
if
|
|
460
|
+
if path.exists():
|
|
453
461
|
try:
|
|
454
|
-
existing = json.loads(
|
|
462
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
455
463
|
except (OSError, ValueError):
|
|
456
464
|
existing = {}
|
|
457
465
|
token = existing.get("token")
|
|
@@ -469,12 +477,12 @@ def ensure_config(port: int, rotate: bool) -> dict:
|
|
|
469
477
|
"allowed_media_roots": existing_media or [str(Path.home())],
|
|
470
478
|
"allowed_output_roots": existing_output or [str(Path.home() / "Movies")],
|
|
471
479
|
}
|
|
472
|
-
|
|
473
|
-
tmp =
|
|
480
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
481
|
+
tmp = path.with_suffix(".tmp")
|
|
474
482
|
tmp.write_text(json.dumps(config, indent=2, sort_keys=True), encoding="utf-8")
|
|
475
483
|
os.chmod(tmp, 0o600)
|
|
476
|
-
tmp.replace(
|
|
477
|
-
os.chmod(
|
|
484
|
+
tmp.replace(path)
|
|
485
|
+
os.chmod(path, 0o600)
|
|
478
486
|
return config
|
|
479
487
|
|
|
480
488
|
|
|
@@ -525,8 +533,9 @@ def install(*, probe_only: bool, port: int, rotate: bool) -> dict:
|
|
|
525
533
|
)
|
|
526
534
|
result["skipped"] = [path for path, _ in skipped]
|
|
527
535
|
if not probe_only:
|
|
528
|
-
|
|
529
|
-
|
|
536
|
+
path = config_path()
|
|
537
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
538
|
+
result["config"] = {"path": str(path),
|
|
530
539
|
**{k: v for k, v in loaded.items() if k != "token"}}
|
|
531
540
|
return result
|
|
532
541
|
|
|
@@ -558,9 +567,10 @@ def _install_to(target: Path, *, probe_only: bool, installed: list[str]) -> None
|
|
|
558
567
|
# unreachable by construction (measured: "Operation not permitted").
|
|
559
568
|
# The runtime dir is already inside the container, so the config goes
|
|
560
569
|
# beside the modules. Same token, so the out-of-sandbox client still
|
|
561
|
-
# authenticates against
|
|
570
|
+
# authenticates against the selected config path.
|
|
562
571
|
runtime_config = runtime / "bridge.json"
|
|
563
|
-
|
|
572
|
+
path = config_path()
|
|
573
|
+
shutil.copy2(path, runtime_config)
|
|
564
574
|
os.chmod(runtime_config, 0o600)
|
|
565
575
|
installed.append(str(runtime_config))
|
|
566
576
|
# The launcher IS the menu entry. Without it the modules sit in the
|
|
@@ -571,7 +581,7 @@ def _install_to(target: Path, *, probe_only: bool, installed: list[str]) -> None
|
|
|
571
581
|
base64.urlsafe_b64encode(str(runtime).encode("utf-8")).decode("ascii"),
|
|
572
582
|
).replace(
|
|
573
583
|
"@@CONFIG_PATH_B64@@",
|
|
574
|
-
base64.urlsafe_b64encode(str(
|
|
584
|
+
base64.urlsafe_b64encode(str(path).encode("utf-8")).decode("ascii"),
|
|
575
585
|
)
|
|
576
586
|
launcher_path = target / "resolve_bridge.py"
|
|
577
587
|
launcher_path.write_text(launcher, encoding="utf-8")
|
|
@@ -605,6 +615,9 @@ def main() -> int:
|
|
|
605
615
|
print(" 1. Restart DaVinci Resolve so it re-scans the Scripts folders.")
|
|
606
616
|
print(" 2. Open a saved project (the Scripts menu is empty in Project Manager).")
|
|
607
617
|
print(" 3. Workspace > Scripts > resolve_bridge_probe — run it TWICE.")
|
|
618
|
+
# The probe runs INSIDE Resolve, which never sees the shell's
|
|
619
|
+
# DAVINCI_RESOLVE_BRIDGE_CONFIG — it always writes to the fixed default
|
|
620
|
+
# directory, so the guidance must not follow the override.
|
|
608
621
|
print(" 4. Read ~/.config/davinci-resolve-mcp/host-model-probe.json")
|
|
609
622
|
return 0
|
|
610
623
|
|
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.145.1"
|
|
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()}")
|