davinci-resolve-mcp 2.68.1 → 2.69.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 +213 -0
- package/README.md +46 -3
- package/docs/SKILL.md +181 -1
- package/docs/install.md +27 -1
- package/install.py +26 -1
- package/package.json +1 -1
- package/resolve-advanced/LICENSE +21 -0
- package/resolve-advanced/package.json +2 -1
- package/resolve-advanced/vendor/conform-qc/package.json +1 -0
- package/resolve-advanced/vendor/drp-format/package.json +1 -0
- package/resolve-advanced/vendor/drt-format/package.json +1 -0
- package/scripts/bridge_differential.py +22 -4
- package/scripts/doctor.py +129 -3
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +639 -18
- package/src/utils/beat_detection.py +242 -0
- package/src/utils/bridge_differential.py +27 -1
- package/src/utils/broll_placement.py +201 -0
- package/src/utils/conform_lint.py +437 -0
- package/src/utils/edit_engine.py +633 -1
- package/src/utils/edit_handles.py +206 -0
- package/src/utils/edit_report.py +360 -0
- package/src/utils/first_impression.py +212 -0
- package/src/utils/prebalance.py +471 -0
- package/src/utils/project_journal.py +360 -0
- package/src/utils/reference_match.py +203 -0
- package/src/utils/rhythm_audit.py +252 -0
- package/src/utils/rule_of_six.py +217 -0
- package/src/utils/setup_sheet.py +121 -0
- package/src/utils/shot_assembly.py +259 -0
- package/src/utils/silence_ripple.py +89 -2
- package/src/utils/sound_density.py +253 -0
- package/src/utils/split_edits.py +180 -0
- package/src/utils/take_ranking.py +206 -0
- package/src/utils/transcript_edit.py +101 -5
- package/src/utils/turnover.py +187 -0
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.69.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -124,6 +124,10 @@ from src.utils.fusion_group_settings import (
|
|
|
124
124
|
from src.utils import analysis_runs as _analysis_runs
|
|
125
125
|
from src.utils import brain_edits as _brain_edits
|
|
126
126
|
from src.utils import edit_engine as _edit_engine_mod
|
|
127
|
+
from src.utils import edit_report as _edit_report_mod
|
|
128
|
+
from src.utils import conform_lint as _conform_lint_mod
|
|
129
|
+
from src.utils import first_impression as _first_impression_mod
|
|
130
|
+
from src.utils import project_journal as _journal_mod
|
|
127
131
|
from src.utils import transcript_edit as _transcript_edit_defaults
|
|
128
132
|
from src.utils import media_pool_changes as _media_pool_changes
|
|
129
133
|
from src.utils import timeline_versioning as _timeline_versioning
|
|
@@ -163,8 +167,13 @@ mcp = FastMCP(
|
|
|
163
167
|
"DaVinciResolveMCP",
|
|
164
168
|
instructions=(
|
|
165
169
|
"DaVinci Resolve MCP Server — controls Resolve via its Scripting API. "
|
|
166
|
-
"
|
|
167
|
-
"
|
|
170
|
+
"If no Resolve is running, tools launch one (up to 60s on the first call); "
|
|
171
|
+
"if one is already running they never launch a second. "
|
|
172
|
+
"On a connection error, read the error's own remediation field — it names the "
|
|
173
|
+
"fix that applies. External scripting is Studio-only, but the free edition is "
|
|
174
|
+
"reachable via the in-app bridge (Workspace > Scripts > resolve_bridge, with "
|
|
175
|
+
"DAVINCI_RESOLVE_BRIDGE=1), so a connection error does NOT mean the free "
|
|
176
|
+
"edition is unsupported."
|
|
168
177
|
),
|
|
169
178
|
)
|
|
170
179
|
|
|
@@ -296,7 +305,7 @@ def davinci_resolve_workflow() -> str:
|
|
|
296
305
|
return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
|
|
297
306
|
|
|
298
307
|
Core pattern:
|
|
299
|
-
- Prefer the
|
|
308
|
+
- Prefer the 34 compound tools and their action names over raw scripting.
|
|
300
309
|
- Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
|
|
301
310
|
- Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
|
|
302
311
|
- Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
|
|
@@ -899,6 +908,37 @@ def _launch_resolve():
|
|
|
899
908
|
logger.warning("Resolve did not respond within 60s after launch")
|
|
900
909
|
return False
|
|
901
910
|
|
|
911
|
+
#: Process names that mean a DaVinci Resolve application is already running.
|
|
912
|
+
#: Deliberately not the full path: the App Store build lives at
|
|
913
|
+
#: `/Applications/DaVinci Resolve.app` and the installer build inside
|
|
914
|
+
#: `/Applications/DaVinci Resolve/`, and either one counts.
|
|
915
|
+
_RESOLVE_PROCESS_PATTERNS = (
|
|
916
|
+
"DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
|
|
917
|
+
"Resolve.exe", # Windows
|
|
918
|
+
"/opt/resolve/bin/resolve", # Linux
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def resolve_is_running() -> Optional[bool]:
|
|
923
|
+
"""Is a Resolve application already up? None when it cannot be determined.
|
|
924
|
+
|
|
925
|
+
Best-effort and dependency-free. `None` rather than `False` on failure, so an
|
|
926
|
+
unanswerable question never silently becomes "nothing is running" — which is
|
|
927
|
+
the answer that leads to launching something.
|
|
928
|
+
"""
|
|
929
|
+
try:
|
|
930
|
+
if platform.system().lower() == "windows":
|
|
931
|
+
out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
|
|
932
|
+
else:
|
|
933
|
+
out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
|
|
934
|
+
if out.returncode != 0 and not out.stdout:
|
|
935
|
+
return None
|
|
936
|
+
listing = out.stdout or ""
|
|
937
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
938
|
+
return None
|
|
939
|
+
return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
|
|
940
|
+
|
|
941
|
+
|
|
902
942
|
def get_resolve():
|
|
903
943
|
"""Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
|
|
904
944
|
global resolve
|
|
@@ -909,12 +949,84 @@ def get_resolve():
|
|
|
909
949
|
# Try to connect to an already-running Resolve.
|
|
910
950
|
if _try_connect():
|
|
911
951
|
return resolve
|
|
912
|
-
#
|
|
952
|
+
# Failing to connect is NOT the same as nothing running, and conflating
|
|
953
|
+
# them opened an application nobody asked for. The free edition refuses
|
|
954
|
+
# external scripting by design, so on a machine where only it is running
|
|
955
|
+
# `_try_connect` always fails — and this then launched *Studio*, a second,
|
|
956
|
+
# different application, on every tool call. Reported three times before
|
|
957
|
+
# it was traced.
|
|
958
|
+
already_running = resolve_is_running()
|
|
959
|
+
if already_running:
|
|
960
|
+
logger.error(
|
|
961
|
+
"DaVinci Resolve is already running but is not answering the scripting API, "
|
|
962
|
+
"so it will NOT be launched again. Either enable Preferences > General > "
|
|
963
|
+
"'External scripting using' = Local (Studio only), or, on the free edition, "
|
|
964
|
+
"use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
|
|
965
|
+
"and set DAVINCI_RESOLVE_BRIDGE=1."
|
|
966
|
+
)
|
|
967
|
+
return None
|
|
968
|
+
if already_running is None:
|
|
969
|
+
logger.warning("Could not determine whether Resolve is running; not launching it.")
|
|
970
|
+
return None
|
|
913
971
|
logger.info("Resolve not running, attempting to launch automatically...")
|
|
914
972
|
_launch_resolve()
|
|
915
973
|
return resolve
|
|
916
974
|
|
|
917
975
|
|
|
976
|
+
def _not_connected_error():
|
|
977
|
+
"""The caller-facing "no Resolve" error, describing what is actually the case.
|
|
978
|
+
|
|
979
|
+
Eleven call sites used to assert that Resolve was not running, that starting it
|
|
980
|
+
had been tried and failed, and that the reader should check their Studio
|
|
981
|
+
install. Once `get_resolve` stopped launching a second application, all three
|
|
982
|
+
claims were wrong: nothing was launched, Resolve *is* running, and a
|
|
983
|
+
free-edition user was being sent to check an install they may not have. The
|
|
984
|
+
accurate guidance was only reaching the log.
|
|
985
|
+
|
|
986
|
+
So the message is derived from the situation instead of asserted, and it names
|
|
987
|
+
the fix that applies: enable external scripting on Studio, or use the in-app
|
|
988
|
+
bridge on the free edition.
|
|
989
|
+
"""
|
|
990
|
+
running = resolve_is_running()
|
|
991
|
+
bridge_on = _bridge_requested()
|
|
992
|
+
if bridge_on:
|
|
993
|
+
return _err(
|
|
994
|
+
"The in-app bridge is enabled but not answering.",
|
|
995
|
+
code="BRIDGE_UNAVAILABLE", category="not_connected",
|
|
996
|
+
# `not_connected` defaults to retryable because auto-launch may
|
|
997
|
+
# succeed next time. Nothing here will: the listener only appears once
|
|
998
|
+
# someone runs the script inside Resolve, so an agent retrying on a
|
|
999
|
+
# loop it cannot win is strictly worse than being told to stop.
|
|
1000
|
+
retryable=False,
|
|
1001
|
+
reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
|
|
1002
|
+
remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
|
|
1003
|
+
"that menu, run `python scripts/install_resolve_bridge.py` and restart "
|
|
1004
|
+
"Resolve; a framework Python from python.org is required for Resolve to "
|
|
1005
|
+
"list .py scripts at all.",
|
|
1006
|
+
state={"resolve_running": running, "bridge_enabled": True},
|
|
1007
|
+
)
|
|
1008
|
+
if running:
|
|
1009
|
+
return _err(
|
|
1010
|
+
"DaVinci Resolve is running but is not answering the scripting API.",
|
|
1011
|
+
code="SCRIPTING_UNAVAILABLE", category="not_connected",
|
|
1012
|
+
# Not retryable: a preference has to change, or the bridge has to be
|
|
1013
|
+
# started. Retrying the same call cannot make either happen.
|
|
1014
|
+
retryable=False,
|
|
1015
|
+
reason="External scripting is a Studio feature; the free edition refuses it "
|
|
1016
|
+
"regardless of the preference. Resolve was NOT launched again.",
|
|
1017
|
+
remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
|
|
1018
|
+
"On the free edition: install the in-app bridge, run "
|
|
1019
|
+
"Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
|
|
1020
|
+
state={"resolve_running": True, "bridge_enabled": False},
|
|
1021
|
+
)
|
|
1022
|
+
return _err(
|
|
1023
|
+
"DaVinci Resolve is not running and could not be started.",
|
|
1024
|
+
code="RESOLVE_NOT_RUNNING", category="not_connected",
|
|
1025
|
+
remediation="Start DaVinci Resolve and open a project, then retry.",
|
|
1026
|
+
state={"resolve_running": bool(running), "bridge_enabled": False},
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
|
|
918
1030
|
def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
|
|
919
1031
|
"""Provider used by the C6 version-on-mutate hook.
|
|
920
1032
|
|
|
@@ -13065,11 +13177,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13065
13177
|
r = get_resolve() # auto-launches if not running
|
|
13066
13178
|
if r is not None:
|
|
13067
13179
|
return _ok(message="DaVinci Resolve is running and connected.")
|
|
13068
|
-
return
|
|
13180
|
+
return _not_connected_error()
|
|
13069
13181
|
|
|
13070
13182
|
r = get_resolve() # auto-launches if not running
|
|
13071
13183
|
if r is None:
|
|
13072
|
-
return
|
|
13184
|
+
return _not_connected_error()
|
|
13073
13185
|
|
|
13074
13186
|
if action == "get_version":
|
|
13075
13187
|
update_env = _setup_update_env()
|
|
@@ -13992,7 +14104,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
13992
14104
|
p = _params(params)
|
|
13993
14105
|
r = get_resolve()
|
|
13994
14106
|
if r is None:
|
|
13995
|
-
return
|
|
14107
|
+
return _not_connected_error()
|
|
13996
14108
|
|
|
13997
14109
|
if action == "save":
|
|
13998
14110
|
if not p.get("name"):
|
|
@@ -14037,7 +14149,7 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
14037
14149
|
p = _params(params)
|
|
14038
14150
|
r = get_resolve()
|
|
14039
14151
|
if r is None:
|
|
14040
|
-
return
|
|
14152
|
+
return _not_connected_error()
|
|
14041
14153
|
|
|
14042
14154
|
if action == "import_render":
|
|
14043
14155
|
return {"success": bool(r.ImportRenderPreset(p["path"]))}
|
|
@@ -14980,7 +15092,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14980
15092
|
p = _params(params)
|
|
14981
15093
|
r = get_resolve()
|
|
14982
15094
|
if r is None:
|
|
14983
|
-
return
|
|
15095
|
+
return _not_connected_error()
|
|
14984
15096
|
pm = r.GetProjectManager()
|
|
14985
15097
|
|
|
14986
15098
|
if action == "lint":
|
|
@@ -15100,7 +15212,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
15100
15212
|
p = _params(params)
|
|
15101
15213
|
r = get_resolve()
|
|
15102
15214
|
if r is None:
|
|
15103
|
-
return
|
|
15215
|
+
return _not_connected_error()
|
|
15104
15216
|
pm = r.GetProjectManager()
|
|
15105
15217
|
|
|
15106
15218
|
if action == "list":
|
|
@@ -15142,7 +15254,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
15142
15254
|
p = _params(params)
|
|
15143
15255
|
r = get_resolve()
|
|
15144
15256
|
if r is None:
|
|
15145
|
-
return
|
|
15257
|
+
return _not_connected_error()
|
|
15146
15258
|
pm = r.GetProjectManager()
|
|
15147
15259
|
|
|
15148
15260
|
# cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
|
|
@@ -15180,7 +15292,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
|
|
|
15180
15292
|
p = _params(params)
|
|
15181
15293
|
r = get_resolve()
|
|
15182
15294
|
if r is None:
|
|
15183
|
-
return
|
|
15295
|
+
return _not_connected_error()
|
|
15184
15296
|
pm = r.GetProjectManager()
|
|
15185
15297
|
|
|
15186
15298
|
if action == "get_current":
|
|
@@ -16190,7 +16302,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
16190
16302
|
p = _params(params)
|
|
16191
16303
|
r = get_resolve()
|
|
16192
16304
|
if r is None:
|
|
16193
|
-
return
|
|
16305
|
+
return _not_connected_error()
|
|
16194
16306
|
ms = r.GetMediaStorage()
|
|
16195
16307
|
|
|
16196
16308
|
if action == "get_volumes":
|
|
@@ -18725,10 +18837,137 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
|
|
|
18725
18837
|
row["audio_track_indices"] = audio_indices
|
|
18726
18838
|
except Exception:
|
|
18727
18839
|
row["audio_track_indices"] = []
|
|
18840
|
+
_edit_engine_add_conform_fields(item, row)
|
|
18728
18841
|
rows.append(row)
|
|
18729
18842
|
return rows
|
|
18730
18843
|
|
|
18731
18844
|
|
|
18845
|
+
def _edit_engine_add_conform_fields(item, row: Dict[str, Any]) -> None:
|
|
18846
|
+
"""Extra per-item metadata the conform lint needs.
|
|
18847
|
+
|
|
18848
|
+
Best-effort and individually guarded: a field this API does not expose on a
|
|
18849
|
+
given build must leave the rest of the row intact. Absent fields are
|
|
18850
|
+
reported by the lint as *not checked* rather than silently passing, so a
|
|
18851
|
+
missing value degrades coverage honestly instead of manufacturing a clean
|
|
18852
|
+
bill of health.
|
|
18853
|
+
"""
|
|
18854
|
+
mpi = None
|
|
18855
|
+
try:
|
|
18856
|
+
mpi = item.GetMediaPoolItem()
|
|
18857
|
+
except Exception:
|
|
18858
|
+
mpi = None
|
|
18859
|
+
if mpi is not None:
|
|
18860
|
+
for key, prop in (
|
|
18861
|
+
("source_start_timecode", "Start TC"),
|
|
18862
|
+
("reel_name", "Reel Name"),
|
|
18863
|
+
("clip_fps", "FPS"),
|
|
18864
|
+
):
|
|
18865
|
+
try:
|
|
18866
|
+
value = mpi.GetClipProperty(prop)
|
|
18867
|
+
except Exception:
|
|
18868
|
+
continue
|
|
18869
|
+
if value in (None, ""):
|
|
18870
|
+
continue
|
|
18871
|
+
if key == "clip_fps":
|
|
18872
|
+
try:
|
|
18873
|
+
row[key] = float(value)
|
|
18874
|
+
except (TypeError, ValueError):
|
|
18875
|
+
continue
|
|
18876
|
+
else:
|
|
18877
|
+
row[key] = value
|
|
18878
|
+
try:
|
|
18879
|
+
fusion_names = [c.GetName() for c in (item.GetFusionCompNameList() or [])] \
|
|
18880
|
+
if _has_method(item, "GetFusionCompNameList") else []
|
|
18881
|
+
except Exception:
|
|
18882
|
+
fusion_names = []
|
|
18883
|
+
effects: List[str] = [n for n in fusion_names if n]
|
|
18884
|
+
for getter in ("GetVersionNameList",):
|
|
18885
|
+
if not _has_method(item, getter):
|
|
18886
|
+
continue
|
|
18887
|
+
try:
|
|
18888
|
+
extra = getattr(item, getter)(0) or []
|
|
18889
|
+
effects.extend(str(e) for e in extra if e)
|
|
18890
|
+
except Exception:
|
|
18891
|
+
pass
|
|
18892
|
+
if effects:
|
|
18893
|
+
row["effects"] = effects
|
|
18894
|
+
|
|
18895
|
+
|
|
18896
|
+
def _attach_audit_report(result: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]:
|
|
18897
|
+
"""Give every audit a human-readable form on request (invariant I5).
|
|
18898
|
+
|
|
18899
|
+
Off by default because a rendered report costs tokens on every call and the
|
|
18900
|
+
structured payload is what an agent acts on. But it must always be one
|
|
18901
|
+
parameter away: an audit a human cannot read is an audit they will re-do by
|
|
18902
|
+
hand, which is the cost the tool exists to remove.
|
|
18903
|
+
"""
|
|
18904
|
+
if not isinstance(result, dict) or not result.get("success"):
|
|
18905
|
+
return result
|
|
18906
|
+
result["report_available"] = "pass include_report=true for a Markdown report"
|
|
18907
|
+
flag = params.get("include_report", params.get("includeReport", False))
|
|
18908
|
+
if str(flag).strip().lower() in {"true", "1", "yes", "on"}:
|
|
18909
|
+
try:
|
|
18910
|
+
result["report_markdown"] = _edit_report_mod.render_any(result)
|
|
18911
|
+
except Exception as exc: # a renderer must never break the audit
|
|
18912
|
+
result["report_error"] = f"report rendering failed: {exc}"
|
|
18913
|
+
return result
|
|
18914
|
+
|
|
18915
|
+
|
|
18916
|
+
def _edit_engine_collect_audio_items(tl) -> List[Dict[str, Any]]:
|
|
18917
|
+
"""Audio-track item boundaries, for split-edit analysis.
|
|
18918
|
+
|
|
18919
|
+
Only the boundaries are needed — a split edit is defined by where the audio
|
|
18920
|
+
cut sits relative to the picture cut, not by what is in the clip.
|
|
18921
|
+
"""
|
|
18922
|
+
rows: List[Dict[str, Any]] = []
|
|
18923
|
+
try:
|
|
18924
|
+
track_count = int(tl.GetTrackCount("audio") or 0)
|
|
18925
|
+
except Exception:
|
|
18926
|
+
return rows
|
|
18927
|
+
for index in range(1, track_count + 1):
|
|
18928
|
+
try:
|
|
18929
|
+
items = tl.GetItemListInTrack("audio", index) or []
|
|
18930
|
+
except Exception:
|
|
18931
|
+
continue
|
|
18932
|
+
for item in items:
|
|
18933
|
+
try:
|
|
18934
|
+
rows.append({
|
|
18935
|
+
"track_index": index,
|
|
18936
|
+
"item_name": item.GetName(),
|
|
18937
|
+
"timeline_start_frame": _frame_int(item.GetStart()),
|
|
18938
|
+
"timeline_end_frame": _frame_int(item.GetEnd()),
|
|
18939
|
+
})
|
|
18940
|
+
except Exception:
|
|
18941
|
+
continue
|
|
18942
|
+
return rows
|
|
18943
|
+
|
|
18944
|
+
|
|
18945
|
+
def _edit_engine_marker_beats(tl, fps: float) -> List[float]:
|
|
18946
|
+
"""Timeline markers as story beats, in seconds.
|
|
18947
|
+
|
|
18948
|
+
Markers are where an editor has already said "something happens here", which
|
|
18949
|
+
makes them the best available proxy for a story beat. Absent markers are
|
|
18950
|
+
reported by the rhythm criterion as beats-not-supplied rather than as an
|
|
18951
|
+
absence of beats.
|
|
18952
|
+
"""
|
|
18953
|
+
beats: List[float] = []
|
|
18954
|
+
try:
|
|
18955
|
+
markers = tl.GetMarkers() or {}
|
|
18956
|
+
except Exception:
|
|
18957
|
+
return beats
|
|
18958
|
+
start = 0
|
|
18959
|
+
try:
|
|
18960
|
+
start = int(tl.GetStartFrame() or 0)
|
|
18961
|
+
except Exception:
|
|
18962
|
+
pass
|
|
18963
|
+
for frame in markers:
|
|
18964
|
+
try:
|
|
18965
|
+
beats.append((float(frame) + start) / (fps or 24.0))
|
|
18966
|
+
except (TypeError, ValueError):
|
|
18967
|
+
continue
|
|
18968
|
+
return sorted(beats)
|
|
18969
|
+
|
|
18970
|
+
|
|
18732
18971
|
def _edit_engine_timeline_fps(tl) -> float:
|
|
18733
18972
|
try:
|
|
18734
18973
|
fps = float(tl.GetSetting("timelineFrameRate") or 0)
|
|
@@ -18966,6 +19205,30 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18966
19205
|
return _err("plan file failed its fingerprint check — re-plan")
|
|
18967
19206
|
return {"success": True, "plan": plan}
|
|
18968
19207
|
|
|
19208
|
+
if action == "plan_report":
|
|
19209
|
+
# A 340-entry keep_ranges array is not reviewable, and the rational
|
|
19210
|
+
# response to a machine you cannot audit is to re-check it by hand —
|
|
19211
|
+
# which is the cost the tool was supposed to remove. This renders any
|
|
19212
|
+
# plan as Markdown: what changes, why, what was deliberately left alone,
|
|
19213
|
+
# what could NOT be checked, and what needs a human.
|
|
19214
|
+
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
19215
|
+
if err:
|
|
19216
|
+
return err
|
|
19217
|
+
plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
|
|
19218
|
+
if not plan:
|
|
19219
|
+
return _err("plan not found")
|
|
19220
|
+
if plan.get("_corrupt"):
|
|
19221
|
+
return _err("plan file failed its fingerprint check — re-plan")
|
|
19222
|
+
_rows = p.get("max_detail_rows") if p.get("max_detail_rows") is not None else p.get("maxDetailRows")
|
|
19223
|
+
return {
|
|
19224
|
+
"success": True,
|
|
19225
|
+
"plan_id": plan.get("plan_id"),
|
|
19226
|
+
"summary": _edit_report_mod.summarize_for_chat(plan),
|
|
19227
|
+
"report_markdown": _edit_report_mod.render_plan_report(
|
|
19228
|
+
plan, max_detail_rows=int(_rows) if _rows is not None else 25
|
|
19229
|
+
),
|
|
19230
|
+
}
|
|
19231
|
+
|
|
18969
19232
|
if action == "plan_selects":
|
|
18970
19233
|
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
18971
19234
|
if err:
|
|
@@ -19047,6 +19310,22 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19047
19310
|
min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
|
|
19048
19311
|
)
|
|
19049
19312
|
|
|
19313
|
+
if action == "rank_takes":
|
|
19314
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
19315
|
+
if err:
|
|
19316
|
+
return err
|
|
19317
|
+
refs = p.get("clip_refs") or p.get("clipRefs") or p.get("clip_ids") or []
|
|
19318
|
+
if isinstance(refs, str):
|
|
19319
|
+
refs = [refs]
|
|
19320
|
+
if not isinstance(refs, (list, tuple)) or not refs:
|
|
19321
|
+
return _err("clip_refs is required (a list of clips to compare)",
|
|
19322
|
+
code="MISSING_CLIP_REFS", category="invalid_input")
|
|
19323
|
+
return _edit_engine_mod.rank_takes(
|
|
19324
|
+
project_root,
|
|
19325
|
+
clip_refs=list(refs),
|
|
19326
|
+
script=p.get("script") if isinstance(p.get("script"), str) else None,
|
|
19327
|
+
)
|
|
19328
|
+
|
|
19050
19329
|
if action == "search_spoken_content":
|
|
19051
19330
|
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
19052
19331
|
if err:
|
|
@@ -19090,6 +19369,332 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19090
19369
|
include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
|
|
19091
19370
|
)
|
|
19092
19371
|
|
|
19372
|
+
if action == "rule_of_six_audit":
|
|
19373
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19374
|
+
if err:
|
|
19375
|
+
return err
|
|
19376
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19377
|
+
if not tl:
|
|
19378
|
+
return _err("Timeline not found")
|
|
19379
|
+
_fps = _edit_engine_timeline_fps(tl)
|
|
19380
|
+
return _attach_audit_report(_edit_engine_mod.rule_of_six_audit(
|
|
19381
|
+
items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
|
|
19382
|
+
timeline_name=tl.GetName(),
|
|
19383
|
+
timeline_fps=_fps,
|
|
19384
|
+
story_beats=_edit_engine_marker_beats(tl, _fps),
|
|
19385
|
+
), p)
|
|
19386
|
+
|
|
19387
|
+
if action == "split_edit_audit":
|
|
19388
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19389
|
+
if err:
|
|
19390
|
+
return err
|
|
19391
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19392
|
+
if not tl:
|
|
19393
|
+
return _err("Timeline not found")
|
|
19394
|
+
return _edit_engine_mod.split_edit_audit(
|
|
19395
|
+
video_items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
|
|
19396
|
+
audio_items=_edit_engine_collect_audio_items(tl),
|
|
19397
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
19398
|
+
)
|
|
19399
|
+
|
|
19400
|
+
if action == "sound_density_audit":
|
|
19401
|
+
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
19402
|
+
if err:
|
|
19403
|
+
return err
|
|
19404
|
+
media = p.get("track_media") or p.get("trackMedia") or {}
|
|
19405
|
+
if not isinstance(media, dict):
|
|
19406
|
+
return _err("track_media must be a mapping of {name: path}",
|
|
19407
|
+
code="INVALID_TRACK_MEDIA", category="invalid_input")
|
|
19408
|
+
_limit = p.get("stream_limit") if p.get("stream_limit") is not None else p.get("streamLimit")
|
|
19409
|
+
_dur = p.get("duration_seconds") if p.get("duration_seconds") is not None else p.get("durationSeconds")
|
|
19410
|
+
return _attach_audit_report(_edit_engine_mod.sound_density_audit(
|
|
19411
|
+
track_media={str(k): str(v) for k, v in media.items()},
|
|
19412
|
+
stream_limit=float(_limit) if _limit is not None else None,
|
|
19413
|
+
duration_seconds=float(_dur) if _dur is not None else None,
|
|
19414
|
+
), p)
|
|
19415
|
+
|
|
19416
|
+
if action == "setup_sheet":
|
|
19417
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19418
|
+
if err:
|
|
19419
|
+
return err
|
|
19420
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19421
|
+
if not tl:
|
|
19422
|
+
return _err("Timeline not found")
|
|
19423
|
+
return _attach_audit_report(_edit_engine_mod.setup_sheet(
|
|
19424
|
+
items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
|
|
19425
|
+
timeline_name=tl.GetName(),
|
|
19426
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
19427
|
+
), p)
|
|
19428
|
+
|
|
19429
|
+
if action == "first_impression":
|
|
19430
|
+
# Measures nothing. Captures the one perception that cannot be recovered
|
|
19431
|
+
# once the editor has seen the film too many times to feel it fresh.
|
|
19432
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
19433
|
+
if err:
|
|
19434
|
+
return err
|
|
19435
|
+
sub = str(p.get("op") or p.get("sub_action") or "").strip().lower()
|
|
19436
|
+
log_id = p.get("log_id") or p.get("logId")
|
|
19437
|
+
if sub == "list":
|
|
19438
|
+
return {"success": True, "logs": _first_impression_mod.list_logs(project_root)}
|
|
19439
|
+
if sub in ("start", "record", "lock", "get") and not log_id:
|
|
19440
|
+
return _err("log_id is required", code="MISSING_LOG_ID", category="invalid_input")
|
|
19441
|
+
if sub == "start":
|
|
19442
|
+
return _first_impression_mod.start_pass(
|
|
19443
|
+
project_root,
|
|
19444
|
+
timeline_name=str(p.get("timeline_name") or p.get("timelineName") or ""),
|
|
19445
|
+
log_id=str(log_id),
|
|
19446
|
+
viewer=p.get("viewer"),
|
|
19447
|
+
)
|
|
19448
|
+
if sub == "record":
|
|
19449
|
+
_t = p.get("time_seconds") if p.get("time_seconds") is not None else p.get("timeSeconds")
|
|
19450
|
+
try:
|
|
19451
|
+
return _first_impression_mod.record(
|
|
19452
|
+
project_root, log_id=str(log_id),
|
|
19453
|
+
time_seconds=float(_t or 0.0), text=str(p.get("text") or ""),
|
|
19454
|
+
)
|
|
19455
|
+
except _first_impression_mod.LogLocked as exc:
|
|
19456
|
+
return _err(str(exc), code="LOG_LOCKED", category="invalid_input", retryable=False)
|
|
19457
|
+
if sub == "lock":
|
|
19458
|
+
return _first_impression_mod.lock(project_root, log_id=str(log_id))
|
|
19459
|
+
if sub == "get":
|
|
19460
|
+
log = _first_impression_mod.load(project_root, log_id=str(log_id))
|
|
19461
|
+
return {"success": bool(log), "log": log} if log else _err("log not found")
|
|
19462
|
+
if sub == "diff":
|
|
19463
|
+
first = _first_impression_mod.load(project_root, log_id=str(p.get("first_log_id") or p.get("firstLogId") or ""))
|
|
19464
|
+
later = _first_impression_mod.load(project_root, log_id=str(p.get("later_log_id") or p.get("laterLogId") or ""))
|
|
19465
|
+
if not first or not later:
|
|
19466
|
+
return _err("both first_log_id and later_log_id must name existing logs")
|
|
19467
|
+
return {"success": True, **_first_impression_mod.diff(first, later)}
|
|
19468
|
+
return _err("op must be one of: start, record, lock, get, list, diff",
|
|
19469
|
+
code="UNKNOWN_OP", category="invalid_input")
|
|
19470
|
+
|
|
19471
|
+
if action == "journal":
|
|
19472
|
+
# The paperwork every craft role keeps and every tool skips: ingest log,
|
|
19473
|
+
# accumulating known issues, session prep, handoff, status.
|
|
19474
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
19475
|
+
if err:
|
|
19476
|
+
return err
|
|
19477
|
+
op = str(p.get("op") or p.get("sub_action") or "").strip().lower()
|
|
19478
|
+
if op == "append":
|
|
19479
|
+
return _journal_mod.append(
|
|
19480
|
+
project_root,
|
|
19481
|
+
kind=str(p.get("kind") or "note"),
|
|
19482
|
+
summary=str(p.get("summary") or ""),
|
|
19483
|
+
detail=p.get("detail"),
|
|
19484
|
+
ref=p.get("ref"),
|
|
19485
|
+
data=p.get("data") if isinstance(p.get("data"), dict) else None,
|
|
19486
|
+
timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
19487
|
+
)
|
|
19488
|
+
if op == "read":
|
|
19489
|
+
return {"success": True,
|
|
19490
|
+
"records": _journal_mod.read(project_root, kind=p.get("kind"))}
|
|
19491
|
+
if op == "known_issues":
|
|
19492
|
+
state = _journal_mod.open_issues(project_root)
|
|
19493
|
+
return {"success": True, **state,
|
|
19494
|
+
"report_markdown": _journal_mod.render_known_issues(project_root)}
|
|
19495
|
+
if op == "ingest_log":
|
|
19496
|
+
return {"success": True,
|
|
19497
|
+
"report_markdown": _journal_mod.render_ingest_log(project_root)}
|
|
19498
|
+
if op == "session_prep":
|
|
19499
|
+
_f = lambda k: (float(p[k]) if p.get(k) is not None else None)
|
|
19500
|
+
return {"success": True, "report_markdown": _journal_mod.render_session_prep(
|
|
19501
|
+
project_root,
|
|
19502
|
+
session_kind=str(p.get("session_kind") or "colour"),
|
|
19503
|
+
prep_hours=_f("prep_hours"),
|
|
19504
|
+
estimated_hours_saved=_f("estimated_hours_saved"),
|
|
19505
|
+
hourly_rate=_f("hourly_rate"),
|
|
19506
|
+
ready=p.get("ready") if isinstance(p.get("ready"), list) else None,
|
|
19507
|
+
outstanding=p.get("outstanding") if isinstance(p.get("outstanding"), list) else None,
|
|
19508
|
+
)}
|
|
19509
|
+
if op == "handoff":
|
|
19510
|
+
return {"success": True, "report_markdown": _journal_mod.render_handoff(
|
|
19511
|
+
project=str(p.get("project") or (proj.GetName() if proj else "project")),
|
|
19512
|
+
to=str(p.get("to") or "receiving facility"),
|
|
19513
|
+
timeline_name=p.get("timeline_name") or p.get("timelineName"),
|
|
19514
|
+
technical=p.get("technical") if isinstance(p.get("technical"), dict) else None,
|
|
19515
|
+
included=p.get("included") if isinstance(p.get("included"), list) else None,
|
|
19516
|
+
known_issues=[i.get("summary") for i in _journal_mod.open_issues(project_root)["open"]],
|
|
19517
|
+
notes=p.get("notes"),
|
|
19518
|
+
)}
|
|
19519
|
+
if op == "picture_lock":
|
|
19520
|
+
_r2, proj2, _pr, err2 = _project_context(need_resolve=True)
|
|
19521
|
+
if err2:
|
|
19522
|
+
return err2
|
|
19523
|
+
tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
|
|
19524
|
+
if not tl:
|
|
19525
|
+
return _err("Timeline not found")
|
|
19526
|
+
return _journal_mod.set_picture_lock(
|
|
19527
|
+
project_root, timeline_name=tl.GetName(),
|
|
19528
|
+
items=_edit_engine_collect_items(tl),
|
|
19529
|
+
timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
19530
|
+
)
|
|
19531
|
+
if op == "check_lock":
|
|
19532
|
+
_r2, proj2, _pr, err2 = _project_context(need_resolve=True)
|
|
19533
|
+
if err2:
|
|
19534
|
+
return err2
|
|
19535
|
+
tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
|
|
19536
|
+
if not tl:
|
|
19537
|
+
return _err("Timeline not found")
|
|
19538
|
+
return {"success": True, **_journal_mod.check_picture_lock(
|
|
19539
|
+
project_root, timeline_name=tl.GetName(),
|
|
19540
|
+
items=_edit_engine_collect_items(tl),
|
|
19541
|
+
)}
|
|
19542
|
+
if op == "status":
|
|
19543
|
+
_pc = p.get("percent_complete") if p.get("percent_complete") is not None else p.get("percentComplete")
|
|
19544
|
+
return {"success": True, "report_markdown": _journal_mod.render_status(
|
|
19545
|
+
project=str(p.get("project") or (proj.GetName() if proj else "project")),
|
|
19546
|
+
phase=str(p.get("phase") or "unspecified"),
|
|
19547
|
+
percent_complete=float(_pc) if _pc is not None else None,
|
|
19548
|
+
blockers=p.get("blockers") if isinstance(p.get("blockers"), list) else None,
|
|
19549
|
+
next_milestone=p.get("next_milestone") or p.get("nextMilestone"),
|
|
19550
|
+
open_issue_count=len(_journal_mod.open_issues(project_root)["open"]),
|
|
19551
|
+
)}
|
|
19552
|
+
return _err("op must be one of: append, read, known_issues, ingest_log, "
|
|
19553
|
+
"session_prep, handoff, status, picture_lock, check_lock",
|
|
19554
|
+
code="UNKNOWN_OP", category="invalid_input")
|
|
19555
|
+
|
|
19556
|
+
if action == "plan_reference_match":
|
|
19557
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19558
|
+
if err:
|
|
19559
|
+
return err
|
|
19560
|
+
ref = p.get("reference_media") or p.get("referenceMedia")
|
|
19561
|
+
if not ref:
|
|
19562
|
+
return _err("reference_media is required (path to the graded reference still or clip)",
|
|
19563
|
+
code="MISSING_REFERENCE", category="invalid_input")
|
|
19564
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19565
|
+
if not tl:
|
|
19566
|
+
return _err("Timeline not found")
|
|
19567
|
+
_at = p.get("reference_at_seconds") if p.get("reference_at_seconds") is not None else p.get("referenceAtSeconds")
|
|
19568
|
+
_max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
|
|
19569
|
+
return _edit_engine_mod.plan_reference_match(
|
|
19570
|
+
project_root,
|
|
19571
|
+
reference_media=str(ref),
|
|
19572
|
+
reference_at_seconds=float(_at) if _at is not None else 0.0,
|
|
19573
|
+
items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
|
|
19574
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
19575
|
+
max_items=int(_max) if _max is not None else 200,
|
|
19576
|
+
)
|
|
19577
|
+
|
|
19578
|
+
if action == "plan_string_out":
|
|
19579
|
+
shots = p.get("shots")
|
|
19580
|
+
if not isinstance(shots, list) or not shots:
|
|
19581
|
+
return _err("shots is required: [{name, start_seconds, end_seconds, motion_energy?}]",
|
|
19582
|
+
code="MISSING_SHOTS", category="invalid_input")
|
|
19583
|
+
return _edit_engine_mod.plan_string_out(
|
|
19584
|
+
shots=shots, order=str(p.get("order") or "chronological"))
|
|
19585
|
+
|
|
19586
|
+
if action == "propose_structure":
|
|
19587
|
+
topics = p.get("topics")
|
|
19588
|
+
if not isinstance(topics, list) or not topics:
|
|
19589
|
+
return _err("topics is required: [{label, total_seconds, clip_count}]",
|
|
19590
|
+
code="MISSING_TOPICS", category="invalid_input")
|
|
19591
|
+
return _edit_engine_mod.propose_structure(topics=topics)
|
|
19592
|
+
|
|
19593
|
+
if action == "plan_broll":
|
|
19594
|
+
beats = p.get("beats")
|
|
19595
|
+
candidates = p.get("candidates")
|
|
19596
|
+
if not isinstance(beats, list) or not beats:
|
|
19597
|
+
return _err("beats is required: [{start_seconds, end_seconds, text?, protected?}]",
|
|
19598
|
+
code="MISSING_BEATS", category="invalid_input")
|
|
19599
|
+
if not isinstance(candidates, list) or not candidates:
|
|
19600
|
+
return _err("candidates is required: [{name, duration_seconds, relevance?, beat_index?}]",
|
|
19601
|
+
code="MISSING_CANDIDATES", category="invalid_input")
|
|
19602
|
+
return _edit_engine_mod.plan_broll(
|
|
19603
|
+
beats=beats, candidates=candidates,
|
|
19604
|
+
allow_reuse=str(p.get("allow_reuse", p.get("allowReuse", False))).strip().lower() in {"true", "1", "yes", "on"},
|
|
19605
|
+
)
|
|
19606
|
+
|
|
19607
|
+
if action == "plan_turnover":
|
|
19608
|
+
dests = p.get("destinations") or p.get("destination")
|
|
19609
|
+
if isinstance(dests, str):
|
|
19610
|
+
dests = [dests]
|
|
19611
|
+
if not isinstance(dests, list) or not dests:
|
|
19612
|
+
return _err("destinations is required: any of sound, vfx, color",
|
|
19613
|
+
code="MISSING_DESTINATIONS", category="invalid_input")
|
|
19614
|
+
_hf = p.get("handle_frames") if p.get("handle_frames") is not None else p.get("handleFrames")
|
|
19615
|
+
return _edit_engine_mod.plan_turnover(
|
|
19616
|
+
destinations=dests,
|
|
19617
|
+
contents=p.get("contents") if isinstance(p.get("contents"), dict) else {},
|
|
19618
|
+
version=str(p.get("version") or "v01"),
|
|
19619
|
+
handle_frames=int(_hf) if _hf is not None else None,
|
|
19620
|
+
)
|
|
19621
|
+
|
|
19622
|
+
if action == "conform_lint":
|
|
19623
|
+
# The online editor's checklist, run before turnover instead of
|
|
19624
|
+
# discovered after picture lock in someone else's suite.
|
|
19625
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19626
|
+
if err:
|
|
19627
|
+
return err
|
|
19628
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19629
|
+
if not tl:
|
|
19630
|
+
return _err("Timeline not found")
|
|
19631
|
+
items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
|
|
19632
|
+
return _attach_audit_report(_conform_lint_mod.lint_timeline({
|
|
19633
|
+
"timeline_name": tl.GetName(),
|
|
19634
|
+
"timeline_fps": _edit_engine_timeline_fps(tl),
|
|
19635
|
+
"items": items,
|
|
19636
|
+
}), p)
|
|
19637
|
+
|
|
19638
|
+
if action == "plan_beat_cuts":
|
|
19639
|
+
_r, proj, project_root, err = _project_context(need_resolve=False)
|
|
19640
|
+
if err:
|
|
19641
|
+
return err
|
|
19642
|
+
_fps = p.get("timeline_fps") if p.get("timeline_fps") is not None else p.get("timelineFps")
|
|
19643
|
+
return _edit_engine_mod.plan_beat_cuts(
|
|
19644
|
+
project_root,
|
|
19645
|
+
clip_ref=p.get("clip_ref") or p.get("clipRef"),
|
|
19646
|
+
media_path=p.get("media_path") or p.get("mediaPath"),
|
|
19647
|
+
timeline_fps=float(_fps) if _fps is not None else 24.0,
|
|
19648
|
+
mode=str(p.get("mode") or "phrase"),
|
|
19649
|
+
beats_per_bar=int(p.get("beats_per_bar") or p.get("beatsPerBar") or 4),
|
|
19650
|
+
bars_per_phrase=int(p.get("bars_per_phrase") or p.get("barsPerPhrase") or 8),
|
|
19651
|
+
beat_offset=int(p.get("beat_offset") or p.get("beatOffset") or 0),
|
|
19652
|
+
min_shot_seconds=float(p.get("min_shot_seconds") or p.get("minShotSeconds") or 0.0),
|
|
19653
|
+
)
|
|
19654
|
+
|
|
19655
|
+
if action == "plan_prebalance":
|
|
19656
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19657
|
+
if err:
|
|
19658
|
+
return err
|
|
19659
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19660
|
+
if not tl:
|
|
19661
|
+
return _err("Timeline not found")
|
|
19662
|
+
items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
|
|
19663
|
+
_max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
|
|
19664
|
+
return _edit_engine_mod.plan_prebalance(
|
|
19665
|
+
project_root,
|
|
19666
|
+
items=items,
|
|
19667
|
+
timeline_name=tl.GetName(),
|
|
19668
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
19669
|
+
max_items=int(_max) if _max is not None else 200,
|
|
19670
|
+
)
|
|
19671
|
+
|
|
19672
|
+
if action == "plan_dead_space_markers":
|
|
19673
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19674
|
+
if err:
|
|
19675
|
+
return err
|
|
19676
|
+
tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
|
|
19677
|
+
if not tl:
|
|
19678
|
+
return _err("Timeline not found")
|
|
19679
|
+
items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
|
|
19680
|
+
return _edit_engine_mod.plan_dead_space_markers(
|
|
19681
|
+
project_root,
|
|
19682
|
+
items=items,
|
|
19683
|
+
timeline_name=tl.GetName(),
|
|
19684
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
19685
|
+
# Same calibrate-by-default contract as plan_silence_ripple: what you
|
|
19686
|
+
# review here must be what that would remove.
|
|
19687
|
+
threshold_db=(
|
|
19688
|
+
float(_th)
|
|
19689
|
+
if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
|
|
19690
|
+
else None
|
|
19691
|
+
),
|
|
19692
|
+
tightness=p.get("tightness"),
|
|
19693
|
+
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),
|
|
19694
|
+
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),
|
|
19695
|
+
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),
|
|
19696
|
+
)
|
|
19697
|
+
|
|
19093
19698
|
if action == "plan_swap":
|
|
19094
19699
|
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
19095
19700
|
if err:
|
|
@@ -19662,7 +20267,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19662
20267
|
"plan_tighten",
|
|
19663
20268
|
"execute_tighten",
|
|
19664
20269
|
"plan_silence_ripple",
|
|
20270
|
+
"plan_dead_space_markers",
|
|
20271
|
+
"conform_lint",
|
|
20272
|
+
"rule_of_six_audit",
|
|
20273
|
+
"split_edit_audit",
|
|
20274
|
+
"sound_density_audit",
|
|
20275
|
+
"setup_sheet",
|
|
20276
|
+
"first_impression",
|
|
20277
|
+
"journal",
|
|
20278
|
+
"plan_prebalance",
|
|
20279
|
+
"plan_reference_match",
|
|
20280
|
+
"plan_beat_cuts",
|
|
20281
|
+
"plan_string_out",
|
|
20282
|
+
"propose_structure",
|
|
20283
|
+
"plan_broll",
|
|
20284
|
+
"plan_turnover",
|
|
19665
20285
|
"plan_transcript_tighten",
|
|
20286
|
+
"rank_takes",
|
|
19666
20287
|
"generate_captions",
|
|
19667
20288
|
"search_spoken_content",
|
|
19668
20289
|
"execute_silence_ripple",
|
|
@@ -19670,6 +20291,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19670
20291
|
"execute_swap",
|
|
19671
20292
|
"list_plans",
|
|
19672
20293
|
"get_plan",
|
|
20294
|
+
"plan_report",
|
|
19673
20295
|
])
|
|
19674
20296
|
|
|
19675
20297
|
|
|
@@ -24936,8 +25558,7 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
24936
25558
|
def _execute_lua_script(path: str) -> Dict[str, Any]:
|
|
24937
25559
|
r = get_resolve()
|
|
24938
25560
|
if r is None:
|
|
24939
|
-
return
|
|
24940
|
-
"auto-launch failed.")
|
|
25561
|
+
return _not_connected_error()
|
|
24941
25562
|
fusion = r.Fusion()
|
|
24942
25563
|
if fusion is None:
|
|
24943
25564
|
return _err("handle.Fusion() returned None — cannot run Lua scripts.")
|
|
@@ -25002,7 +25623,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
|
|
|
25002
25623
|
"""
|
|
25003
25624
|
r = get_resolve()
|
|
25004
25625
|
if r is None:
|
|
25005
|
-
return
|
|
25626
|
+
return _not_connected_error()
|
|
25006
25627
|
fusion = r.Fusion()
|
|
25007
25628
|
if fusion is None:
|
|
25008
25629
|
return _err("handle.Fusion() returned None — cannot run inline Lua.")
|
|
@@ -26112,5 +26733,5 @@ if __name__ == "__main__":
|
|
|
26112
26733
|
logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
|
|
26113
26734
|
sys.exit(2)
|
|
26114
26735
|
|
|
26115
|
-
logger.info(
|
|
26736
|
+
logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
|
|
26116
26737
|
run_fastmcp_stdio(mcp)
|