davinci-resolve-mcp 2.69.3 → 2.70.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.
@@ -0,0 +1,11 @@
1
+ # Pixel equality: GUI vs headless rendered frames
2
+
3
+ Frames are compared after decoding (ffmpeg `framemd5`), so container metadata cannot influence the result. `effective` is the within-mode control: whether the effect changed the picture at all relative to `plain`. An effect that is not effective makes its mode comparison meaningless, and is called out rather than counted as a pass.
4
+
5
+ | effect | effective (GUI) | effective (headless) | pixels identical across modes |
6
+ | --- | --- | --- | --- |
7
+ | plain | — | — | **identical** |
8
+ | transform | True | True | **identical** |
9
+ | cdl | True | True | **identical** |
10
+ | lut | True | True | **identical** |
11
+ | fusion_comp | True | True | **identical** |
@@ -0,0 +1,17 @@
1
+ # Timeline round-trip fidelity: export, re-import, compare
2
+
3
+ `exact` = every item's record start, duration and **source in-point** survived, with all media linked. Anything else is unusable for an iterative edit loop.
4
+
5
+ | format | link strategy | GUI | headless |
6
+ | --- | --- | --- | --- |
7
+ | drt | reuse_pool | exact | exact |
8
+ | fcpxml_1_10 | reuse_pool | exact | exact |
9
+ | fcpxml_1_10 | import_source | exact | exact |
10
+ | fcp7xml | reuse_pool | exact | exact |
11
+ | fcp7xml | import_source | exact | exact |
12
+ | aaf | reuse_pool | exact | exact |
13
+ | aaf | import_source | exact | exact |
14
+ | otio | reuse_pool | exact-but-4-offline | exact-but-4-offline |
15
+ | otio | import_source | exact | exact |
16
+ | edl | reuse_pool | exact | exact |
17
+ | edl | import_source | exact | exact |
@@ -0,0 +1,18 @@
1
+ # Complex-cut round trip: what each format keeps
2
+
3
+ Two video tracks, audio, a per-item transform, clip colours, flags, item and timeline markers. `kept` = identical to the reference; `n/a` = the API would not set that dimension, so no format can be blamed for losing it.
4
+
5
+ | format | mode | cut | tracks | transform | colour | flags | markers | timeline_markers | linkage |
6
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
7
+ | drt | gui | kept | kept | kept | kept | kept | kept | kept | kept |
8
+ | drt | headless | kept | kept | kept | kept | kept | kept | kept | kept |
9
+ | fcpxml_1_10 | gui | kept | lost:2v1a!=2v2a | kept | lost | kept | lost | lost | kept |
10
+ | fcpxml_1_10 | headless | kept | lost:2v1a!=2v2a | kept | lost | kept | lost | lost | kept |
11
+ | fcp7xml | gui | kept | kept | lost:FlipX | lost | kept | lost | lost | kept |
12
+ | fcp7xml | headless | kept | kept | lost:FlipX | lost | kept | lost | lost | kept |
13
+ | aaf | gui | kept | kept | lost:all | lost | kept | lost | kept | kept |
14
+ | aaf | headless | kept | kept | lost:all | lost | kept | lost | kept | kept |
15
+ | otio | gui | lost:source | kept | kept | lost | lost | kept | kept | offline:4 |
16
+ | otio | headless | lost:source | kept | kept | lost | lost | kept | kept | offline:4 |
17
+ | edl | gui | lost | lost:1v1a!=2v2a | lost:item-count | lost | lost | lost | lost | kept |
18
+ | edl | headless | lost | lost:1v1a!=2v2a | lost:item-count | lost | lost | lost | lost | kept |
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.69.3"
39
+ VERSION = "2.70.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.69.3",
3
+ "version": "2.70.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -0,0 +1,125 @@
1
+ """Walk the stress harness's setup sequence one call at a time, on a cold headless Resolve.
2
+
3
+ Two things are recorded around EVERY call: how long it took, and whether the
4
+ current database is still attached afterwards. The hang and the null database
5
+ have so far only been seen together, from two different clients, so neither is
6
+ known to cause the other. Checking the database between every step is what turns
7
+ that correlation into an ordering — if the database goes null one step *before*
8
+ the hang, it is a cause; if it only goes null once something is already blocked,
9
+ it is a symptom.
10
+
11
+ Each step has its own alarm, so a hang is reported with the culprit named instead
12
+ of parking the process forever.
13
+ """
14
+ import json
15
+ import signal
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ sys.path.append(
21
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules"
22
+ )
23
+ import DaVinciResolveScript as dvr # noqa: E402
24
+
25
+
26
+ class Hang(Exception):
27
+ pass
28
+
29
+
30
+ def _alarm(*_):
31
+ raise Hang()
32
+
33
+
34
+ signal.signal(signal.SIGALRM, _alarm)
35
+ RESULTS = []
36
+
37
+
38
+ def step(label, fn, secs=45, pm=None):
39
+ """Run one call, timed, with the database checked immediately afterwards."""
40
+ signal.alarm(secs)
41
+ started = time.monotonic()
42
+ outcome, value = "ok", None
43
+ try:
44
+ value = fn()
45
+ except Hang:
46
+ outcome = "HUNG"
47
+ except Exception as exc:
48
+ outcome, value = "error", f"{type(exc).__name__}: {exc}"
49
+ finally:
50
+ signal.alarm(0)
51
+ elapsed = round(time.monotonic() - started, 1)
52
+
53
+ db = "unchecked"
54
+ if pm is not None and outcome != "HUNG":
55
+ signal.alarm(20)
56
+ try:
57
+ db = pm.GetCurrentDatabase()
58
+ except Hang:
59
+ db = "HUNG-checking-db"
60
+ except Exception as exc:
61
+ db = f"error: {type(exc).__name__}"
62
+ finally:
63
+ signal.alarm(0)
64
+
65
+ record = {"step": label, "outcome": outcome, "seconds": elapsed,
66
+ "value": repr(value)[:120], "database_after": db}
67
+ RESULTS.append(record)
68
+ print(json.dumps(record), flush=True)
69
+ if outcome == "HUNG":
70
+ print(f"\n>>> CULPRIT: {label} hung after {secs}s", flush=True)
71
+ dump()
72
+ raise SystemExit(9)
73
+ return value
74
+
75
+
76
+ def dump():
77
+ Path(sys.argv[1]).write_text(json.dumps(RESULTS, indent=2, default=str))
78
+
79
+
80
+ def main():
81
+ resolve = None
82
+ for _ in range(420):
83
+ resolve = dvr.scriptapp("Resolve")
84
+ if resolve is not None:
85
+ break
86
+ time.sleep(1)
87
+ if resolve is None:
88
+ raise SystemExit("no Resolve")
89
+
90
+ pm = step("GetProjectManager", resolve.GetProjectManager, pm=None)
91
+ step("GetCurrentDatabase (baseline)", pm.GetCurrentDatabase, pm=pm)
92
+ step("GetProjectListInCurrentFolder", lambda: len(pm.GetProjectListInCurrentFolder() or []), pm=pm)
93
+ incumbent = step("GetCurrentProject().GetName()",
94
+ lambda: (pm.GetCurrentProject().GetName() if pm.GetCurrentProject() else None), pm=pm)
95
+ # The first suspect: SaveProject on the never-saved default project. It is
96
+ # the first mutating call the harness makes, and it is documented to return
97
+ # False for exactly this project.
98
+ step("SaveProject (incumbent)", pm.SaveProject, pm=pm)
99
+
100
+ name = "COLD_HEADLESS_PROBE"
101
+ step("CreateProject", lambda: bool(pm.CreateProject(name)), pm=pm)
102
+ step("LoadProject", lambda: bool(pm.LoadProject(name)), pm=pm)
103
+ project = step("GetCurrentProject", pm.GetCurrentProject, pm=pm)
104
+ if project is None:
105
+ dump()
106
+ raise SystemExit("no project after LoadProject")
107
+
108
+ step("GetMediaStorage", resolve.GetMediaStorage, pm=pm)
109
+ step("project.GetMediaPool", project.GetMediaPool, pm=pm)
110
+ step("GetRenderFormats", lambda: len(project.GetRenderFormats() or {}), pm=pm)
111
+ step("CreateEmptyTimeline", lambda: bool(project.GetMediaPool().CreateEmptyTimeline("COLD_TL")), pm=pm)
112
+
113
+ # Teardown, itself instrumented — the harness's teardown is as likely a
114
+ # suspect as its setup, and it runs on every iteration boundary.
115
+ step("SaveProject (scratch)", pm.SaveProject, pm=pm)
116
+ step("CloseProject", lambda: bool(pm.CloseProject(pm.GetCurrentProject())), pm=pm)
117
+ if incumbent:
118
+ step("LoadProject (incumbent)", lambda: bool(pm.LoadProject(incumbent)), pm=pm)
119
+ step("DeleteProject", lambda: bool(pm.DeleteProject(name)), pm=pm)
120
+ dump()
121
+ print("\n>>> COMPLETED WITH NO HANG", flush=True)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """Labelled contact sheets from a media_analysis frames directory.
3
+
4
+ Solves a concrete cost problem in the host-chat vision loop. `media_analysis`
5
+ extracts sampled frames at FULL source resolution — a 35-minute 4K clip yields
6
+ 80 frames at 2160x3840, ~280 KB each. Reading those one at a time to satisfy
7
+ `commit_vision` costs roughly 1,100 tokens per frame; a four-clip shoot runs to
8
+ 250+ frames and several hundred thousand tokens.
9
+
10
+ Tiling them into labelled grids drops that by an order of magnitude while
11
+ keeping every frame visible. Each tile is burned with its frame index, source
12
+ timestamp and the sampler's `selection_reason`, so per-frame findings can still
13
+ be reported by index against the schema.
14
+
15
+ Source-safe: reads only the analysis scratch directory and the sidecar
16
+ `visual.json`; writes only into `out_dir`. Never touches source media.
17
+
18
+ Run: `python3 scripts/contact_sheet.py <clip_analysis_dir> <out_dir>`
19
+ where `<clip_analysis_dir>` is the directory holding `visual.json` and
20
+ `frames/` (printed as `artifacts.clip_dir` by `media_analysis`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ from typing import Any, Dict, List, Tuple
29
+
30
+ try:
31
+ from PIL import Image, ImageDraw, ImageFont
32
+ except ImportError: # pragma: no cover - guidance only, never auto-installs
33
+ raise SystemExit(
34
+ "Pillow is required: pip install Pillow "
35
+ "(source-safe; used only to tile analysis scratch frames)"
36
+ )
37
+
38
+ LABEL_HEIGHT = 26
39
+ FONT_CANDIDATES = (
40
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
41
+ "/System/Library/Fonts/Helvetica.ttc",
42
+ "/Library/Fonts/Arial.ttf",
43
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
44
+ )
45
+
46
+
47
+ def _load_font(size: int):
48
+ for path in FONT_CANDIDATES:
49
+ if os.path.exists(path):
50
+ try:
51
+ return ImageFont.truetype(path, size)
52
+ except OSError:
53
+ continue
54
+ return ImageFont.load_default()
55
+
56
+
57
+ def _timestamp(seconds: Any) -> str:
58
+ if seconds is None:
59
+ return "?"
60
+ minutes, secs = divmod(float(seconds), 60)
61
+ return "%d:%05.2f" % (int(minutes), secs)
62
+
63
+
64
+ def _read_json(path: str) -> Dict[str, Any]:
65
+ try:
66
+ with open(path, encoding="utf-8") as handle:
67
+ return json.load(handle)
68
+ except (OSError, ValueError):
69
+ return {}
70
+
71
+
72
+ def load_frames(clip_dir: str) -> Tuple[List[str], Dict[int, Dict[str, Any]]]:
73
+ """Resolve frame paths + per-frame labels across all three artifact states.
74
+
75
+ Order matters. While vision is pending, visual.json's `frame_paths` IS the
76
+ payload `commit_vision` expects findings reported against, so its indices
77
+ must win — analysis.json holds a larger superset (vision sample plus
78
+ cut-boundary frames) whose numbering would not line up.
79
+
80
+ `commit_vision` then REPLACES visual.json with the committed analysis,
81
+ dropping `frame_paths`. After that analysis.json's `analysis_keyframes` is
82
+ the richest surviving source, and index alignment no longer matters because
83
+ there is no vision payload left to report against.
84
+ """
85
+ visual = _read_json(os.path.join(clip_dir, "visual.json"))
86
+ paths = [p for p in visual.get("frame_paths", []) if os.path.exists(p)]
87
+ if paths:
88
+ meta = {
89
+ m["frame_index"]: m for m in visual.get("frame_metadata", [])
90
+ }
91
+ return paths, meta
92
+
93
+ keyframes = _read_json(os.path.join(clip_dir, "analysis.json")).get(
94
+ "analysis_keyframes"
95
+ )
96
+ if keyframes:
97
+ paths, meta = [], {}
98
+ for position, entry in enumerate(keyframes, start=1):
99
+ frame_path = entry.get("frame_path")
100
+ if not frame_path or not os.path.exists(frame_path):
101
+ continue
102
+ paths.append(frame_path)
103
+ meta[len(paths)] = {
104
+ "time_seconds": entry.get("time_seconds"),
105
+ "selection_reason": entry.get("selection_reason"),
106
+ "frame_index": entry.get("index", position),
107
+ }
108
+ if paths:
109
+ return paths, meta
110
+
111
+ frames_dir = os.path.join(clip_dir, "frames")
112
+ if os.path.isdir(frames_dir):
113
+ listed = sorted(
114
+ os.path.join(frames_dir, n)
115
+ for n in os.listdir(frames_dir)
116
+ if n.lower().endswith((".jpg", ".jpeg", ".png"))
117
+ )
118
+ return listed, {}
119
+
120
+ return [], {}
121
+
122
+
123
+ def build_sheets(
124
+ clip_dir: str,
125
+ out_dir: str,
126
+ tile_width: int = 300,
127
+ columns: int = 6,
128
+ rows: int = 3,
129
+ ) -> Tuple[List[Tuple[str, int, int]], int]:
130
+ """Tile every sampled frame into labelled sheets. Returns (sheets, frame_count)."""
131
+ paths, meta = load_frames(clip_dir)
132
+ if not paths:
133
+ return [], 0
134
+
135
+ per_sheet = columns * rows
136
+ os.makedirs(out_dir, exist_ok=True)
137
+ font = _load_font(17)
138
+
139
+ # Derive tile height from a real frame — never assume 16:9. Vertical phone
140
+ # footage arrives here already rotated to portrait by ffmpeg.
141
+ with Image.open(paths[0]) as probe:
142
+ tile_height = max(1, round(tile_width * probe.height / probe.width))
143
+
144
+ sheets: List[Tuple[str, int, int]] = []
145
+ for offset in range(0, len(paths), per_sheet):
146
+ chunk = paths[offset:offset + per_sheet]
147
+ used_rows = (len(chunk) + columns - 1) // columns
148
+ sheet = Image.new(
149
+ "RGB",
150
+ (columns * tile_width, used_rows * (tile_height + LABEL_HEIGHT)),
151
+ (18, 18, 18),
152
+ )
153
+ draw = ImageDraw.Draw(sheet)
154
+
155
+ for position, frame_path in enumerate(chunk):
156
+ index = offset + position + 1
157
+ row, column = divmod(position, columns)
158
+ x = column * tile_width
159
+ y = row * (tile_height + LABEL_HEIGHT)
160
+ with Image.open(frame_path) as frame:
161
+ frame = frame.convert("RGB").resize(
162
+ (tile_width, tile_height), Image.LANCZOS
163
+ )
164
+ sheet.paste(frame, (x, y + LABEL_HEIGHT))
165
+ entry = meta.get(index, {})
166
+ reason = (entry.get("selection_reason") or "")[:14]
167
+ draw.text(
168
+ (x + 5, y + 4),
169
+ "#%d %s %s" % (index, _timestamp(entry.get("time_seconds")), reason),
170
+ fill=(255, 220, 90),
171
+ font=font,
172
+ )
173
+
174
+ out_path = os.path.join(out_dir, "sheet_%02d.jpg" % (offset // per_sheet + 1))
175
+ sheet.save(out_path, quality=82, optimize=True)
176
+ sheets.append((out_path, offset + 1, offset + len(chunk)))
177
+
178
+ return sheets, len(paths)
179
+
180
+
181
+ def main() -> int:
182
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
183
+ parser.add_argument("clip_dir", help="directory containing visual.json and frames/")
184
+ parser.add_argument("out_dir", help="where to write the contact sheets")
185
+ parser.add_argument("--tile-width", type=int, default=300)
186
+ parser.add_argument("--columns", type=int, default=6)
187
+ parser.add_argument("--rows", type=int, default=3)
188
+ args = parser.parse_args()
189
+
190
+ sheets, frame_count = build_sheets(
191
+ args.clip_dir, args.out_dir, args.tile_width, args.columns, args.rows
192
+ )
193
+ if not frame_count:
194
+ print(
195
+ "no sampled frames found in %s\n"
196
+ "expected analysis.json, visual.json, or a frames/ directory "
197
+ "(use the clip_dir printed as artifacts.clip_dir by media_analysis)"
198
+ % args.clip_dir
199
+ )
200
+ return 1
201
+
202
+ print("frames=%d sheets=%d" % (frame_count, len(sheets)))
203
+ for path, first, last in sheets:
204
+ print("%s frames %d-%d" % (path, first, last))
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ raise SystemExit(main())