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/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.95.3"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.95.3",
3
+ "version": "2.97.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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
- nxt = None
180
- for slot in getattr(mob, "slots", None) or []:
181
- nxt = _find_source_clip(getattr(slot, "segment", None))
182
- if nxt is not None:
183
- break
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 = nxt
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
- nxt = None
280
- for slot in getattr(mob, "slots", None) or []:
281
- nxt = _find_source_clip(getattr(slot, "segment", None))
282
- if nxt is not None:
283
- break
284
- if nxt is None:
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
- current = nxt
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.Sm2TiTrack_id AS trackId,
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
- if (!includeGrade) for (const r of rows) delete r.gradeBody;
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();