davinci-resolve-mcp 2.104.1 → 2.104.3

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +2 -2
  4. package/docs/guides/headless-edit-loop.md +15 -0
  5. package/docs/reference/api-limitations.md +10 -1
  6. package/install.py +33 -12
  7. package/package.json +1 -1
  8. package/resolve-advanced/server/db-patch.mjs +15 -3
  9. package/resolve-advanced/server/offline-ref-db.mjs +8 -2
  10. package/resolve-advanced/server/tools/project_db.mjs +1 -1
  11. package/resolve-advanced/vendor/drp-format/__tests__/framerate-encoding.test.js +47 -0
  12. package/resolve-advanced/vendor/drp-format/drp-packager.js +1 -0
  13. package/resolve-advanced/vendor/drp-format/seq-container-builder.js +11 -3
  14. package/resolve-advanced/vendor/drp-format/xml-builder.js +23 -19
  15. package/scripts/doctor.py +85 -39
  16. package/src/granular/common.py +1 -1
  17. package/src/granular/media_pool.py +15 -7
  18. package/src/granular/timeline.py +10 -5
  19. package/src/granular/timeline_item.py +21 -7
  20. package/src/server.py +408 -82
  21. package/src/utils/api_truth.py +26 -0
  22. package/src/utils/app_control.py +11 -1
  23. package/src/utils/audio_fairlight_live_probe.py +8 -1
  24. package/src/utils/color_grade_live_probe.py +8 -1
  25. package/src/utils/fusion_composition_live_probe.py +8 -1
  26. package/src/utils/probe_catalogue.py +15 -3
  27. package/src/utils/project_cleanup.py +17 -3
  28. package/src/utils/render_deliver_live_probe.py +8 -1
  29. package/src/utils/resolve_bridge_client.py +10 -1
  30. package/src/utils/resolve_writes.py +79 -0
  31. package/src/utils/review_annotation_live_probe.py +8 -1
  32. package/src/utils/timeline_conform_live_probe.py +8 -1
  33. package/src/utils/timeline_kernel_live_probe.py +7 -1
  34. package/src/utils/timeline_versioning.py +15 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
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.104.3
6
+
7
+ Documentation follow-through on the v2.104.2 batch.
8
+
9
+ - The FCP7 internal-sequence-name-overrides-timelineName behavior (#171) is now
10
+ a submit-tagged api_truth entry, so it feeds the Blackmagic-facing
11
+ limitations report alongside the fix that works around it.
12
+ - `project_db.list_subtitle_styles`'s styled:false note now states that the
13
+ "must be styled once in the UI" precondition covers the scripted
14
+ `ImportMedia(srt)` + `AppendToTimeline` route too (confirmed by the #169
15
+ reporter on Studio 21.0.4.5), not only tracks added empty in the UI.
16
+
17
+ ## What's New in v2.104.2
18
+
19
+ A contributor batch: two merged PRs, one PR converted into its fix, and four
20
+ sharp issues from @andytsai821201-spec — all live- or repro-verified.
21
+
22
+ **Merged.** PR #166 by @matoberuc-afk routes SetCurrentTimeline and 28 other
23
+ discarded Resolve mutator returns through a checked helper — a refused
24
+ timeline switch now errors instead of silently sending the next edit to
25
+ whatever timeline was current. PR #170 by @FerroQuant makes the doctor and
26
+ installer probes bridge-first and hard-exits probe children after native
27
+ Fusion imports, extending PR #108's fusionscript-teardown rule to the
28
+ remaining short-lived probes.
29
+
30
+ **Fixed (from PR #165 by @Douglas4000).** `timeline_frame capture` died on the
31
+ free-edition bridge with "unexpected keyword argument 'isInteractiveMode'":
32
+ the bridge proxies Resolve calls positionally, and the single-frame render
33
+ used a keyword. The call is positional now, and the bridge proxy raises a
34
+ TypeError that names the rule instead of the bare stack trace.
35
+
36
+ **Fixed (#167).** `drt.author`'s hand-typed frame-rate hex table was wrong in
37
+ three of eight entries: 23.976 stored 30000/1001 (a different, plausible
38
+ rate), 29.97 stored 29.9739, and 59.94 stored 0.9367 — while validate stayed
39
+ green. The table is gone; rounded NTSC decimals snap to their exact rationals
40
+ and everything encodes through writeDoubleLE.
41
+
42
+ **Fixed (#168).** `drt.author` wrote fractional `<StartFrame>` values at
43
+ fractional rates (01:00:00:00 at 30000/1001 → 107892.107…) and ignored the
44
+ spec's `startFrame` field entirely. Frame indexes now round, and an explicit
45
+ startFrame wins over the timecode.
46
+
47
+ **Fixed (#169).** `project_db` by projectName never searched
48
+ `Resolve Project Library/Resolve Projects` — the root a stock modern Studio
49
+ install actually uses (this repo's own 19.1.3 machine uses the old
50
+ `Resolve Disk Database` name, so both are real). Both Studio roots and the
51
+ sandboxed free-edition root are searched and deduped.
52
+
53
+ **Fixed (#171).** Resolve honours the sequence name INSIDE an FCP7 XML over
54
+ the `timelineName` import option, so an iterating export→edit→import loop
55
+ with a stale internal name "succeeded" while returning the same existing
56
+ timeline forever. `import_timeline_checked` now rewrites the XML's internal
57
+ sequence name to the requested timelineName before importing (surgical text
58
+ replacement on a temp copy — DOCTYPE and clip names survive byte-for-byte),
59
+ and a format it cannot rewrite that still returns an existing timeline errors
60
+ instead of reporting success. Live-verified on Studio 19.1.3.7 with the
61
+ reporter's exact step sequence. The headless-edit-loop guide documents the
62
+ internal-name rule for raw-API callers.
63
+
5
64
  ## What's New in v2.104.1
6
65
 
7
66
  **The job metadata lies too — verify_output now cross-checks the timeline.**
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.104.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.104.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.104.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.104.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -61,6 +61,21 @@ name is taken. An iterative loop that reuses one name works exactly once and
61
61
  then quietly does nothing, which is the worst possible failure for an automated
62
62
  edit cycle. Make the name unique per iteration.
63
63
 
64
+ ### A unique `timelineName` is still not enough — the file's internal name wins
65
+
66
+ Resolve honours the sequence name **inside** the interchange file over the
67
+ `timelineName` option (issue #171, measured on Studio 21.0.4.5). Export → edit
68
+ → re-import with `timelineName: CUT_v002` while the XML still says `CUT_v001`
69
+ and Resolve hands back the **existing** `CUT_v001` timeline: the raw API
70
+ reports the old timeline as if it were the import, and the loop operates on one
71
+ timeline forever. When driving the raw API, bump the `<sequence><name>` inside
72
+ the XML each iteration.
73
+
74
+ `timeline.import_timeline_checked` handles both halves for you: it rewrites the
75
+ FCP7 XML's internal sequence name to the requested `timelineName` before
76
+ importing, and if a format it cannot rewrite still returns an existing timeline
77
+ it errors instead of reporting success.
78
+
64
79
  ### DRT ignores `timelineName` and re-imports the media
65
80
 
66
81
  DRT is the native format and the obvious first choice, but it behaves
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 33 missing capabilities, 39 bugs / unreliable behaviors.
15
+ **Totals:** 33 missing capabilities, 40 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -517,6 +517,15 @@ values, or automation-hostile modal prompts.
517
517
  - **Reference:** [issue #77](https://github.com/samuelgursky/davinci-resolve-mcp/issues/77)
518
518
  - **Tags:** unreliable-return, silent-failure, metadata, reel-name
519
519
 
520
+ ### MediaPool.ImportTimelineFromFile (internal sequence name overrides timelineName)
521
+
522
+ - **Object:** `MediaPool`
523
+ - **Signature:** `(filePath, {timelineName, importSourceClips, ...}) -> Timeline`
524
+ - **Behavior:** For FCP7 XML, the sequence name INSIDE the file wins over the timelineName import option. When the internal name matches an existing timeline, the call returns that EXISTING timeline — no error, no new timeline — so an export→edit→re-import loop keying uniqueness on the option 'succeeds' while operating on one timeline forever (issue #171, Studio 21.0.4.5; wrapper behavior verified on 19.1.3.7). Distinct from the documented repeated-timelineName None return: here the option is fresh and the file's name is stale.
525
+ - **Workaround / current handling:** Rewrite the <sequence><name> inside the file to the intended name before importing — timeline.import_timeline_checked does this automatically for FCP7 XML and errors when a non-rewritable format still returns an existing timeline. Never treat a truthy return as proof of creation; check the returned timeline's id against the pre-import set.
526
+ - **Reference:** [issue #171](https://github.com/samuelgursky/davinci-resolve-mcp/issues/171)
527
+ - **Tags:** timeline, import, silent-failure, unreliable-return
528
+
520
529
  ### Timeline.DeleteClips (requires the Edit page; flaky first attempt)
521
530
 
522
531
  - **Object:** `Timeline`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.104.1"
40
+ VERSION = "2.104.3"
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
@@ -1329,37 +1329,57 @@ def access_violation_message(returncode, version=None):
1329
1329
 
1330
1330
 
1331
1331
  def verify_resolve_connection(python_path, api_path, lib_path):
1332
- """Try to import DaVinciResolveScript and connect."""
1332
+ """Probe Resolve without exposing short-lived children to Fusion teardown.
1333
+
1334
+ The persistent bridge is tried before Blackmagic's native scripting module.
1335
+ If direct scripting is genuinely required, the disposable child flushes its
1336
+ result and hard-exits after any native import attempt so fusionscript's
1337
+ background RemoteApp thread cannot race CPython finalization.
1338
+ """
1333
1339
  if not api_path:
1334
1340
  return False, "Resolve API path not found"
1335
1341
 
1336
1342
  env = {**os.environ, **build_server_env(python_path, api_path, lib_path)}
1337
1343
  modules_path = env["PYTHONPATH"]
1338
1344
  repo_root = str(Path(__file__).resolve().parent)
1339
- # Route through connect_resolve so Network mode (RESOLVE_SCRIPT_HOST, propagated
1340
- # into env by build_server_env) uses the explicit IP-targeted overload. Fall
1341
- # back to Local-mode discovery if the helper cannot be imported.
1342
1345
  test_script = textwrap.dedent(f"""\
1346
+ import os
1343
1347
  import sys
1344
1348
  sys.path.insert(0, {modules_path!r})
1345
1349
  sys.path.insert(0, {repo_root!r})
1350
+ native_import_attempted = False
1346
1351
  try:
1347
- import DaVinciResolveScript as dvr
1348
1352
  try:
1349
1353
  from src.utils.resolve_connection import connect_resolve
1350
1354
  except Exception:
1351
- connect_resolve = lambda mod: mod.scriptapp('Resolve')
1352
- resolve = connect_resolve(dvr)
1355
+ connect_resolve = None
1356
+
1357
+ resolve = None
1358
+ if connect_resolve is not None:
1359
+ resolve = connect_resolve(None)
1360
+
1361
+ if resolve is None:
1362
+ native_import_attempted = True
1363
+ import DaVinciResolveScript as dvr
1364
+ if connect_resolve is not None:
1365
+ resolve = connect_resolve(dvr)
1366
+ else:
1367
+ resolve = dvr.scriptapp('Resolve')
1368
+
1353
1369
  if resolve:
1354
1370
  name = resolve.GetProductName()
1355
1371
  ver = resolve.GetVersionString()
1356
- print(f"CONNECTED: {{name}} {{ver}}")
1372
+ print(f"CONNECTED: {{name}} {{ver}}", flush=True)
1357
1373
  else:
1358
- print("IMPORTED_OK: Module loads but Resolve not running or not responding")
1374
+ print("IMPORTED_OK: Module loads but Resolve not running or not responding", flush=True)
1359
1375
  except ImportError as e:
1360
- print(f"IMPORT_ERROR: {{e}}")
1376
+ print(f"IMPORT_ERROR: {{e}}", flush=True)
1361
1377
  except Exception as e:
1362
- print(f"ERROR: {{e}}")
1378
+ print(f"ERROR: {{e}}", flush=True)
1379
+ if native_import_attempted:
1380
+ sys.stdout.flush()
1381
+ sys.stderr.flush()
1382
+ os._exit(0)
1363
1383
  """)
1364
1384
 
1365
1385
  process_timeout = 10.0
@@ -1400,6 +1420,7 @@ def verify_resolve_connection(python_path, api_path, lib_path):
1400
1420
  except Exception as e:
1401
1421
  return False, str(e)
1402
1422
 
1423
+
1403
1424
  # ─── Interactive UI ───────────────────────────────────────────────────────────
1404
1425
 
1405
1426
  def print_banner():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.104.1",
3
+ "version": "2.104.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -18,6 +18,17 @@ const require = createRequire(import.meta.url);
18
18
 
19
19
  export const DISK_DB_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Disk Database/Resolve Projects');
20
20
 
21
+ /**
22
+ * The other Studio layout. A stock modern Studio install on macOS keeps its
23
+ * local library under "Resolve Project Library", not "Resolve Disk Database"
24
+ * (issue #169, confirmed on Studio 21.0.4.5: every local project sits at
25
+ * Resolve Project Library/Resolve Projects/Users/<dbuser>/Projects/<name>/).
26
+ * The recursive walk already spans the Users/<dbuser>/Projects segment, so
27
+ * listing the root is all that is needed. Which root exists depends on how
28
+ * and when the library was created — search both, dedupe, never guess.
29
+ */
30
+ export const PROJECT_LIBRARY_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Project Library/Resolve Projects');
31
+
21
32
  /**
22
33
  * The FREE edition ships from the App Store and runs SANDBOXED, so its project
23
34
  * library is not under Application Support at all — it lives inside the app's
@@ -39,7 +50,7 @@ export const LITE_DB_ROOT = path.join(
39
50
  );
40
51
 
41
52
  /** Every root searched when resolving a project by name, Studio first. */
42
- export const DB_ROOTS = [DISK_DB_ROOT, LITE_DB_ROOT];
53
+ export const DB_ROOTS = [DISK_DB_ROOT, PROJECT_LIBRARY_ROOT, LITE_DB_ROOT];
43
54
 
44
55
  export function loadSqlite() {
45
56
  try {
@@ -82,8 +93,9 @@ export function resolveDbPath({ projectDb, projectName }) {
82
93
  const hits = [...new Set(DB_ROOTS.flatMap((root) => findProjectDb(projectName, root)))];
83
94
  if (!hits.length) {
84
95
  throw new Error(
85
- `no Project.db found for project "${projectName}". Searched the Studio library ` +
86
- `(${DISK_DB_ROOT}) and the sandboxed free-edition library (${LITE_DB_ROOT}). ` +
96
+ `no Project.db found for project "${projectName}". Searched the Studio libraries ` +
97
+ `(${DISK_DB_ROOT} and ${PROJECT_LIBRARY_ROOT}) and the sandboxed free-edition ` +
98
+ `library (${LITE_DB_ROOT}). ` +
87
99
  'If Resolve keeps its projects elsewhere — a relocated library, a network/Postgres ' +
88
100
  'database, or the free edition on Windows/Linux — pass projectDb with the full path.',
89
101
  );
@@ -33,6 +33,9 @@ import { createRequire } from 'node:module';
33
33
  const require = createRequire(import.meta.url);
34
34
 
35
35
  const DISK_DB_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Disk Database/Resolve Projects');
36
+ // Modern Studio installs keep the local library under "Resolve Project
37
+ // Library" instead (issue #169) — same shape, different root name.
38
+ const PROJECT_LIBRARY_ROOT = path.join(os.homedir(), 'Library/Application Support/Blackmagic Design/DaVinci Resolve/Resolve Project Library/Resolve Projects');
36
39
 
37
40
  function loadSqlite() {
38
41
  try {
@@ -86,8 +89,11 @@ function openDb(dbPath, writable) {
86
89
  function resolveDbPath({ projectDb, projectName }) {
87
90
  if (projectDb) return projectDb;
88
91
  if (!projectName) throw new Error('provide projectDb (path) or projectName');
89
- const hits = findProjectDb(projectName);
90
- if (!hits.length) throw new Error(`no Project.db found for project "${projectName}" under the Resolve Disk Database`);
92
+ const hits = [...new Set([
93
+ ...findProjectDb(projectName, DISK_DB_ROOT),
94
+ ...findProjectDb(projectName, PROJECT_LIBRARY_ROOT),
95
+ ])];
96
+ if (!hits.length) throw new Error(`no Project.db found for project "${projectName}" under ${DISK_DB_ROOT} or ${PROJECT_LIBRARY_ROOT}`);
91
97
  if (hits.length > 1) throw new Error(`multiple Project.db match "${projectName}": ${hits.join(', ')} — pass projectDb explicitly`);
92
98
  return hits[0];
93
99
  }
@@ -259,7 +259,7 @@ export const projectDbTool = {
259
259
  return {
260
260
  tracks,
261
261
  note: tracks.some((t) => t.styled === false)
262
- ? 'Tracks with styled:false carry no style blob (Resolve writes a NumLayers-only stub until the track is styled once in the UI); set_subtitle_style cannot patch those.'
262
+ ? 'Tracks with styled:false carry no style blob (Resolve writes a NumLayers-only stub until the track is styled once in the UI); set_subtitle_style cannot patch those. This covers scripted routes too: a track built by MediaPool.ImportMedia(srt) + AppendToTimeline([srtClip]) reports styled:false (confirmed on Studio 21.0.4.5, issue #169), so style once in the UI regardless of how the track was created.'
263
263
  : undefined,
264
264
  };
265
265
  } finally {
@@ -0,0 +1,47 @@
1
+ // Issue #167: the hand-typed FRAME_RATE_ENCODINGS table stored 30000/1001 for
2
+ // 23.976, 29.9739 for 29.97, and 0.9367 for 59.94, while validate stayed
3
+ // green. Issue #168: fractional StartFrame at NTSC rates, startFrame ignored.
4
+ const { test } = require('node:test');
5
+ const assert = require('node:assert');
6
+ const { encodeFrameRate, snapFrameRate } = require('../xml-builder');
7
+ const { buildSeqContainerFile } = require('../seq-container-builder');
8
+
9
+ function decode(hex) {
10
+ return Buffer.from(hex, 'hex').readDoubleLE(0);
11
+ }
12
+
13
+ test('rounded NTSC decimals snap to their exact rationals', () => {
14
+ assert.ok(Math.abs(decode(encodeFrameRate(23.976)) - 24000 / 1001) < 1e-9);
15
+ assert.ok(Math.abs(decode(encodeFrameRate(29.97)) - 30000 / 1001) < 1e-9);
16
+ assert.ok(Math.abs(decode(encodeFrameRate(59.94)) - 60000 / 1001) < 1e-9);
17
+ assert.ok(Math.abs(decode(encodeFrameRate(23.98)) - 24000 / 1001) < 1e-9);
18
+ });
19
+
20
+ test('exact rationals and integers round-trip untouched', () => {
21
+ for (const fps of [24, 25, 30, 48, 50, 60, 30000 / 1001, 24000 / 1001, 60000 / 1001]) {
22
+ assert.ok(Math.abs(decode(encodeFrameRate(fps)) - snapFrameRate(fps)) < 1e-12);
23
+ if (Number.isInteger(fps)) {
24
+ assert.strictEqual(snapFrameRate(fps), fps, `integer ${fps} must not snap`);
25
+ }
26
+ }
27
+ });
28
+
29
+ test('the three formerly-wrong table rates never reappear', () => {
30
+ // The old table's values, byte for byte. None may ever be produced again
31
+ // for the inputs that used to hit them.
32
+ assert.notStrictEqual(encodeFrameRate(23.976), '286b55e253f83d40');
33
+ assert.notStrictEqual(encodeFrameRate(29.97), '286b55e253f93d40');
34
+ assert.notStrictEqual(encodeFrameRate(59.94), '286b55e253f9ed3f');
35
+ });
36
+
37
+ test('StartFrame is an integer at fractional rates and startFrame wins', async () => {
38
+ const tl = { name: 'T', videoTracks: [], audioTracks: [] };
39
+ const fromTc = await buildSeqContainerFile(tl, {
40
+ frameRate: 30000 / 1001, startTimecode: '01:00:00:00',
41
+ });
42
+ assert.match(fromTc, /<StartFrame>107892<\/StartFrame>/);
43
+ const explicit = await buildSeqContainerFile(tl, {
44
+ frameRate: 30000 / 1001, startTimecode: '01:00:00:00', startFrame: 99999,
45
+ });
46
+ assert.match(explicit, /<StartFrame>99999<\/StartFrame>/);
47
+ });
@@ -93,6 +93,7 @@ async function packageFullDRP(options) {
93
93
  const seqContainerXml = await buildSeqContainerFile(timeline, {
94
94
  frameRate: timeline.frameRate || 24,
95
95
  startTimecode: timeline.startTimecode || '01:00:00:00',
96
+ startFrame: timeline.startFrame,
96
97
  markers: timeline.markers || [],
97
98
  resolution: timeline.resolution || '1920x1080',
98
99
  });
@@ -60,8 +60,11 @@ async function buildSeqContainerFile(timeline, options = {}) {
60
60
  // Build lockable blob with markers
61
61
  const lockableBlob = buildLockableBlobElement(markers, lockableBlobId, frameRate);
62
62
 
63
- // Calculate start frame from timecode
64
- const startFrame = timecodeToFrames(startTimecode, frameRate);
63
+ // An explicit startFrame wins; otherwise derive it from the timecode
64
+ // (issue #168 the spec field used to be ignored entirely).
65
+ const startFrame = Number.isFinite(options.startFrame)
66
+ ? Math.round(options.startFrame)
67
+ : timecodeToFrames(startTimecode, frameRate);
65
68
 
66
69
  // Combine video tracks: standard video tracks first, then Rich title tracks
67
70
  const allVideoTrackElements = [...videoTrackElements, ...richTitleTrackElements];
@@ -364,7 +367,11 @@ function timecodeToFrames(timecode, fps = 24) {
364
367
  if (parts.length !== 4) return 0;
365
368
 
366
369
  const [hh, mm, ss, ff] = parts;
367
- return hh * 3600 * fps + mm * 60 * fps + ss * fps + ff;
370
+ // A frame index is an integer. At fractional rates the seconds product is
371
+ // fractional (3600 x 30000/1001 = 107892.107...), and writing it raw put a
372
+ // fractional <StartFrame> in the XML (issue #168). Non-drop convention:
373
+ // round the seconds part, then add the frame component.
374
+ return Math.round(hh * 3600 * fps + mm * 60 * fps + ss * fps) + ff;
368
375
  }
369
376
 
370
377
  // =============================================================================
@@ -549,6 +556,7 @@ async function buildSeqContainerFiles(timelines, options = {}) {
549
556
  ...options,
550
557
  frameRate: timeline.frameRate || options.frameRate || 24,
551
558
  startTimecode: timeline.startTimecode || options.startTimecode || '01:00:00:00',
559
+ startFrame: timeline.startFrame ?? options.startFrame,
552
560
  markers: timeline.markers || options.markers || [],
553
561
  });
554
562
  results.push({ filename: `SeqContainer${idx + 1}.xml`, content });
@@ -30,19 +30,28 @@ const DEFAULT_PROJECT_SETTINGS = {
30
30
  };
31
31
 
32
32
  /**
33
- * Frame rate encoding lookup table
34
- * Maps common frame rates to their hex-encoded double representation
33
+ * Snap rounded NTSC decimals to their exact rationals.
34
+ *
35
+ * A caller writing 23.976 / 29.97 / 59.94 means 24000/1001, 30000/1001,
36
+ * 60000/1001 — the value Resolve itself stores. Integer rates are left
37
+ * untouched (24 × 1.001 = 24.024 sits outside the window), and an exact
38
+ * rational snaps to itself. This replaces a hand-typed hex lookup table whose
39
+ * 23.976 entry actually held 30000/1001, whose 29.97 entry held 29.9739, and
40
+ * whose 59.94 entry held 0.9367 (issue #167) — magic constants nobody could
41
+ * read were wrong for years while every exact input bypassed them.
35
42
  */
36
- const FRAME_RATE_ENCODINGS = {
37
- 23.976: '286b55e253f83d40',
38
- 24.0: '0000000000003840',
39
- 25.0: '0000000000003940',
40
- 29.97: '286b55e253f93d40',
41
- 30.0: '0000000000003e40',
42
- 50.0: '0000000000004940',
43
- 59.94: '286b55e253f9ed3f',
44
- 60.0: '0000000000004e40'
45
- };
43
+ function snapFrameRate(fps) {
44
+ const scaled = (fps * 1001) / 1000;
45
+ const nearest = Math.round(scaled);
46
+ if (
47
+ nearest > 0 &&
48
+ Math.abs(scaled - nearest) < 0.02 &&
49
+ Math.abs(fps - nearest) > 1e-9
50
+ ) {
51
+ return (nearest * 1000) / 1001;
52
+ }
53
+ return fps;
54
+ }
46
55
 
47
56
  // ============================================================================
48
57
  // HELPER FUNCTIONS
@@ -73,14 +82,9 @@ function generateUUID() {
73
82
  * // Returns: "0000000000003840"
74
83
  */
75
84
  function encodeFrameRate(fps) {
76
- // Check if we have a pre-encoded value
77
- if (FRAME_RATE_ENCODINGS[fps]) {
78
- return FRAME_RATE_ENCODINGS[fps];
79
- }
80
-
81
85
  // Encode as IEEE 754 double precision (8 bytes, little-endian)
82
86
  const buffer = Buffer.allocUnsafe(8);
83
- buffer.writeDoubleLE(fps, 0);
87
+ buffer.writeDoubleLE(snapFrameRate(fps), 0);
84
88
  return buffer.toString('hex');
85
89
  }
86
90
 
@@ -755,5 +759,5 @@ module.exports = {
755
759
 
756
760
  // Constants
757
761
  DEFAULT_PROJECT_SETTINGS,
758
- FRAME_RATE_ENCODINGS
762
+ snapFrameRate
759
763
  };
package/scripts/doctor.py CHANGED
@@ -275,35 +275,71 @@ def resolve_probe(
275
275
  if not PYTHON.exists():
276
276
  return {"import_ok": False, "error": f"{PYTHON} is missing"}
277
277
 
278
+ # Disposable probe: ask the persistent bridge before importing Blackmagic's
279
+ # native Fusion module. fusionscript can leave a RemoteApp thread alive and
280
+ # crash CPython during interpreter finalization. If native import is needed,
281
+ # flush the result and hard-exit so those unsafe finalizers never run.
278
282
  code = f"""
279
283
  import json
284
+ import os
280
285
  import sys
281
286
  sys.path.insert(0, {str(REPO)!r})
282
287
  sys.path.insert(0, {str(RESOLVE_MODULES)!r})
288
+ native_import_attempted = False
283
289
  try:
284
- import DaVinciResolveScript as dvr
285
290
  from src.utils.resolve_connection import connect_resolve
286
291
  except Exception as exc:
287
292
  payload = {{"import_ok": False, "error": repr(exc)}}
288
293
  else:
289
294
  try:
290
- resolve = connect_resolve(dvr)
295
+ resolve = connect_resolve(None)
291
296
  except Exception as exc:
292
297
  payload = {{
293
- "import_ok": True,
294
- "module": getattr(dvr, "__file__", None),
298
+ "import_ok": None,
299
+ "import_skipped": True,
295
300
  "resolve_connected": False,
296
301
  "connection_error": repr(exc),
297
302
  }}
298
303
  else:
299
- payload = {{
300
- "import_ok": True,
301
- "module": getattr(dvr, "__file__", None),
302
- "resolve_connected": bool(resolve),
303
- "product": resolve.GetProductName() if resolve else None,
304
- "version": resolve.GetVersionString() if resolve else None,
305
- }}
306
- print(json.dumps(payload))
304
+ if resolve is not None:
305
+ payload = {{
306
+ "import_ok": None,
307
+ "import_skipped": True,
308
+ "module": None,
309
+ "resolve_connected": True,
310
+ "transport": "bridge",
311
+ "product": resolve.GetProductName(),
312
+ "version": resolve.GetVersionString(),
313
+ }}
314
+ else:
315
+ native_import_attempted = True
316
+ try:
317
+ import DaVinciResolveScript as dvr
318
+ except Exception as exc:
319
+ payload = {{"import_ok": False, "error": repr(exc)}}
320
+ else:
321
+ try:
322
+ resolve = connect_resolve(dvr)
323
+ except Exception as exc:
324
+ payload = {{
325
+ "import_ok": True,
326
+ "module": getattr(dvr, "__file__", None),
327
+ "resolve_connected": False,
328
+ "connection_error": repr(exc),
329
+ }}
330
+ else:
331
+ payload = {{
332
+ "import_ok": True,
333
+ "module": getattr(dvr, "__file__", None),
334
+ "resolve_connected": bool(resolve),
335
+ "product": resolve.GetProductName() if resolve else None,
336
+ "version": resolve.GetVersionString() if resolve else None,
337
+ }}
338
+ print(json.dumps(payload), flush=True)
339
+ if native_import_attempted:
340
+ sys.stdout.flush()
341
+ sys.stderr.flush()
342
+ os._exit(0)
307
343
  """
308
344
  env = {
309
345
  **os.environ,
@@ -481,37 +517,47 @@ def collect(
481
517
  check(results, "OK" if pyver["ok"] else "FAIL", "Python version", pyver["stdout"] or pyver["stderr"])
482
518
 
483
519
  probe = resolve_probe(resolve_host, resolve_timeout)
484
- if probe.get("import_ok"):
520
+ if probe.get("import_skipped"):
521
+ check(
522
+ results,
523
+ "OK" if probe.get("resolve_connected") else "INFO",
524
+ "DaVinciResolveScript import",
525
+ "skipped — persistent bridge answered; native Fusion module not loaded"
526
+ if probe.get("resolve_connected")
527
+ else "skipped — bridge mode failed before native Fusion import",
528
+ )
529
+ elif probe.get("import_ok"):
485
530
  check(results, "OK", "DaVinciResolveScript import", str(probe.get("module")))
486
- if probe.get("resolve_connected"):
487
- detail = f"{probe.get('product')} {probe.get('version')}"
488
- check(results, "OK", "Resolve scripting connection", detail)
489
- elif probe.get("connection_error"):
490
- check(
491
- results,
492
- "FAIL",
493
- "Resolve scripting connection",
494
- str(probe["connection_error"]),
495
- )
496
- else:
497
- # This is also exactly what the free edition looks like: the module
498
- # imports fine and scriptapp refuses, because Blackmagic gates
499
- # *external* scripting to Studio. Sending someone to toggle a
500
- # preference that cannot help them is a dead end, so name the bridge
501
- # here too.
502
- check(
503
- results,
504
- "WARN",
505
- "Resolve scripting connection",
506
- "Module import worked, but scriptapp returned no object. On Studio: "
507
- "External scripting = Local, or Network with --resolve-host set to "
508
- "the Resolve host IP, then restart Resolve. On the FREE edition "
509
- "external scripting is gated off entirely — use the in-app bridge "
510
- "(see 'Free-edition bridge' below).",
511
- )
512
531
  else:
513
532
  check(results, "FAIL", "DaVinciResolveScript import", str(probe.get("error")))
514
533
 
534
+ if probe.get("resolve_connected"):
535
+ detail = f"{probe.get('product')} {probe.get('version')}"
536
+ check(results, "OK", "Resolve scripting connection", detail)
537
+ elif probe.get("connection_error"):
538
+ check(
539
+ results,
540
+ "FAIL",
541
+ "Resolve scripting connection",
542
+ str(probe["connection_error"]),
543
+ )
544
+ elif probe.get("import_ok"):
545
+ # This is also exactly what the free edition looks like: the module
546
+ # imports fine and scriptapp refuses, because Blackmagic gates
547
+ # *external* scripting to Studio. Sending someone to toggle a
548
+ # preference that cannot help them is a dead end, so name the bridge
549
+ # here too.
550
+ check(
551
+ results,
552
+ "WARN",
553
+ "Resolve scripting connection",
554
+ "Module import worked, but scriptapp returned no object. On Studio: "
555
+ "External scripting = Local, or Network with --resolve-host set to "
556
+ "the Resolve host IP, then restart Resolve. On the FREE edition "
557
+ "external scripting is gated off entirely — use the in-app bridge "
558
+ "(see 'Free-edition bridge' below).",
559
+ )
560
+
515
561
  results.extend(bridge_checks(probe))
516
562
 
517
563
  results.extend(extras_checks())
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.104.1"
90
+ VERSION = "2.104.3"
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()}")