davinci-resolve-mcp 2.104.0 → 2.104.2

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 (32) hide show
  1. package/CHANGELOG.md +77 -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/install.py +33 -12
  6. package/package.json +1 -1
  7. package/resolve-advanced/server/db-patch.mjs +15 -3
  8. package/resolve-advanced/server/offline-ref-db.mjs +8 -2
  9. package/resolve-advanced/vendor/drp-format/__tests__/framerate-encoding.test.js +47 -0
  10. package/resolve-advanced/vendor/drp-format/drp-packager.js +1 -0
  11. package/resolve-advanced/vendor/drp-format/seq-container-builder.js +11 -3
  12. package/resolve-advanced/vendor/drp-format/xml-builder.js +23 -19
  13. package/scripts/doctor.py +85 -39
  14. package/src/granular/common.py +1 -1
  15. package/src/granular/media_pool.py +15 -7
  16. package/src/granular/timeline.py +10 -5
  17. package/src/granular/timeline_item.py +21 -7
  18. package/src/server.py +497 -93
  19. package/src/utils/api_truth.py +17 -5
  20. package/src/utils/app_control.py +11 -1
  21. package/src/utils/audio_fairlight_live_probe.py +8 -1
  22. package/src/utils/color_grade_live_probe.py +8 -1
  23. package/src/utils/fusion_composition_live_probe.py +8 -1
  24. package/src/utils/probe_catalogue.py +15 -3
  25. package/src/utils/project_cleanup.py +17 -3
  26. package/src/utils/render_deliver_live_probe.py +8 -1
  27. package/src/utils/resolve_bridge_client.py +10 -1
  28. package/src/utils/resolve_writes.py +79 -0
  29. package/src/utils/review_annotation_live_probe.py +8 -1
  30. package/src/utils/timeline_conform_live_probe.py +8 -1
  31. package/src/utils/timeline_kernel_live_probe.py +7 -1
  32. package/src/utils/timeline_versioning.py +15 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,83 @@
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.2
6
+
7
+ A contributor batch: two merged PRs, one PR converted into its fix, and four
8
+ sharp issues from @andytsai821201-spec — all live- or repro-verified.
9
+
10
+ **Merged.** PR #166 by @matoberuc-afk routes SetCurrentTimeline and 28 other
11
+ discarded Resolve mutator returns through a checked helper — a refused
12
+ timeline switch now errors instead of silently sending the next edit to
13
+ whatever timeline was current. PR #170 by @FerroQuant makes the doctor and
14
+ installer probes bridge-first and hard-exits probe children after native
15
+ Fusion imports, extending PR #108's fusionscript-teardown rule to the
16
+ remaining short-lived probes.
17
+
18
+ **Fixed (from PR #165 by @Douglas4000).** `timeline_frame capture` died on the
19
+ free-edition bridge with "unexpected keyword argument 'isInteractiveMode'":
20
+ the bridge proxies Resolve calls positionally, and the single-frame render
21
+ used a keyword. The call is positional now, and the bridge proxy raises a
22
+ TypeError that names the rule instead of the bare stack trace.
23
+
24
+ **Fixed (#167).** `drt.author`'s hand-typed frame-rate hex table was wrong in
25
+ three of eight entries: 23.976 stored 30000/1001 (a different, plausible
26
+ rate), 29.97 stored 29.9739, and 59.94 stored 0.9367 — while validate stayed
27
+ green. The table is gone; rounded NTSC decimals snap to their exact rationals
28
+ and everything encodes through writeDoubleLE.
29
+
30
+ **Fixed (#168).** `drt.author` wrote fractional `<StartFrame>` values at
31
+ fractional rates (01:00:00:00 at 30000/1001 → 107892.107…) and ignored the
32
+ spec's `startFrame` field entirely. Frame indexes now round, and an explicit
33
+ startFrame wins over the timecode.
34
+
35
+ **Fixed (#169).** `project_db` by projectName never searched
36
+ `Resolve Project Library/Resolve Projects` — the root a stock modern Studio
37
+ install actually uses (this repo's own 19.1.3 machine uses the old
38
+ `Resolve Disk Database` name, so both are real). Both Studio roots and the
39
+ sandboxed free-edition root are searched and deduped.
40
+
41
+ **Fixed (#171).** Resolve honours the sequence name INSIDE an FCP7 XML over
42
+ the `timelineName` import option, so an iterating export→edit→import loop
43
+ with a stale internal name "succeeded" while returning the same existing
44
+ timeline forever. `import_timeline_checked` now rewrites the XML's internal
45
+ sequence name to the requested timelineName before importing (surgical text
46
+ replacement on a temp copy — DOCTYPE and clip names survive byte-for-byte),
47
+ and a format it cannot rewrite that still returns an existing timeline errors
48
+ instead of reporting success. Live-verified on Studio 19.1.3.7 with the
49
+ reporter's exact step sequence. The headless-edit-loop guide documents the
50
+ internal-name rule for raw-API callers.
51
+
52
+ ## What's New in v2.104.1
53
+
54
+ **The job metadata lies too — verify_output now cross-checks the timeline.**
55
+ Finishing v2.104.0's pending live validation exposed a hole in the new
56
+ `render.verify_output`: in the issue #164 case (content before the timeline
57
+ start) Resolve rewrites the render job's own MarkIn/MarkOut down to the
58
+ collapsed extent. Measured live on Studio 19.1.3.7: 96 frames of content
59
+ placed before the start turned an explicit 96-frame mark range into a 1-frame
60
+ job — Complete at 100%, a 1-frame black stub, and a clean duration ratio,
61
+ because the expected duration was computed from the job's own lying range.
62
+
63
+ The only truthful readback in that state is the timeline items themselves,
64
+ which report their real positions. `verify_output` now finds the job's
65
+ timeline and cross-checks it: video items starting before the timeline's
66
+ start frame warn (the #164 signature by direct evidence), and a mark range
67
+ under half the items' extent warns (the collapse signature). Callers can also
68
+ pass `expected_frames` or `expected_duration_seconds` outright. Live-verified
69
+ both ways on 19.1.3.7: the healthy render verifies clean, the stub now fails
70
+ with both warnings.
71
+
72
+ ### Fixed
73
+
74
+ - `render.verify_output` no longer trusts the job's MarkIn/MarkOut as the
75
+ expected duration — the #164 stub previously verified clean at ratio 1.0.
76
+
77
+ ### Changed
78
+
79
+ - The api_truth recordFrame entry now documents the mark-range rewrite and
80
+ the item-readback discriminator; the Blackmagic-facing report regenerated.
81
+
5
82
  ## What's New in v2.104.0
6
83
 
7
84
  The read/write symmetry audit's worklist, worked. PR #162's AST rewrite left
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.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.2-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.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.2-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.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.104.2 版 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
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.0"
40
+ VERSION = "2.104.2"
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.0",
3
+ "version": "2.104.2",
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
  }
@@ -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.0"
90
+ VERSION = "2.104.2"
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()}")