davinci-resolve-mcp 2.95.3 → 2.97.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 +140 -0
- package/README.md +5 -5
- package/README.zh-CN.md +6 -6
- package/docs/SKILL.md +85 -17
- package/docs/contributing.md +1 -1
- package/docs/install.md +17 -1
- package/docs/reference/api-coverage.md +2 -2
- package/docs/reference/api-limitations.md +17 -1
- package/install.py +383 -4
- package/package.json +1 -1
- package/resolve-advanced/server/aaf_probe.py +117 -14
- package/resolve-advanced/server/tools/project_read.mjs +35 -2
- package/src/granular/common.py +1 -1
- package/src/server.py +668 -15
- package/src/utils/api_truth.py +49 -0
package/install.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
DaVinci Resolve MCP Server — Universal Installer
|
|
4
4
|
|
|
5
5
|
Supports: macOS, Windows, Linux
|
|
6
|
-
Configures: Claude Desktop, Claude Code, Cursor, VS Code (Copilot),
|
|
6
|
+
Configures: Claude Desktop, Claude Code, Codex CLI, Cursor, VS Code (Copilot),
|
|
7
7
|
Windsurf, Cline, Roo Code, Zed, Continue, OpenCode, and manual setup.
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import math
|
|
19
19
|
import os
|
|
20
20
|
import platform
|
|
21
|
+
import re
|
|
21
22
|
import shutil
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
@@ -36,7 +37,7 @@ from src.utils.update_check import (
|
|
|
36
37
|
|
|
37
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
39
|
|
|
39
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.97.0"
|
|
40
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
|
@@ -318,6 +319,16 @@ def xdg_config():
|
|
|
318
319
|
"""Linux XDG_CONFIG_HOME or default."""
|
|
319
320
|
return Path(os.environ.get("XDG_CONFIG_HOME", home() / ".config"))
|
|
320
321
|
|
|
322
|
+
def codex_config():
|
|
323
|
+
"""OpenAI Codex CLI config path (issue #39).
|
|
324
|
+
|
|
325
|
+
Codex keeps everything under ``$CODEX_HOME`` (default ``~/.codex``) and its
|
|
326
|
+
config is TOML, not JSON -- which is why the JSON-only installer skipped it
|
|
327
|
+
and users found no ``davinci-resolve`` entry after a successful install.
|
|
328
|
+
``scripts/doctor.py`` already probes this exact path.
|
|
329
|
+
"""
|
|
330
|
+
return Path(os.environ.get("CODEX_HOME", home() / ".codex")).expanduser() / "config.toml"
|
|
331
|
+
|
|
321
332
|
def vscode_global_storage():
|
|
322
333
|
"""VS Code global storage path per platform."""
|
|
323
334
|
if is_mac():
|
|
@@ -434,6 +445,16 @@ MCP_CLIENTS = [
|
|
|
434
445
|
"config_key": "mcp",
|
|
435
446
|
"notes": "AI coding agent (uses its own type/enabled/command-array format)",
|
|
436
447
|
},
|
|
448
|
+
{
|
|
449
|
+
"id": "codex",
|
|
450
|
+
"name": "Codex CLI",
|
|
451
|
+
# Codex reads $CODEX_HOME/config.toml (default ~/.codex/config.toml) and
|
|
452
|
+
# keys MCP servers under [mcp_servers.<name>]. TOML, not JSON (issue #39).
|
|
453
|
+
"get_path": codex_config,
|
|
454
|
+
"config_key": "mcp_servers",
|
|
455
|
+
"format": "toml",
|
|
456
|
+
"notes": "OpenAI's CLI agent (TOML config)",
|
|
457
|
+
},
|
|
437
458
|
]
|
|
438
459
|
|
|
439
460
|
CLIENT_IDS = [c["id"] for c in MCP_CLIENTS]
|
|
@@ -523,6 +544,126 @@ def build_opencode_entry(python_path, server_path, api_path, lib_path, system=SY
|
|
|
523
544
|
}
|
|
524
545
|
|
|
525
546
|
|
|
547
|
+
def _toml_basic_string(value):
|
|
548
|
+
"""Quote a value as a TOML basic string, escaping what TOML requires.
|
|
549
|
+
|
|
550
|
+
Windows paths carry backslashes, which are escape characters inside a TOML
|
|
551
|
+
basic string -- an unescaped ``C:\\Users\\...`` would either change meaning or
|
|
552
|
+
make the whole config unparseable, taking every other MCP server down with it.
|
|
553
|
+
"""
|
|
554
|
+
out = []
|
|
555
|
+
for ch in str(value):
|
|
556
|
+
if ch == "\\":
|
|
557
|
+
out.append("\\\\")
|
|
558
|
+
elif ch == '"':
|
|
559
|
+
out.append('\\"')
|
|
560
|
+
elif ch == "\n":
|
|
561
|
+
out.append("\\n")
|
|
562
|
+
elif ch == "\r":
|
|
563
|
+
out.append("\\r")
|
|
564
|
+
elif ch == "\t":
|
|
565
|
+
out.append("\\t")
|
|
566
|
+
elif ord(ch) < 0x20 or ord(ch) == 0x7F:
|
|
567
|
+
out.append("\\u%04X" % ord(ch))
|
|
568
|
+
else:
|
|
569
|
+
out.append(ch)
|
|
570
|
+
return '"' + "".join(out) + '"'
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
CODEX_TABLE_HEADER = "[mcp_servers.davinci-resolve]"
|
|
574
|
+
|
|
575
|
+
# A line that opens a new TOML table/array-of-tables: `[foo]`, `[foo.bar]`,
|
|
576
|
+
# `[[foo]]`, optionally quoted, optionally trailed by a comment. Deliberately
|
|
577
|
+
# stricter than "starts with [" so a line inside a multi-line array value is not
|
|
578
|
+
# mistaken for the end of our table.
|
|
579
|
+
_TOML_TABLE_LINE = re.compile(r"""^\s*\[\[?\s*[A-Za-z0-9_."'\- ]+\s*\]\]?\s*(#.*)?$""")
|
|
580
|
+
|
|
581
|
+
# `[mcp_servers.davinci-resolve]` in any of TOML's equivalent spellings.
|
|
582
|
+
_CODEX_TABLE_LINE = re.compile(
|
|
583
|
+
r"""^\s*\[\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*"""
|
|
584
|
+
r"""(?:davinci-resolve|"davinci-resolve"|'davinci-resolve')\s*\]\s*(#.*)?$"""
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Inline spellings we can read but must not try to rewrite line-by-line:
|
|
588
|
+
# mcp_servers.davinci-resolve = { ... } (dotted key at top level)
|
|
589
|
+
# davinci-resolve = { ... } (key inside [mcp_servers])
|
|
590
|
+
# mcp_servers = { "davinci-resolve" = ... } (inline table at top level)
|
|
591
|
+
_CODEX_DOTTED_KEY = re.compile(
|
|
592
|
+
r"""^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*"""
|
|
593
|
+
r"""(?:davinci-resolve|"davinci-resolve"|'davinci-resolve')\s*="""
|
|
594
|
+
)
|
|
595
|
+
_CODEX_BARE_KEY = re.compile(r"""^\s*(?:davinci-resolve|"davinci-resolve"|'davinci-resolve')\s*=""")
|
|
596
|
+
_MCP_SERVERS_INLINE = re.compile(r"""^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*=""")
|
|
597
|
+
_MCP_SERVERS_TABLE = re.compile(
|
|
598
|
+
r"""^\s*\[\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\]\s*(#.*)?$"""
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# A sub-table of ours: `[mcp_servers.davinci-resolve.env]`,
|
|
602
|
+
# `[mcp_servers.davinci-resolve.tools.timeline]`, and so on. Hand-written Codex
|
|
603
|
+
# configs use these for per-tool approval modes, so they must survive a rewrite.
|
|
604
|
+
_CODEX_CHILD_TABLE = re.compile(
|
|
605
|
+
r"""^\s*\[\[?\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*"""
|
|
606
|
+
r"""(?:davinci-resolve|"davinci-resolve"|'davinci-resolve')\s*\.\s*"""
|
|
607
|
+
r"""[A-Za-z0-9_."'\- ]+\s*\]\]?\s*(#.*)?$"""
|
|
608
|
+
)
|
|
609
|
+
_CODEX_ENV_TABLE = re.compile(
|
|
610
|
+
r"""^\s*\[\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*"""
|
|
611
|
+
r"""(?:davinci-resolve|"davinci-resolve"|'davinci-resolve')\s*\.\s*"""
|
|
612
|
+
r"""(?:env|"env"|'env')\s*\]\s*(#.*)?$"""
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def build_codex_entry(python_path, server_path, api_path, lib_path, system=SYSTEM, python_home=None):
|
|
617
|
+
"""Build the Codex server entry as data, before it is rendered to TOML."""
|
|
618
|
+
return {
|
|
619
|
+
"command": str(python_path),
|
|
620
|
+
"args": [str(server_path)],
|
|
621
|
+
"env": build_server_env(
|
|
622
|
+
python_path, api_path, lib_path, system=system, python_home=python_home
|
|
623
|
+
),
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def render_codex_table(entry, env_as_subtable=False):
|
|
628
|
+
"""Render a Codex entry as a ``[mcp_servers.davinci-resolve]`` table.
|
|
629
|
+
|
|
630
|
+
``env`` goes in an inline table by default -- the shape ``codex mcp add``
|
|
631
|
+
writes. When the config being edited already spells env out as an
|
|
632
|
+
``[mcp_servers.davinci-resolve.env]`` sub-table, pass ``env_as_subtable`` so
|
|
633
|
+
the rewrite keeps that shape: TOML forbids defining ``env`` both ways, and a
|
|
634
|
+
file with both is rejected in full.
|
|
635
|
+
"""
|
|
636
|
+
args = ", ".join(_toml_basic_string(arg) for arg in entry["args"])
|
|
637
|
+
lines = [
|
|
638
|
+
CODEX_TABLE_HEADER,
|
|
639
|
+
f"command = {_toml_basic_string(entry['command'])}",
|
|
640
|
+
f"args = [{args}]",
|
|
641
|
+
]
|
|
642
|
+
env = entry.get("env") or {}
|
|
643
|
+
if env and not env_as_subtable:
|
|
644
|
+
inner = ", ".join(f"{key} = {_toml_basic_string(value)}" for key, value in env.items())
|
|
645
|
+
lines.append("env = { " + inner + " }")
|
|
646
|
+
return "\n".join(lines) + "\n"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def render_codex_env_table(env):
|
|
650
|
+
"""Render the ``[mcp_servers.davinci-resolve.env]`` sub-table form."""
|
|
651
|
+
lines = [CODEX_TABLE_HEADER[:-1] + ".env]"]
|
|
652
|
+
lines += [f"{key} = {_toml_basic_string(value)}" for key, value in env.items()]
|
|
653
|
+
return "\n".join(lines) + "\n"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def build_codex_block(python_path, server_path, api_path, lib_path, system=SYSTEM, python_home=None):
|
|
657
|
+
"""Render the Codex CLI ``[mcp_servers.davinci-resolve]`` table (issue #39).
|
|
658
|
+
|
|
659
|
+
Codex's config is TOML, so this returns text rather than a dict.
|
|
660
|
+
"""
|
|
661
|
+
entry = build_codex_entry(
|
|
662
|
+
python_path, server_path, api_path, lib_path, system=system, python_home=python_home
|
|
663
|
+
)
|
|
664
|
+
return render_codex_table(entry)
|
|
665
|
+
|
|
666
|
+
|
|
526
667
|
def build_entry_for_client(client, python_path, server_path, api_path, lib_path, system=SYSTEM, python_home=None):
|
|
527
668
|
"""Return the server entry shaped for a specific client's config schema."""
|
|
528
669
|
builders = {
|
|
@@ -542,6 +683,233 @@ class ConfigParseError(Exception):
|
|
|
542
683
|
"""
|
|
543
684
|
|
|
544
685
|
|
|
686
|
+
_CODEX_MANAGED_KEY = re.compile(
|
|
687
|
+
r"""^\s*(?:command|"command"|'command'|args|"args"|'args'|env|"env"|'env')\s*="""
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _toml_open_delimiters(line):
|
|
692
|
+
"""Net count of unclosed ``[``/``{`` on a line, ignoring quoted text."""
|
|
693
|
+
depth = 0
|
|
694
|
+
quote = None
|
|
695
|
+
escape = False
|
|
696
|
+
for ch in line:
|
|
697
|
+
if quote:
|
|
698
|
+
if escape:
|
|
699
|
+
escape = False
|
|
700
|
+
elif ch == "\\" and quote == '"':
|
|
701
|
+
escape = True
|
|
702
|
+
elif ch == quote:
|
|
703
|
+
quote = None
|
|
704
|
+
continue
|
|
705
|
+
if ch in "\"'":
|
|
706
|
+
quote = ch
|
|
707
|
+
elif ch in "[{":
|
|
708
|
+
depth += 1
|
|
709
|
+
elif ch in "]}":
|
|
710
|
+
depth -= 1
|
|
711
|
+
elif ch == "#":
|
|
712
|
+
break
|
|
713
|
+
return depth
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _drop_managed_codex_keys(direct_lines):
|
|
717
|
+
"""Return the table's own lines minus the command/args/env we regenerate.
|
|
718
|
+
|
|
719
|
+
Anything else in the table is the user's -- Codex's per-server knobs
|
|
720
|
+
(``startup_timeout_sec``, ``tool_timeout_sec``), comments, blank lines -- and
|
|
721
|
+
an installer has no business dropping it during an update.
|
|
722
|
+
"""
|
|
723
|
+
kept = []
|
|
724
|
+
i = 0
|
|
725
|
+
while i < len(direct_lines):
|
|
726
|
+
line = direct_lines[i]
|
|
727
|
+
if not _CODEX_MANAGED_KEY.match(line):
|
|
728
|
+
kept.append(line)
|
|
729
|
+
i += 1
|
|
730
|
+
continue
|
|
731
|
+
# Skip the assignment, including a value spread over several lines.
|
|
732
|
+
depth = _toml_open_delimiters(line)
|
|
733
|
+
i += 1
|
|
734
|
+
while i < len(direct_lines) and depth > 0:
|
|
735
|
+
depth += _toml_open_delimiters(direct_lines[i])
|
|
736
|
+
i += 1
|
|
737
|
+
return kept
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def merge_codex_toml(existing_text, entry):
|
|
741
|
+
"""Splice a Codex server ``entry`` into an existing config, preserving the rest.
|
|
742
|
+
|
|
743
|
+
This is a text-level merge on purpose: Python has no TOML writer in the
|
|
744
|
+
standard library, and a parse-and-rewrite would silently strip the user's
|
|
745
|
+
comments and formatting. An existing ``[mcp_servers.davinci-resolve]`` table
|
|
746
|
+
has its ``command``/``args``/``env`` replaced in place; otherwise the table is
|
|
747
|
+
appended.
|
|
748
|
+
|
|
749
|
+
Sub-tables of that entry survive untouched -- hand-written Codex configs put
|
|
750
|
+
per-tool approval modes in ``[mcp_servers.davinci-resolve.tools.<tool>]``, and
|
|
751
|
+
an installer that dropped them would quietly widen what the agent may do
|
|
752
|
+
without asking. An ``[mcp_servers.davinci-resolve.env]`` sub-table is
|
|
753
|
+
regenerated in place rather than replaced with an inline ``env``, since TOML
|
|
754
|
+
rejects a file that spells the same key both ways.
|
|
755
|
+
|
|
756
|
+
Raises :class:`ConfigParseError` when the server is defined in an inline form
|
|
757
|
+
this splice cannot safely rewrite -- appending anyway would produce a
|
|
758
|
+
duplicate key and make Codex reject the entire file.
|
|
759
|
+
"""
|
|
760
|
+
lines = existing_text.splitlines()
|
|
761
|
+
|
|
762
|
+
in_mcp_servers_table = False
|
|
763
|
+
for line in lines:
|
|
764
|
+
if _CODEX_DOTTED_KEY.match(line):
|
|
765
|
+
raise ConfigParseError(
|
|
766
|
+
"davinci-resolve is already defined as a dotted key (mcp_servers.davinci-resolve)"
|
|
767
|
+
)
|
|
768
|
+
if _MCP_SERVERS_INLINE.match(line):
|
|
769
|
+
# TOML forbids extending an inline table, so no table header we
|
|
770
|
+
# append could attach to this mcp_servers definition.
|
|
771
|
+
raise ConfigParseError(
|
|
772
|
+
"mcp_servers is defined as an inline table, which cannot be extended"
|
|
773
|
+
)
|
|
774
|
+
if _MCP_SERVERS_TABLE.match(line):
|
|
775
|
+
in_mcp_servers_table = True
|
|
776
|
+
continue
|
|
777
|
+
if _TOML_TABLE_LINE.match(line):
|
|
778
|
+
in_mcp_servers_table = False
|
|
779
|
+
continue
|
|
780
|
+
if in_mcp_servers_table and _CODEX_BARE_KEY.match(line):
|
|
781
|
+
raise ConfigParseError(
|
|
782
|
+
"davinci-resolve is already defined as an inline key under [mcp_servers]"
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
start = next((i for i, line in enumerate(lines) if _CODEX_TABLE_LINE.match(line)), None)
|
|
786
|
+
|
|
787
|
+
if start is None:
|
|
788
|
+
prefix = existing_text
|
|
789
|
+
if prefix and not prefix.endswith("\n"):
|
|
790
|
+
prefix += "\n"
|
|
791
|
+
if prefix.strip():
|
|
792
|
+
prefix += "\n"
|
|
793
|
+
return prefix + render_codex_table(entry)
|
|
794
|
+
|
|
795
|
+
# Our region runs to the next table that is NOT one of our sub-tables.
|
|
796
|
+
end = len(lines)
|
|
797
|
+
for i in range(start + 1, len(lines)):
|
|
798
|
+
if _TOML_TABLE_LINE.match(lines[i]) and not _CODEX_CHILD_TABLE.match(lines[i]):
|
|
799
|
+
end = i
|
|
800
|
+
break
|
|
801
|
+
|
|
802
|
+
region = lines[start + 1:end]
|
|
803
|
+
first_child = next(
|
|
804
|
+
(i for i, line in enumerate(region) if _CODEX_CHILD_TABLE.match(line)), None
|
|
805
|
+
)
|
|
806
|
+
direct = region if first_child is None else region[:first_child]
|
|
807
|
+
children = [] if first_child is None else region[first_child:]
|
|
808
|
+
|
|
809
|
+
env_child = next((i for i, line in enumerate(children) if _CODEX_ENV_TABLE.match(line)), None)
|
|
810
|
+
|
|
811
|
+
rebuilt = render_codex_table(entry, env_as_subtable=env_child is not None).rstrip("\n").split("\n")
|
|
812
|
+
# Everything in the table that is not command/args/env stays: Codex's own
|
|
813
|
+
# per-server knobs (startup_timeout_sec, tool_timeout_sec, ...) plus the
|
|
814
|
+
# user's comments and blank lines.
|
|
815
|
+
rebuilt += _drop_managed_codex_keys(direct)
|
|
816
|
+
|
|
817
|
+
if env_child is not None:
|
|
818
|
+
env_end = len(children)
|
|
819
|
+
for i in range(env_child + 1, len(children)):
|
|
820
|
+
if _CODEX_CHILD_TABLE.match(children[i]):
|
|
821
|
+
env_end = i
|
|
822
|
+
break
|
|
823
|
+
env_tail = []
|
|
824
|
+
for line in reversed(children[env_child + 1:env_end]):
|
|
825
|
+
if line.strip():
|
|
826
|
+
break
|
|
827
|
+
env_tail.append("")
|
|
828
|
+
children = (
|
|
829
|
+
children[:env_child]
|
|
830
|
+
+ render_codex_env_table(entry.get("env") or {}).rstrip("\n").split("\n")
|
|
831
|
+
+ env_tail
|
|
832
|
+
+ children[env_end:]
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
merged = lines[:start] + rebuilt + children + lines[end:]
|
|
836
|
+
return "\n".join(merged) + "\n"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def write_codex_config(config_path, entry, dry_run=False):
|
|
840
|
+
"""Write/merge the Codex TOML config. Returns (success, message)."""
|
|
841
|
+
config_path = Path(config_path)
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
existing_text = config_path.read_text(encoding="utf-8")
|
|
845
|
+
except FileNotFoundError:
|
|
846
|
+
existing_text = ""
|
|
847
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
848
|
+
return False, (
|
|
849
|
+
f"{config_path} could not be read ({exc}). Refusing to overwrite it — "
|
|
850
|
+
f"add the entry manually (run with --manual)."
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# If this interpreter can parse TOML, refuse to touch a file that is already
|
|
854
|
+
# broken: same policy as the JSON clients (issue #71) — never rewrite a config
|
|
855
|
+
# we cannot understand.
|
|
856
|
+
parse_toml = getattr(_toml_loader(), "loads", None)
|
|
857
|
+
if parse_toml and existing_text.strip():
|
|
858
|
+
try:
|
|
859
|
+
parse_toml(existing_text)
|
|
860
|
+
except Exception as exc:
|
|
861
|
+
return False, (
|
|
862
|
+
f"{config_path} exists but is not valid TOML ({exc}). Refusing to "
|
|
863
|
+
f"overwrite to avoid data loss. Add the "
|
|
864
|
+
f'"{CODEX_TABLE_HEADER}" entry manually (run with --manual).'
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
merged = merge_codex_toml(existing_text, entry)
|
|
869
|
+
except ConfigParseError as exc:
|
|
870
|
+
return False, (
|
|
871
|
+
f"{config_path}: {exc}. Refusing to edit it — update that entry "
|
|
872
|
+
f"manually (run with --manual)."
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
if parse_toml:
|
|
876
|
+
try:
|
|
877
|
+
parse_toml(merged)
|
|
878
|
+
except Exception as exc: # pragma: no cover - guard against a bad splice
|
|
879
|
+
return False, (
|
|
880
|
+
f"Merged Codex config would not parse ({exc}); left {config_path} "
|
|
881
|
+
f"untouched. Add the entry manually (run with --manual)."
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
if dry_run:
|
|
885
|
+
return True, f"Would write to {config_path}:\n{render_codex_table(entry)}"
|
|
886
|
+
|
|
887
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
888
|
+
if config_path.exists():
|
|
889
|
+
shutil.copy2(config_path, config_path.with_suffix(config_path.suffix + ".backup"))
|
|
890
|
+
config_path.write_text(merged, encoding="utf-8")
|
|
891
|
+
return True, str(config_path)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _toml_loader():
|
|
895
|
+
"""Return a TOML reader module, or None on interpreters without one.
|
|
896
|
+
|
|
897
|
+
``tomllib`` is stdlib from Python 3.11; the project floor is 3.10, so the
|
|
898
|
+
validation it enables is a bonus, not a requirement.
|
|
899
|
+
"""
|
|
900
|
+
try:
|
|
901
|
+
import tomllib
|
|
902
|
+
|
|
903
|
+
return tomllib
|
|
904
|
+
except ImportError:
|
|
905
|
+
try:
|
|
906
|
+
import tomli
|
|
907
|
+
|
|
908
|
+
return tomli
|
|
909
|
+
except ImportError:
|
|
910
|
+
return None
|
|
911
|
+
|
|
912
|
+
|
|
545
913
|
def _strip_jsonc(text):
|
|
546
914
|
"""Best-effort strip of // and /* */ comments and trailing commas.
|
|
547
915
|
|
|
@@ -676,6 +1044,12 @@ def write_client_config(client, python_path, server_path, api_path, lib_path, dr
|
|
|
676
1044
|
|
|
677
1045
|
config_key = client["config_key"]
|
|
678
1046
|
|
|
1047
|
+
# Codex keeps its config in TOML, so it takes a text splice rather than a
|
|
1048
|
+
# JSON merge (issue #39).
|
|
1049
|
+
if client.get("format") == "toml":
|
|
1050
|
+
entry = build_codex_entry(python_path, server_path, api_path, lib_path)
|
|
1051
|
+
return write_codex_config(config_path, entry, dry_run=dry_run)
|
|
1052
|
+
|
|
679
1053
|
# Build the server entry (some clients use a non-standard schema)
|
|
680
1054
|
server_entry = build_entry_for_client(client, python_path, server_path, api_path, lib_path)
|
|
681
1055
|
|
|
@@ -746,8 +1120,9 @@ def generate_manual_config(python_path, server_path, api_path, lib_path):
|
|
|
746
1120
|
}}, indent=2)
|
|
747
1121
|
zed_fmt = json.dumps({"context_servers": {"davinci-resolve": zed_entry}}, indent=2)
|
|
748
1122
|
opencode_fmt = json.dumps({"mcp": {"davinci-resolve": opencode_entry}}, indent=2)
|
|
1123
|
+
codex_fmt = build_codex_block(python_path, server_path, api_path, lib_path).rstrip("\n")
|
|
749
1124
|
|
|
750
|
-
return standard, vscode_fmt, zed_fmt, opencode_fmt
|
|
1125
|
+
return standard, vscode_fmt, zed_fmt, opencode_fmt, codex_fmt
|
|
751
1126
|
|
|
752
1127
|
# ─── Virtual Environment ─────────────────────────────────────────────────────
|
|
753
1128
|
|
|
@@ -1668,7 +2043,7 @@ def main():
|
|
|
1668
2043
|
|
|
1669
2044
|
# Show manual config
|
|
1670
2045
|
if show_manual:
|
|
1671
|
-
standard, vscode_fmt, zed_fmt, opencode_fmt = generate_manual_config(
|
|
2046
|
+
standard, vscode_fmt, zed_fmt, opencode_fmt, codex_fmt = generate_manual_config(
|
|
1672
2047
|
python_path, server_path, api_path, lib_path
|
|
1673
2048
|
)
|
|
1674
2049
|
env_preview = build_server_env(python_path, api_path, lib_path)
|
|
@@ -1690,6 +2065,10 @@ def main():
|
|
|
1690
2065
|
print()
|
|
1691
2066
|
for line in opencode_fmt.split("\n"):
|
|
1692
2067
|
print(f" {line}")
|
|
2068
|
+
print(f"\n {cyan('Codex CLI format')} (TOML — add to ~/.codex/config.toml):")
|
|
2069
|
+
print()
|
|
2070
|
+
for line in codex_fmt.split("\n"):
|
|
2071
|
+
print(f" {line}")
|
|
1693
2072
|
print(f"\n {cyan('JetBrains IDEs')} (IntelliJ, WebStorm, PyCharm, etc.):")
|
|
1694
2073
|
print(f" Settings → Tools → AI Assistant → Model Context Protocol (MCP)")
|
|
1695
2074
|
print(f" Add server with command: {python_path} {server_path}")
|
package/package.json
CHANGED
|
@@ -147,6 +147,109 @@ def _find_source_clip(segment, depth=0):
|
|
|
147
147
|
return None
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
def _hop_referenced_clip(mob, ref_clip):
|
|
151
|
+
"""The next SourceClip down the mob chain — through the slot the REFERENCE
|
|
152
|
+
names, resolved AT the reference's offset. Returns (clip, position_adjust)
|
|
153
|
+
or None; callers accumulating physical position must add `position_adjust`
|
|
154
|
+
after adding the reference's own `start`.
|
|
155
|
+
|
|
156
|
+
🚨 A SourceClip carries `slot_id`: which slot of the referenced mob it
|
|
157
|
+
means. A GROUP CLIP (Avid multicam) is an unnamed CompositionMob with one
|
|
158
|
+
slot per camera, and hopping through the first slot returns CAMERA 1's
|
|
159
|
+
chain regardless of which camera the editor selected. Measured on a real
|
|
160
|
+
turnover (CS031, 2026-08-13): ~24 cuts linked a same-day SIBLING camera or
|
|
161
|
+
take — A015C017 built where the editor cut A082C105 — because both the
|
|
162
|
+
name chase and the source-position chase took the first-slot shortcut.
|
|
163
|
+
The wrong name is only the visible half: srcTcFrame accumulated down the
|
|
164
|
+
wrong camera's chain, so the build placed frames of the wrong source and
|
|
165
|
+
called them proven.
|
|
166
|
+
|
|
167
|
+
🚨 And the slot is only half the address. A group slot's segment can be a
|
|
168
|
+
SEQUENCE — the camera's whole tape timeline, one component per recording
|
|
169
|
+
with filler between (measured on KBP R2, 2026-08-14: A-cam slot =
|
|
170
|
+
[A008C001, filler, A008C002, filler, A009C001]; the Selector's SourceClip
|
|
171
|
+
entered it at start=123456, inside A008C002, while the first-clip shortcut
|
|
172
|
+
reported A008C001 with a source TC ~6.8s early — the reference burn-in
|
|
173
|
+
proved the picture was A008C002 @ 16:34:56:10). The reference's `start` is
|
|
174
|
+
a position ON that sequence: resolve the covered component, and return the
|
|
175
|
+
component's own sequence offset as a NEGATIVE adjust so the accumulated
|
|
176
|
+
position becomes (ref.start − component_offset) + covered_clip.start.
|
|
177
|
+
|
|
178
|
+
Falls back to the first-slot scan only when slot_id is absent or names a
|
|
179
|
+
slot the mob does not have — the pre-fix behavior, kept for tape-style
|
|
180
|
+
single-slot mobs where it was always right.
|
|
181
|
+
"""
|
|
182
|
+
slots = getattr(mob, "slots", None) or []
|
|
183
|
+
sid = getattr(ref_clip, "slot_id", None)
|
|
184
|
+
|
|
185
|
+
def resolve_at_offset(segment):
|
|
186
|
+
"""(clip, adjust) for this slot's content at the reference's offset."""
|
|
187
|
+
if segment is None:
|
|
188
|
+
return None
|
|
189
|
+
if type(segment).__name__ == "Sequence":
|
|
190
|
+
try:
|
|
191
|
+
offset = int(getattr(ref_clip, "start", 0) or 0)
|
|
192
|
+
except Exception:
|
|
193
|
+
offset = 0
|
|
194
|
+
# 🚨 Two offset conventions live in one structure, and only the
|
|
195
|
+
# sequence's SHAPE tells them apart — measured against FOUR
|
|
196
|
+
# independent reference burn-ins (2026-08-14):
|
|
197
|
+
# KICK wrapper [Filler 1, SC 118 @1 ] start=42 → truth 42−0
|
|
198
|
+
# KICK wrapper [Filler 21, SC 150 @21] start=43 → truth 43−20
|
|
199
|
+
# KBP group A [SC, Filler, SC, …] start big → truth raw
|
|
200
|
+
# KBP group B [Filler, SC, Filler, …] start big → truth raw
|
|
201
|
+
# A consolidate SUBCLIP WRAPPER (exactly one non-filler component,
|
|
202
|
+
# head filler in front) measures offsets from ONE FRAME INTO the
|
|
203
|
+
# head filler: shift = rawOffset − 1. A GROUP/tape sequence
|
|
204
|
+
# (multiple non-filler components) uses raw coordinates, head
|
|
205
|
+
# filler or not. This is also why the first-clip shortcut mostly
|
|
206
|
+
# worked: wrapper head fillers are usually 1 frame, making its
|
|
207
|
+
# error zero — until a 21-frame filler made it +20. The burn-in is
|
|
208
|
+
# the arbiter here, not the AAF spec — Avid writes what Avid
|
|
209
|
+
# writes. Interior fillers DO count: real dark space on a group's
|
|
210
|
+
# tape timeline.
|
|
211
|
+
comps = getattr(segment, "components", None) or []
|
|
212
|
+
non_filler = sum(1 for c in comps if type(c).__name__ != "Filler")
|
|
213
|
+
pad = (
|
|
214
|
+
1
|
|
215
|
+
if comps and type(comps[0]).__name__ == "Filler" and non_filler == 1
|
|
216
|
+
else 0
|
|
217
|
+
)
|
|
218
|
+
pos = 0
|
|
219
|
+
for comp in comps:
|
|
220
|
+
ln = _length(comp) or 0
|
|
221
|
+
eff = pos - pad
|
|
222
|
+
if eff <= offset < eff + ln:
|
|
223
|
+
found = _find_source_clip(comp)
|
|
224
|
+
# A filler here means this angle is dark at the offset —
|
|
225
|
+
# no source to name; let the caller fall back or stop.
|
|
226
|
+
return (found, -eff) if found is not None else None
|
|
227
|
+
pos += ln
|
|
228
|
+
# Offset past the sequence end — a malformed reference; the old
|
|
229
|
+
# first-clip answer is the least-wrong fallback, with no adjust.
|
|
230
|
+
found = _find_source_clip(segment)
|
|
231
|
+
return (found, 0) if found is not None else None
|
|
232
|
+
|
|
233
|
+
if sid is not None:
|
|
234
|
+
for slot in slots:
|
|
235
|
+
try:
|
|
236
|
+
if getattr(slot, "slot_id", None) == sid:
|
|
237
|
+
resolved = resolve_at_offset(getattr(slot, "segment", None))
|
|
238
|
+
if resolved is not None:
|
|
239
|
+
return resolved
|
|
240
|
+
break # the named slot has no SourceClip at the offset — fall back
|
|
241
|
+
except Exception:
|
|
242
|
+
continue
|
|
243
|
+
for slot in slots:
|
|
244
|
+
try:
|
|
245
|
+
resolved = resolve_at_offset(getattr(slot, "segment", None))
|
|
246
|
+
except Exception:
|
|
247
|
+
continue
|
|
248
|
+
if resolved is not None:
|
|
249
|
+
return resolved
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
150
253
|
def _source_name(clip):
|
|
151
254
|
"""Best-effort human name for a SourceClip: the nearest NAMED mob it references.
|
|
152
255
|
|
|
@@ -176,14 +279,13 @@ def _source_name(clip):
|
|
|
176
279
|
name = _usable_name(mob)
|
|
177
280
|
if name:
|
|
178
281
|
return name
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if nxt is None:
|
|
282
|
+
# Honor the reference's slot_id — group clips have one slot per camera
|
|
283
|
+
# and the first-slot shortcut returns the wrong member (see
|
|
284
|
+
# _hop_referenced_clip).
|
|
285
|
+
hop = _hop_referenced_clip(mob, current)
|
|
286
|
+
if hop is None:
|
|
185
287
|
break
|
|
186
|
-
current =
|
|
288
|
+
current = hop[0]
|
|
187
289
|
# No named mob anywhere in the chain — fall back to the clip's own name.
|
|
188
290
|
return _usable_name(clip) or "UNKNOWN"
|
|
189
291
|
|
|
@@ -276,14 +378,15 @@ def _chase_source_position(clip):
|
|
|
276
378
|
tc = _mob_timecode(mob)
|
|
277
379
|
if tc is not None:
|
|
278
380
|
timecode = (tc[0], tc[1], tc[2], position)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if
|
|
381
|
+
# Same slot_id discipline as _source_name: the position/timecode chain
|
|
382
|
+
# must descend the SELECTED camera of a group clip, or srcTcFrame is
|
|
383
|
+
# the right frame of the WRONG source. The hop's adjust re-bases a
|
|
384
|
+
# sequence-slot reference onto the covered component (KBP R2 class).
|
|
385
|
+
hop = _hop_referenced_clip(mob, current)
|
|
386
|
+
if hop is None:
|
|
285
387
|
break
|
|
286
|
-
|
|
388
|
+
position += hop[1]
|
|
389
|
+
current = hop[0]
|
|
287
390
|
return position, timecode
|
|
288
391
|
|
|
289
392
|
|
|
@@ -29,9 +29,17 @@ const timelineClipsSchema = z.object({ ...dbTarget, timeline: z.string().describ
|
|
|
29
29
|
// Sm2TiTrack.Sequence (NOT Sm2Sequence_id, which is empty); the sequence carries
|
|
30
30
|
// Sm2Timeline_id. Type: 0=video, 1=audio. The grade Body (DRX-format 0x81+zstd) is
|
|
31
31
|
// reached via Sm2TiItem.pLmVerTable → LmVersion(HasCorrection='1').Body.
|
|
32
|
+
// 🚨 mediaStart (Sm2TiItem.MediaStartTime) is the MEDIA FILE's start time in
|
|
33
|
+
// seconds — NOT the item's in-point. Reading it as the in-point produced two
|
|
34
|
+
// phantom conform bugs in a row (CS031 retractions). The in-point is the "In"
|
|
35
|
+
// column: file-relative source frames, calibrated frame-EXACT against a real
|
|
36
|
+
// turnover's AAF ground truth (A005C004 run: 36069/8863/8919/12804, through-
|
|
37
|
+
// edit continuity intact). Emitted as sourceIn.
|
|
32
38
|
const TIMELINE_CLIPS_SQL = `
|
|
33
39
|
SELECT i.Name AS name, t.Type AS trackType, i.Start AS start, i.Duration AS duration,
|
|
34
|
-
i.MediaReelNumber AS reel, i.MediaStartTime AS mediaStart, i.
|
|
40
|
+
i.MediaReelNumber AS reel, i.MediaStartTime AS mediaStart, i."In" AS sourceIn,
|
|
41
|
+
i.MediaFilePath AS mediaPath,
|
|
42
|
+
i.Sm2TiTrack_id AS trackId,
|
|
35
43
|
lower(hex(v.Body)) AS gradeBody
|
|
36
44
|
FROM Sm2TiItem i
|
|
37
45
|
JOIN Sm2TiTrack t ON i.Sm2TiTrack_id = t.Sm2TiTrack_id
|
|
@@ -73,6 +81,28 @@ function count(db, t) {
|
|
|
73
81
|
|
|
74
82
|
/** Read a timeline's clips from a Project.db (read-only). Shared by the
|
|
75
83
|
* timeline_clips action + the color_trace tool. trackType: 'video'|'audio'|'all'. */
|
|
84
|
+
/**
|
|
85
|
+
* Sm2TiItem."In" — the item's file-relative source in-point — arrives in TWO
|
|
86
|
+
* encodings in one 'character varying' column (both measured on Resolve
|
|
87
|
+
* 19.1.3 disk DBs, same show):
|
|
88
|
+
* - a plain decimal string: '48', '36069' (older/edited rows)
|
|
89
|
+
* - a 16-hex-char little-endian DOUBLE of frames/1000: '000000bb7493a83f'
|
|
90
|
+
* = 0.048 → 48 (freshly-appended rows)
|
|
91
|
+
* Calibrated against AAF ground truth on both encodings. Anything else is
|
|
92
|
+
* null — an unreadable in-point must read as absent, not as 0.
|
|
93
|
+
*/
|
|
94
|
+
function decodeItemIn(v) {
|
|
95
|
+
if (v == null || v === '') return null;
|
|
96
|
+
// Two shapes measured on Resolve 19.1.3 disk DBs (same show, different
|
|
97
|
+
// projects): a plain decimal ('48', '36069'), and a pipe-joined composite
|
|
98
|
+
// ('48|000000bb7493a83f') whose FIRST field is the frame count — the hex
|
|
99
|
+
// tail is a packed double with its own (unpinned) meaning and is ignored.
|
|
100
|
+
// The pipe in the value is also why a naive `sqlite3` CLI dump appears to
|
|
101
|
+
// grow an extra column. Anything else reads as ABSENT, never as 0.
|
|
102
|
+
const head = String(v).split('|', 1)[0];
|
|
103
|
+
return /^-?\d+(\.\d+)?$/.test(head) ? Number(head) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGrade = false) {
|
|
77
107
|
const db = openGuarded(dbPath, { writable: false });
|
|
78
108
|
try {
|
|
@@ -81,7 +111,10 @@ export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGr
|
|
|
81
111
|
else if (trackType === 'audio') rows = rows.filter((r) => r.trackType !== 0);
|
|
82
112
|
rows.sort((a, b) => a.trackType - b.trackType || Number(a.start) - Number(b.start));
|
|
83
113
|
// grade Body is large + only needed for ColorTrace — strip it by default to avoid bloat.
|
|
84
|
-
|
|
114
|
+
for (const r of rows) {
|
|
115
|
+
if (!includeGrade) delete r.gradeBody;
|
|
116
|
+
r.sourceIn = decodeItemIn(r.sourceIn);
|
|
117
|
+
}
|
|
85
118
|
return rows;
|
|
86
119
|
} finally {
|
|
87
120
|
db.close();
|