davinci-resolve-mcp 2.67.0 → 2.68.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 +117 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/docs/kernels/render-deliver-kernel.md +14 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Resolve Scripts-menu capability probe: what does THIS edition's API expose?
|
|
2
|
+
|
|
3
|
+
Run from **Workspace ▸ Scripts ▸ resolve_capability_probe**, on whichever
|
|
4
|
+
edition you want to characterise.
|
|
5
|
+
|
|
6
|
+
The free edition gates *features* (noise reduction, Magic Mask, Super Scale,
|
|
7
|
+
some codecs, some resolutions). What it does to the *scripting API* is a
|
|
8
|
+
separate question and is not documented anywhere authoritative: a method may be
|
|
9
|
+
absent, or present and always return False, or present and work. Those three
|
|
10
|
+
cases need completely different handling in a connector, and guessing which is
|
|
11
|
+
which is how you ship a tool that lies about what it did.
|
|
12
|
+
|
|
13
|
+
So this asks the running application, on the edition in front of you.
|
|
14
|
+
|
|
15
|
+
**Strictly read-only.** It enumerates methods, calls only getters, and creates,
|
|
16
|
+
modifies and deletes nothing. Safe to run on a real project — though it reads
|
|
17
|
+
whatever project is open, so use a scratch one if you would rather not have your
|
|
18
|
+
timeline names in the report.
|
|
19
|
+
|
|
20
|
+
Writes to ``~/.config/davinci-resolve-mcp/capability-probe-<edition>.json`` and
|
|
21
|
+
prints a summary. Self-contained: no repository imports.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
REPORT_DIR = os.path.expanduser("~/.config/davinci-resolve-mcp")
|
|
30
|
+
|
|
31
|
+
#: Methods worth knowing about, grouped by the object they live on. Chosen for
|
|
32
|
+
#: what a connector actually needs, not for completeness.
|
|
33
|
+
SURFACE = {
|
|
34
|
+
"Resolve": [
|
|
35
|
+
"GetProductName", "GetVersionString", "GetCurrentPage", "OpenPage",
|
|
36
|
+
"GetProjectManager", "GetMediaStorage", "ImportRenderPreset",
|
|
37
|
+
"ExportRenderPreset", "ImportBurnInPreset", "Quit",
|
|
38
|
+
],
|
|
39
|
+
"ProjectManager": [
|
|
40
|
+
"GetCurrentProject", "GetProjectListInCurrentFolder", "GetCurrentFolder",
|
|
41
|
+
"GetCurrentDatabase", "GetDatabaseList", "CreateProject", "LoadProject",
|
|
42
|
+
"SaveProject", "CloseProject", "DeleteProject", "ExportProject",
|
|
43
|
+
"ImportProject", "CreateFolder", "ArchiveProject",
|
|
44
|
+
],
|
|
45
|
+
"Project": [
|
|
46
|
+
"GetName", "GetTimelineCount", "GetTimelineByIndex", "GetCurrentTimeline",
|
|
47
|
+
"SetCurrentTimeline", "GetMediaPool", "GetGallery", "GetSetting",
|
|
48
|
+
"SetSetting", "GetRenderFormats", "GetRenderCodecs",
|
|
49
|
+
"GetCurrentRenderFormatAndCodec", "GetRenderPresetList", "AddRenderJob",
|
|
50
|
+
"StartRendering", "IsRenderingInProgress", "GetRenderJobList",
|
|
51
|
+
"GetQuickExportRenderPresets", "RefreshLUTList", "GetUniqueId",
|
|
52
|
+
],
|
|
53
|
+
"MediaPool": [
|
|
54
|
+
"GetRootFolder", "GetCurrentFolder", "AddSubFolder", "ImportMedia",
|
|
55
|
+
"CreateEmptyTimeline", "CreateTimelineFromClips", "AppendToTimeline",
|
|
56
|
+
"DeleteTimelines", "DeleteClips", "ImportTimelineFromFile",
|
|
57
|
+
"GetTimelineMatte", "RefreshFolders",
|
|
58
|
+
],
|
|
59
|
+
"Timeline": [
|
|
60
|
+
"GetName", "SetName", "GetStartFrame", "GetEndFrame", "GetTrackCount",
|
|
61
|
+
"GetItemListInTrack", "GetTrackName", "GetIsTrackEnabled",
|
|
62
|
+
"AddTrack", "DeleteTrack", "AddMarker", "GetMarkers",
|
|
63
|
+
"DuplicateTimeline", "Export", "GetCurrentTimecode", "SetCurrentTimecode",
|
|
64
|
+
"InsertFusionTitleIntoTimeline", "InsertTitleIntoTimeline",
|
|
65
|
+
"CreateSubtitlesFromAudio", "DetectSceneCuts", "GetSetting",
|
|
66
|
+
],
|
|
67
|
+
"TimelineItem": [
|
|
68
|
+
"GetName", "GetDuration", "GetStart", "GetEnd", "GetProperty",
|
|
69
|
+
"SetProperty", "GetNumNodes", "SetLUT", "GetLUT", "SetCDL",
|
|
70
|
+
"AddMarker", "GetMarkers", "GetFusionCompCount", "AddFusionComp",
|
|
71
|
+
"SetClipEnabled", "GetMediaPoolItem", "CreateMagicMask",
|
|
72
|
+
"SmartReframe", "GetNodeLabel",
|
|
73
|
+
],
|
|
74
|
+
"MediaPoolItem": [
|
|
75
|
+
"GetName", "GetClipProperty", "SetClipProperty", "GetMetadata",
|
|
76
|
+
"SetMetadata", "GetMediaId", "AddMarker", "GetMarkers", "LinkProxyMedia",
|
|
77
|
+
"TranscribeAudio", "ClearTranscription",
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#: Getters that are safe to actually call, to separate "method exists" from
|
|
82
|
+
#: "method works". A method can be present and still be inert on this edition.
|
|
83
|
+
SAFE_CALLS = {
|
|
84
|
+
"Resolve": ["GetProductName", "GetVersionString", "GetCurrentPage"],
|
|
85
|
+
"ProjectManager": ["GetProjectListInCurrentFolder", "GetCurrentFolder"],
|
|
86
|
+
"Project": [
|
|
87
|
+
"GetName", "GetTimelineCount", "GetRenderFormats",
|
|
88
|
+
"GetCurrentRenderFormatAndCodec", "GetRenderPresetList",
|
|
89
|
+
"IsRenderingInProgress", "GetRenderJobList",
|
|
90
|
+
],
|
|
91
|
+
"MediaPool": ["GetRootFolder", "GetCurrentFolder"],
|
|
92
|
+
"Timeline": [
|
|
93
|
+
"GetName", "GetStartFrame", "GetEndFrame", "GetCurrentTimecode", "GetMarkers",
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def acquire_resolve():
|
|
99
|
+
candidate = globals().get("resolve")
|
|
100
|
+
if candidate is not None:
|
|
101
|
+
return candidate
|
|
102
|
+
bmd = globals().get("bmd")
|
|
103
|
+
if bmd is not None:
|
|
104
|
+
try:
|
|
105
|
+
return bmd.scriptapp("Resolve")
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
import DaVinciResolveScript as script_module
|
|
110
|
+
return script_module.scriptapp("Resolve")
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def classify(obj, names):
|
|
116
|
+
"""Which of `names` exist on `obj`."""
|
|
117
|
+
if obj is None:
|
|
118
|
+
return {"reachable": False, "note": "object not sampled in this context",
|
|
119
|
+
"present": [], "missing": sorted(names)}
|
|
120
|
+
present, missing = [], []
|
|
121
|
+
for name in names:
|
|
122
|
+
(present if callable(getattr(obj, name, None)) else missing).append(name)
|
|
123
|
+
return {"reachable": True, "present": sorted(present), "missing": sorted(missing)}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def try_calls(obj, names):
|
|
127
|
+
"""Call safe getters; record value shape or the failure. Never mutates."""
|
|
128
|
+
out = {}
|
|
129
|
+
if obj is None:
|
|
130
|
+
return out
|
|
131
|
+
for name in names:
|
|
132
|
+
fn = getattr(obj, name, None)
|
|
133
|
+
if not callable(fn):
|
|
134
|
+
out[name] = {"ok": False, "why": "absent"}
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
value = fn()
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
out[name] = {"ok": False, "why": "raised: %s" % type(exc).__name__}
|
|
140
|
+
continue
|
|
141
|
+
if value is None:
|
|
142
|
+
out[name] = {"ok": False, "why": "returned None"}
|
|
143
|
+
elif isinstance(value, bool):
|
|
144
|
+
# A bool getter answering False is a real answer, not a failure.
|
|
145
|
+
# Scoring it as one is how "nothing is rendering" reads as "broken".
|
|
146
|
+
out[name] = {"ok": True, "type": "bool", "value": value}
|
|
147
|
+
elif isinstance(value, dict):
|
|
148
|
+
out[name] = {"ok": True, "type": "dict", "size": len(value),
|
|
149
|
+
"sample": sorted(list(value)[:8])}
|
|
150
|
+
elif isinstance(value, (list, tuple)):
|
|
151
|
+
out[name] = {"ok": True, "type": "list", "size": len(value),
|
|
152
|
+
"sample": [str(v)[:40] for v in list(value)[:8]]}
|
|
153
|
+
else:
|
|
154
|
+
out[name] = {"ok": True, "type": type(value).__name__, "value": str(value)[:120]}
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main():
|
|
159
|
+
resolve = acquire_resolve()
|
|
160
|
+
if resolve is None:
|
|
161
|
+
print("Could not get the resolve object. Run this from Workspace > Scripts.")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
report = {"probed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
165
|
+
"interpreter": sys.executable}
|
|
166
|
+
try:
|
|
167
|
+
product = resolve.GetProductName()
|
|
168
|
+
report["product"] = product
|
|
169
|
+
report["version"] = resolve.GetVersionString()
|
|
170
|
+
report["edition"] = "studio" if "studio" in str(product).lower() else "free"
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
report["product_error"] = type(exc).__name__
|
|
173
|
+
report["edition"] = "unknown"
|
|
174
|
+
|
|
175
|
+
manager = getattr(resolve, "GetProjectManager", lambda: None)()
|
|
176
|
+
project = getattr(manager, "GetCurrentProject", lambda: None)() if manager else None
|
|
177
|
+
pool = getattr(project, "GetMediaPool", lambda: None)() if project else None
|
|
178
|
+
timeline = getattr(project, "GetCurrentTimeline", lambda: None)() if project else None
|
|
179
|
+
|
|
180
|
+
item = None
|
|
181
|
+
if timeline is not None:
|
|
182
|
+
try:
|
|
183
|
+
for index in range(1, int(timeline.GetTrackCount("video") or 0) + 1):
|
|
184
|
+
items = timeline.GetItemListInTrack("video", index) or []
|
|
185
|
+
if items:
|
|
186
|
+
item = items[0]
|
|
187
|
+
break
|
|
188
|
+
except Exception:
|
|
189
|
+
item = None
|
|
190
|
+
media_item = None
|
|
191
|
+
if pool is not None:
|
|
192
|
+
try:
|
|
193
|
+
clips = pool.GetRootFolder().GetClipList() or []
|
|
194
|
+
media_item = clips[0] if clips else None
|
|
195
|
+
except Exception:
|
|
196
|
+
media_item = None
|
|
197
|
+
|
|
198
|
+
objects = {
|
|
199
|
+
"Resolve": resolve, "ProjectManager": manager, "Project": project,
|
|
200
|
+
"MediaPool": pool, "Timeline": timeline, "TimelineItem": item,
|
|
201
|
+
"MediaPoolItem": media_item,
|
|
202
|
+
}
|
|
203
|
+
report["surface"] = {k: classify(objects.get(k), v) for k, v in SURFACE.items()}
|
|
204
|
+
report["calls"] = {k: try_calls(objects.get(k), v) for k, v in SAFE_CALLS.items()}
|
|
205
|
+
report["context"] = {
|
|
206
|
+
"project_open": project is not None,
|
|
207
|
+
"timeline_open": timeline is not None,
|
|
208
|
+
"timeline_item_sampled": item is not None,
|
|
209
|
+
"media_item_sampled": media_item is not None,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# The render matrix is where free/Studio differ most visibly.
|
|
213
|
+
if project is not None:
|
|
214
|
+
try:
|
|
215
|
+
formats = project.GetRenderFormats() or {}
|
|
216
|
+
matrix = {}
|
|
217
|
+
for label in sorted(formats)[:40]:
|
|
218
|
+
try:
|
|
219
|
+
codecs = project.GetRenderCodecs(formats[label]) or {}
|
|
220
|
+
matrix[label] = sorted(codecs)
|
|
221
|
+
except Exception:
|
|
222
|
+
matrix[label] = ["<error>"]
|
|
223
|
+
report["render_matrix"] = matrix
|
|
224
|
+
report["render_format_count"] = len(formats)
|
|
225
|
+
report["render_pair_count"] = sum(len(v) for v in matrix.values())
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
report["render_matrix_error"] = type(exc).__name__
|
|
228
|
+
|
|
229
|
+
edition = report.get("edition", "unknown")
|
|
230
|
+
path = os.path.join(REPORT_DIR, "capability-probe-%s.json" % edition)
|
|
231
|
+
try:
|
|
232
|
+
if not os.path.isdir(REPORT_DIR):
|
|
233
|
+
os.makedirs(REPORT_DIR)
|
|
234
|
+
with open(path, "w") as handle:
|
|
235
|
+
json.dump(report, handle, indent=2, sort_keys=True)
|
|
236
|
+
written = path
|
|
237
|
+
except (OSError, IOError) as exc:
|
|
238
|
+
written = "could not write (%s)" % type(exc).__name__
|
|
239
|
+
|
|
240
|
+
print("=" * 70)
|
|
241
|
+
print("Resolve capability probe - %s %s (%s)" % (
|
|
242
|
+
report.get("product", "?"), report.get("version", "?"), edition))
|
|
243
|
+
print("=" * 70)
|
|
244
|
+
for group, result in report["surface"].items():
|
|
245
|
+
if not result.get("reachable"):
|
|
246
|
+
print(" %-16s not sampled (%s)" % (group, result.get("note")))
|
|
247
|
+
continue
|
|
248
|
+
total = len(result["present"]) + len(result["missing"])
|
|
249
|
+
print(" %-16s %d/%d present" % (group, len(result["present"]), total))
|
|
250
|
+
if result["missing"]:
|
|
251
|
+
print(" missing: %s" % ", ".join(result["missing"]))
|
|
252
|
+
print()
|
|
253
|
+
failed = [(g, n, r["why"]) for g, calls in report["calls"].items()
|
|
254
|
+
for n, r in calls.items() if not r["ok"]]
|
|
255
|
+
print(" safe getter calls: %d ok, %d not" % (
|
|
256
|
+
sum(1 for g, c in report["calls"].items() for r in c.values() if r["ok"]), len(failed)))
|
|
257
|
+
for group, name, why in failed:
|
|
258
|
+
print(" %s.%s -> %s" % (group, name, why))
|
|
259
|
+
if "render_format_count" in report:
|
|
260
|
+
print()
|
|
261
|
+
print(" render matrix: %d formats, %d format/codec pairs" % (
|
|
262
|
+
report["render_format_count"], report["render_pair_count"]))
|
|
263
|
+
print()
|
|
264
|
+
print(" context: %s" % json.dumps(report["context"]))
|
|
265
|
+
print(" report: %s" % written)
|
|
266
|
+
print("=" * 70)
|
|
267
|
+
return report
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
main()
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.68.0"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
|
@@ -234,6 +234,11 @@ mcp.tool = _tool_with_default_annotations
|
|
|
234
234
|
|
|
235
235
|
resolve = None
|
|
236
236
|
dvr_script = None
|
|
237
|
+
#: `dvr_script` may be None when Resolve is not installed; every use passes it
|
|
238
|
+
#: to `connect_resolve()`, which treats None as "not available" and returns None.
|
|
239
|
+
_OPTIONAL_DEPENDENCY_CONTRACT = (
|
|
240
|
+
"DaVinciResolveScript: always routed through connect_resolve(), which is None-tolerant"
|
|
241
|
+
)
|
|
237
242
|
|
|
238
243
|
try:
|
|
239
244
|
import DaVinciResolveScript as dvr_script # type: ignore
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.68.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -122,6 +122,7 @@ from src.utils.fusion_group_settings import (
|
|
|
122
122
|
from src.utils import analysis_runs as _analysis_runs
|
|
123
123
|
from src.utils import brain_edits as _brain_edits
|
|
124
124
|
from src.utils import edit_engine as _edit_engine_mod
|
|
125
|
+
from src.utils import transcript_edit as _transcript_edit_defaults
|
|
125
126
|
from src.utils import media_pool_changes as _media_pool_changes
|
|
126
127
|
from src.utils import timeline_versioning as _timeline_versioning
|
|
127
128
|
from src.utils import project_spec as _project_spec
|
|
@@ -803,11 +804,32 @@ def _is_resolve_handle_live(candidate) -> bool:
|
|
|
803
804
|
return False
|
|
804
805
|
|
|
805
806
|
|
|
807
|
+
def _bridge_requested() -> bool:
|
|
808
|
+
"""Has the operator asked for the in-app bridge?
|
|
809
|
+
|
|
810
|
+
Read at call time rather than at import, so a server started before the
|
|
811
|
+
variable was set still honours it.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
from src.utils import resolve_bridge_client
|
|
815
|
+
|
|
816
|
+
return resolve_bridge_client.bridge_enabled()
|
|
817
|
+
except Exception: # pragma: no cover - the client is optional
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
|
|
806
821
|
def _try_connect():
|
|
807
822
|
"""Attempt to connect to Resolve once. Returns resolve object or None."""
|
|
808
823
|
global resolve
|
|
809
824
|
with _resolve_lock:
|
|
810
|
-
|
|
825
|
+
# `dvr_script` is Blackmagic's module, and it ships with the *installer*,
|
|
826
|
+
# not the App Store build — a free-edition-only machine has no
|
|
827
|
+
# Developer/Scripting/Modules tree at all, so the import fails. Bailing
|
|
828
|
+
# here made the bridge unreachable on precisely the configuration it
|
|
829
|
+
# exists for: `connect_resolve` accepts None in bridge mode, and this
|
|
830
|
+
# returned before ever calling it. Verified by blocking the import with a
|
|
831
|
+
# healthy bridge listening — get_resolve() answered None.
|
|
832
|
+
if dvr_script is None and not _bridge_requested():
|
|
811
833
|
return None
|
|
812
834
|
try:
|
|
813
835
|
candidate = connect_resolve(dvr_script)
|
|
@@ -822,13 +844,34 @@ def _try_connect():
|
|
|
822
844
|
resolve = None
|
|
823
845
|
return None
|
|
824
846
|
|
|
847
|
+
#: macOS puts the two editions in different places, and only the first was ever
|
|
848
|
+
#: checked — so an auto-launch on a machine with both would start Studio even
|
|
849
|
+
#: when the free edition was the one in use. Ordered installer-first to keep the
|
|
850
|
+
#: previous behaviour where Studio is present.
|
|
851
|
+
_MACOS_RESOLVE_APPS = (
|
|
852
|
+
"/Applications/DaVinci Resolve/DaVinci Resolve.app", # installer / Studio
|
|
853
|
+
"/Applications/DaVinci Resolve.app", # App Store, free
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
825
857
|
def _launch_resolve():
|
|
826
858
|
"""Launch DaVinci Resolve and wait for it to become available."""
|
|
859
|
+
if _bridge_requested():
|
|
860
|
+
# Launching cannot produce a bridge. The listener only exists once
|
|
861
|
+
# someone runs Workspace > Scripts > resolve_bridge inside an already
|
|
862
|
+
# running Resolve, so starting the application here would open a window
|
|
863
|
+
# nobody asked for and still fail to connect.
|
|
864
|
+
logger.error(
|
|
865
|
+
"The in-app bridge is enabled but not answering. Start it from "
|
|
866
|
+
"Workspace > Scripts > resolve_bridge inside the running Resolve; "
|
|
867
|
+
"launching the application cannot start it."
|
|
868
|
+
)
|
|
869
|
+
return False
|
|
827
870
|
sys_name = platform.system().lower()
|
|
828
871
|
if sys_name == "darwin":
|
|
829
|
-
app_path =
|
|
830
|
-
if
|
|
831
|
-
logger.error(
|
|
872
|
+
app_path = next((p for p in _MACOS_RESOLVE_APPS if os.path.exists(p)), None)
|
|
873
|
+
if app_path is None:
|
|
874
|
+
logger.error("DaVinci Resolve not found in %s", list(_MACOS_RESOLVE_APPS))
|
|
832
875
|
return False
|
|
833
876
|
subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
|
|
834
877
|
elif sys_name == "windows":
|
|
@@ -15240,6 +15283,7 @@ _RENDER_KERNEL_ACTIONS = [
|
|
|
15240
15283
|
"export_render_boundary_report",
|
|
15241
15284
|
"list_delivery_targets",
|
|
15242
15285
|
"resolve_delivery_target",
|
|
15286
|
+
"list_loudness_standards",
|
|
15243
15287
|
"prepare_delivery_job",
|
|
15244
15288
|
]
|
|
15245
15289
|
|
|
@@ -15618,6 +15662,9 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
|
|
|
15618
15662
|
|
|
15619
15663
|
fps = _delivery_timeline_fps(proj)
|
|
15620
15664
|
qc_spec = _delivery_targets.to_qc_spec(target, timeline_fps=fps)
|
|
15665
|
+
# Loudness is a separate projection feeding advanced `loudness_qc`, not a
|
|
15666
|
+
# deliverable_qc field — the mix sets programme loudness, not the render.
|
|
15667
|
+
loudness = _delivery_targets.to_loudness_target(target)
|
|
15621
15668
|
return (
|
|
15622
15669
|
{
|
|
15623
15670
|
"target": target.id,
|
|
@@ -15633,12 +15680,40 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
|
|
|
15633
15680
|
if qc_spec
|
|
15634
15681
|
else "This target renders an image sequence; deliverable_qc probes a single file, so it has no QC spec."
|
|
15635
15682
|
),
|
|
15683
|
+
"loudness_target": loudness,
|
|
15684
|
+
"loudness_note": (
|
|
15685
|
+
None
|
|
15686
|
+
if loudness
|
|
15687
|
+
else "This target pins no programme loudness. Name one with "
|
|
15688
|
+
"overrides={'loudness_standard': ...}; see render(action='list_loudness_standards')."
|
|
15689
|
+
),
|
|
15636
15690
|
"notes": list(target.notes),
|
|
15637
15691
|
},
|
|
15638
15692
|
None,
|
|
15639
15693
|
)
|
|
15640
15694
|
|
|
15641
15695
|
|
|
15696
|
+
def _list_loudness_standards(proj, p: Dict[str, Any]):
|
|
15697
|
+
"""Named programme-loudness contracts, so an agent cites one rather than inventing numbers."""
|
|
15698
|
+
return (
|
|
15699
|
+
{
|
|
15700
|
+
"standards": _delivery_targets.list_loudness_standards(),
|
|
15701
|
+
"lra_advisory_lu": _delivery_targets.LRA_ADVISORY_LU,
|
|
15702
|
+
"usage": (
|
|
15703
|
+
"Attach with render(action='resolve_delivery_target', params={'target': ..., "
|
|
15704
|
+
"'overrides': {'loudness_standard': '<id>'}}), then hand the returned "
|
|
15705
|
+
"loudness_target.target to the advanced server's loudness_qc."
|
|
15706
|
+
),
|
|
15707
|
+
"note": (
|
|
15708
|
+
"Dialogue-gated standards emit no gradeable integrated value: loudness_qc "
|
|
15709
|
+
"measures full-program loudness, so that figure travels in meta for a "
|
|
15710
|
+
"dialogue-gated meter and only true peak is asserted."
|
|
15711
|
+
),
|
|
15712
|
+
},
|
|
15713
|
+
None,
|
|
15714
|
+
)
|
|
15715
|
+
|
|
15716
|
+
|
|
15642
15717
|
def _list_delivery_targets(proj, p: Dict[str, Any]):
|
|
15643
15718
|
tier = p.get("tier")
|
|
15644
15719
|
if tier is not None and tier not in _delivery_targets.TIERS:
|
|
@@ -15864,6 +15939,9 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
15864
15939
|
elif action == "resolve_delivery_target":
|
|
15865
15940
|
_resolved, _target_err = _resolve_delivery_target_live(proj, p)
|
|
15866
15941
|
return _target_err if _target_err else _ok(**_resolved)
|
|
15942
|
+
elif action == "list_loudness_standards":
|
|
15943
|
+
_standards, _standards_err = _list_loudness_standards(proj, p)
|
|
15944
|
+
return _standards_err if _standards_err else _ok(**_standards)
|
|
15867
15945
|
elif action == "prepare_delivery_job":
|
|
15868
15946
|
return _prepare_delivery_job(proj, p)
|
|
15869
15947
|
elif action == "set_format_and_codec":
|
|
@@ -17288,6 +17366,38 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17288
17366
|
|
|
17289
17367
|
if action == "capabilities":
|
|
17290
17368
|
return _media_analysis_capabilities_for_request(ctx)
|
|
17369
|
+
if action == "assess_grade":
|
|
17370
|
+
# Numeric grade-damage QC. Distinct from advanced `verify_grade`, which
|
|
17371
|
+
# asks whether Resolve applied what was asked; this asks whether the
|
|
17372
|
+
# resulting image is damaged. Free and deterministic — the vision block
|
|
17373
|
+
# in the result says whether a paid second opinion is warranted.
|
|
17374
|
+
from src.utils import image_qc as _image_qc_mod
|
|
17375
|
+
|
|
17376
|
+
try:
|
|
17377
|
+
return _ok(**_image_qc_mod.assess_grade(
|
|
17378
|
+
str(p.get("source_path") or p.get("sourcePath") or ""),
|
|
17379
|
+
time_seconds=float(p.get("time_seconds", p.get("timeSeconds", 0.0)) or 0.0),
|
|
17380
|
+
graded_path=(p.get("graded_path") or p.get("gradedPath")) or None,
|
|
17381
|
+
lut_path=(p.get("lut_path") or p.get("lutPath")) or None,
|
|
17382
|
+
working_space=str(p.get("working_space") or p.get("workingSpace") or "rec709"),
|
|
17383
|
+
cost_tier=str(p.get("cost_tier") or p.get("costTier") or _image_qc_mod.DEFAULT_COST_TIER),
|
|
17384
|
+
))
|
|
17385
|
+
except _image_qc_mod.ImageQcError as exc:
|
|
17386
|
+
return _err(
|
|
17387
|
+
str(exc),
|
|
17388
|
+
code="IMAGE_QC_REFUSED",
|
|
17389
|
+
category="invalid_input",
|
|
17390
|
+
remediation=(
|
|
17391
|
+
"Supply exactly one of graded_path or lut_path, and declare a display-referred "
|
|
17392
|
+
"working_space. Log/scene-referred frames must be converted first — these "
|
|
17393
|
+
"metrics are undefined on them and will not be guessed at."
|
|
17394
|
+
),
|
|
17395
|
+
)
|
|
17396
|
+
if action == "image_qc_capabilities":
|
|
17397
|
+
from src.utils import image_qc as _image_qc_mod
|
|
17398
|
+
|
|
17399
|
+
return _ok(**_image_qc_mod.capabilities(), cost_tiers=list(_image_qc_mod.COST_TIERS),
|
|
17400
|
+
default_cost_tier=_image_qc_mod.DEFAULT_COST_TIER)
|
|
17291
17401
|
if action == "recheck_capabilities":
|
|
17292
17402
|
# Re-runs detection (shutil.which / importlib.util.find_spec) so a tool
|
|
17293
17403
|
# an agent just installed flips from missing → available without the
|
|
@@ -18197,6 +18307,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
18197
18307
|
return _unknown(action, [
|
|
18198
18308
|
"capabilities",
|
|
18199
18309
|
"recheck_capabilities",
|
|
18310
|
+
"assess_grade",
|
|
18311
|
+
"image_qc_capabilities",
|
|
18200
18312
|
"install_guidance",
|
|
18201
18313
|
"resolve_output_root",
|
|
18202
18314
|
"plan",
|
|
@@ -18752,6 +18864,71 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18752
18864
|
include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
|
|
18753
18865
|
)
|
|
18754
18866
|
|
|
18867
|
+
if action == "generate_captions":
|
|
18868
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
18869
|
+
if err:
|
|
18870
|
+
return err
|
|
18871
|
+
from src.utils import captions as _captions_mod
|
|
18872
|
+
from src.utils import strata as _strata_mod
|
|
18873
|
+
|
|
18874
|
+
conn, clip, clip_err = _strata_mod.resolve_clip(
|
|
18875
|
+
project_root, p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"), require_media=False
|
|
18876
|
+
)
|
|
18877
|
+
if clip_err:
|
|
18878
|
+
return clip_err
|
|
18879
|
+
_words = _strata_mod.read_words(conn, clip["clip_uuid"])
|
|
18880
|
+
_opts = {}
|
|
18881
|
+
for _key, _param in (
|
|
18882
|
+
("max_chars_per_line", "maxCharsPerLine"), ("max_lines", "maxLines"),
|
|
18883
|
+
("max_block_seconds", "maxBlockSeconds"), ("min_block_seconds", "minBlockSeconds"),
|
|
18884
|
+
("min_gap_seconds", "minGapSeconds"), ("pause_break_seconds", "pauseBreakSeconds"),
|
|
18885
|
+
):
|
|
18886
|
+
_value = p.get(_key, p.get(_param))
|
|
18887
|
+
if _value is not None:
|
|
18888
|
+
_opts[_key] = int(_value) if _key in {"max_chars_per_line", "max_lines"} else float(_value)
|
|
18889
|
+
try:
|
|
18890
|
+
_result = _captions_mod.generate(
|
|
18891
|
+
_words,
|
|
18892
|
+
fmt=str(p.get("format") or p.get("fmt") or "srt").lower(),
|
|
18893
|
+
with_chapters=str(p.get("with_chapters", p.get("withChapters", False))).strip().lower() in {"true", "1", "yes", "on"},
|
|
18894
|
+
**_opts,
|
|
18895
|
+
)
|
|
18896
|
+
except _captions_mod.CaptionError as exc:
|
|
18897
|
+
return _err(str(exc), code="CAPTION_PARAMS_INVALID", category="invalid_input")
|
|
18898
|
+
_result["clip"] = {"clip_uuid": clip["clip_uuid"], "clip_name": clip.get("clip_name")}
|
|
18899
|
+
return _result
|
|
18900
|
+
|
|
18901
|
+
if action == "plan_transcript_tighten":
|
|
18902
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
18903
|
+
if err:
|
|
18904
|
+
return err
|
|
18905
|
+
return _edit_engine_mod.plan_transcript_tighten(
|
|
18906
|
+
project_root,
|
|
18907
|
+
clip_ref=p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"),
|
|
18908
|
+
remove_fillers=str(p.get("remove_fillers", p.get("removeFillers", True))).strip().lower() not in {"false", "0", "no", "off"},
|
|
18909
|
+
remove_false_starts=str(p.get("remove_false_starts", p.get("removeFalseStarts", True))).strip().lower() not in {"false", "0", "no", "off"},
|
|
18910
|
+
collapse_pauses=str(p.get("collapse_pauses", p.get("collapsePauses", True))).strip().lower() not in {"false", "0", "no", "off"},
|
|
18911
|
+
max_pause=float(p.get("max_pause", p.get("maxPause", _transcript_edit_defaults.DEFAULT_MAX_PAUSE_S))),
|
|
18912
|
+
handle=float(p.get("handle", _transcript_edit_defaults.DEFAULT_HANDLE_S)),
|
|
18913
|
+
min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
|
|
18914
|
+
)
|
|
18915
|
+
|
|
18916
|
+
if action == "search_spoken_content":
|
|
18917
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
18918
|
+
if err:
|
|
18919
|
+
return err
|
|
18920
|
+
query = p.get("query") or p.get("q")
|
|
18921
|
+
if not isinstance(query, str) or not query.strip():
|
|
18922
|
+
return _err("query is required", code="MISSING_QUERY", category="invalid_input")
|
|
18923
|
+
return _edit_engine_mod.search_spoken_content(
|
|
18924
|
+
project_root,
|
|
18925
|
+
query=query,
|
|
18926
|
+
mode=str(p.get("mode") or "phrase"),
|
|
18927
|
+
context_seconds=float(p.get("context_seconds", p.get("contextSeconds", 1.5))),
|
|
18928
|
+
handle_seconds=float(p.get("handle_seconds", p.get("handleSeconds", 0.5))),
|
|
18929
|
+
max_hits=int(p.get("max_hits", p.get("maxHits", 200))),
|
|
18930
|
+
)
|
|
18931
|
+
|
|
18755
18932
|
if action == "plan_silence_ripple":
|
|
18756
18933
|
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
18757
18934
|
if err:
|
|
@@ -18765,7 +18942,14 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18765
18942
|
items=items,
|
|
18766
18943
|
timeline_name=tl.GetName(),
|
|
18767
18944
|
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
18768
|
-
|
|
18945
|
+
# Omitted => None => the gate is calibrated per item from that
|
|
18946
|
+
# slice's own dynamics. Passing the old fixed default here would
|
|
18947
|
+
# make auto-calibration unreachable through the MCP surface.
|
|
18948
|
+
threshold_db=(
|
|
18949
|
+
float(_th)
|
|
18950
|
+
if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
|
|
18951
|
+
else None
|
|
18952
|
+
),
|
|
18769
18953
|
min_strip_frames=float(p.get("min_strip_frames") if p.get("min_strip_frames") is not None else p.get("minStripFrames") if p.get("minStripFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_MIN_STRIP_FRAMES),
|
|
18770
18954
|
pre_head_frames=float(p.get("pre_head_frames") if p.get("pre_head_frames") is not None else p.get("preHeadFrames") if p.get("preHeadFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_PRE_HEAD_FRAMES),
|
|
18771
18955
|
post_tail_frames=float(p.get("post_tail_frames") if p.get("post_tail_frames") is not None else p.get("postTailFrames") if p.get("postTailFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_POST_TAIL_FRAMES),
|
|
@@ -19344,6 +19528,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19344
19528
|
"plan_tighten",
|
|
19345
19529
|
"execute_tighten",
|
|
19346
19530
|
"plan_silence_ripple",
|
|
19531
|
+
"plan_transcript_tighten",
|
|
19532
|
+
"generate_captions",
|
|
19533
|
+
"search_spoken_content",
|
|
19347
19534
|
"execute_silence_ripple",
|
|
19348
19535
|
"plan_swap",
|
|
19349
19536
|
"execute_swap",
|