davinci-resolve-mcp 2.44.0 → 2.46.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 +67 -0
- package/README.md +3 -3
- package/docs/SKILL.md +35 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/timeline.py +2 -1
- package/src/server.py +1264 -18
- package/src/utils/analysis_store.py +20 -2
- package/src/utils/destructive_hook.py +5 -0
- package/src/utils/edit_engine.py +646 -0
- package/src/utils/project_lint.py +10 -1
- package/src/utils/project_spec.py +31 -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.46.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -112,6 +112,7 @@ from src.utils.fusion_group_settings import (
|
|
|
112
112
|
)
|
|
113
113
|
from src.utils import analysis_runs as _analysis_runs
|
|
114
114
|
from src.utils import brain_edits as _brain_edits
|
|
115
|
+
from src.utils import edit_engine as _edit_engine_mod
|
|
115
116
|
from src.utils import media_pool_changes as _media_pool_changes
|
|
116
117
|
from src.utils import timeline_versioning as _timeline_versioning
|
|
117
118
|
from src.utils import project_spec as _project_spec
|
|
@@ -782,6 +783,10 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
|
|
|
782
783
|
_TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
|
|
783
784
|
("timeline", "delete_track"),
|
|
784
785
|
("timeline", "apply_cuts"),
|
|
786
|
+
# Phase E edit-engine loops: plan → confirm → execute.
|
|
787
|
+
("edit_engine", "execute_selects"),
|
|
788
|
+
("edit_engine", "execute_tighten"),
|
|
789
|
+
("edit_engine", "execute_swap"),
|
|
785
790
|
("graph", "apply_grade_from_drx"),
|
|
786
791
|
("graph", "reset_all_grades"),
|
|
787
792
|
# 21.0 AI ops that render/generate NEW media files (additive, but expensive
|
|
@@ -1342,6 +1347,46 @@ def _timeline_timecode_to_frame_id(tl, timecode):
|
|
|
1342
1347
|
return _timecode_to_frame_id(timecode, fps)
|
|
1343
1348
|
|
|
1344
1349
|
|
|
1350
|
+
def _marker_rebase_to_timeline_start(tl, frame):
|
|
1351
|
+
"""Rebase an absolute frame to a Timeline marker frameId.
|
|
1352
|
+
|
|
1353
|
+
Timeline.AddMarker frameIds are relative to the timeline start (frame 0 ==
|
|
1354
|
+
first frame of the timeline), while GetCurrentTimecode and the timecodes
|
|
1355
|
+
shown in the Resolve UI are absolute. GetMarkers() echoes back whatever
|
|
1356
|
+
frameId was passed without validating, so a marker stored at an absolute
|
|
1357
|
+
frame displays past the end of the timeline (invisible in the UI).
|
|
1358
|
+
Frames below the timeline start are treated as already relative (elapsed
|
|
1359
|
+
time), and the frame passes through unchanged when the start frame is
|
|
1360
|
+
unavailable.
|
|
1361
|
+
"""
|
|
1362
|
+
start = _timeline_start_frame(tl)
|
|
1363
|
+
if start and frame >= start:
|
|
1364
|
+
return frame - start
|
|
1365
|
+
return frame
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _marker_timecode_to_frame_id(tl, timecode):
|
|
1369
|
+
frame, err = _timeline_timecode_to_frame_id(tl, timecode)
|
|
1370
|
+
if err:
|
|
1371
|
+
return None, err
|
|
1372
|
+
return _marker_rebase_to_timeline_start(tl, frame), None
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _marker_display_frame(tl, frame):
|
|
1376
|
+
"""Absolute frame for driving the playhead at a stored marker frameId.
|
|
1377
|
+
|
|
1378
|
+
Inverse of _marker_rebase_to_timeline_start: stored timeline marker keys
|
|
1379
|
+
are relative to the timeline start, while SetCurrentTimecode is absolute.
|
|
1380
|
+
Frames at or past the start frame are assumed to be legacy absolute keys
|
|
1381
|
+
and pass through unchanged so they still sample the intended frame.
|
|
1382
|
+
"""
|
|
1383
|
+
frame = int(frame)
|
|
1384
|
+
start = _timeline_start_frame(tl)
|
|
1385
|
+
if start and frame < start:
|
|
1386
|
+
return frame + start
|
|
1387
|
+
return frame
|
|
1388
|
+
|
|
1389
|
+
|
|
1345
1390
|
def _frame_id_to_timecode(frame: int, fps: float, separator: str = ":") -> str:
|
|
1346
1391
|
nominal_fps = max(1, int(round(float(fps))))
|
|
1347
1392
|
frame = max(0, int(frame))
|
|
@@ -1359,6 +1404,7 @@ def _timeline_frame_id_to_timecode(tl, frame: int) -> Tuple[Optional[str], Optio
|
|
|
1359
1404
|
|
|
1360
1405
|
|
|
1361
1406
|
def _current_timeline_frame_id(tl):
|
|
1407
|
+
"""Absolute frame at the playhead, matching TimelineItem.GetStart()."""
|
|
1362
1408
|
if tl is None:
|
|
1363
1409
|
return None, _err("current playhead marker requires a timeline")
|
|
1364
1410
|
try:
|
|
@@ -1370,23 +1416,37 @@ def _current_timeline_frame_id(tl):
|
|
|
1370
1416
|
return _timeline_timecode_to_frame_id(tl, timecode)
|
|
1371
1417
|
|
|
1372
1418
|
|
|
1419
|
+
def _current_timeline_marker_frame_id(tl):
|
|
1420
|
+
frame, err = _current_timeline_frame_id(tl)
|
|
1421
|
+
if err:
|
|
1422
|
+
return None, err
|
|
1423
|
+
return _marker_rebase_to_timeline_start(tl, frame), None
|
|
1424
|
+
|
|
1425
|
+
|
|
1373
1426
|
def _marker_frame_from_params(p: Dict[str, Any], tl=None, default_to_current=False):
|
|
1427
|
+
"""Resolve marker frame params to a marker frameId.
|
|
1428
|
+
|
|
1429
|
+
With a timeline, timecodes (and the playhead default) are rebased so the
|
|
1430
|
+
returned frameId is relative to the timeline start. Raw frame numbers are
|
|
1431
|
+
passed through unchanged: timeline marker frames are relative to the
|
|
1432
|
+
timeline start, clip marker frames are relative to the clip start.
|
|
1433
|
+
"""
|
|
1374
1434
|
raw_timecode = _first_param(p, "timecode", "time_code", "tc")
|
|
1375
1435
|
if raw_timecode is not None:
|
|
1376
|
-
return
|
|
1436
|
+
return _marker_timecode_to_frame_id(tl, raw_timecode)
|
|
1377
1437
|
|
|
1378
1438
|
raw_frame = _first_param(p, "frame", "frame_id", "frameId", "frame_num", "frameNum")
|
|
1379
1439
|
if raw_frame is not None:
|
|
1380
1440
|
if isinstance(raw_frame, str):
|
|
1381
1441
|
lowered = raw_frame.strip().lower()
|
|
1382
1442
|
if lowered in {"current", "playhead", "current_playhead", "now"}:
|
|
1383
|
-
return
|
|
1443
|
+
return _current_timeline_marker_frame_id(tl)
|
|
1384
1444
|
if ":" in raw_frame or ";" in raw_frame:
|
|
1385
|
-
return
|
|
1445
|
+
return _marker_timecode_to_frame_id(tl, raw_frame)
|
|
1386
1446
|
return _coerce_marker_number(raw_frame, "frame")
|
|
1387
1447
|
|
|
1388
1448
|
if default_to_current:
|
|
1389
|
-
return
|
|
1449
|
+
return _current_timeline_marker_frame_id(tl)
|
|
1390
1450
|
return None, _err("Missing marker frame. Provide frame, frame_id/frameId, or timecode.")
|
|
1391
1451
|
|
|
1392
1452
|
|
|
@@ -4816,7 +4876,7 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
|
|
|
4816
4876
|
sampled = []
|
|
4817
4877
|
try:
|
|
4818
4878
|
for sample in samples:
|
|
4819
|
-
timecode, tc_err = _timeline_frame_id_to_timecode(tl,
|
|
4879
|
+
timecode, tc_err = _timeline_frame_id_to_timecode(tl, _marker_display_frame(tl, sample["frame"]))
|
|
4820
4880
|
if tc_err:
|
|
4821
4881
|
sample["error"] = tc_err.get("error")
|
|
4822
4882
|
sampled.append(sample)
|
|
@@ -6134,6 +6194,7 @@ _MEDIA_POOL_KERNEL_ACTIONS = [
|
|
|
6134
6194
|
"metadata_field_inventory",
|
|
6135
6195
|
"safe_relink",
|
|
6136
6196
|
"safe_unlink",
|
|
6197
|
+
"check_proxy_media_compatibility",
|
|
6137
6198
|
"link_proxy_checked",
|
|
6138
6199
|
"link_full_resolution_checked",
|
|
6139
6200
|
"set_clip_marks",
|
|
@@ -9469,7 +9530,8 @@ def _media_pool_ingest_capabilities():
|
|
|
9469
9530
|
"relink/unlink through Resolve MediaPool APIs",
|
|
9470
9531
|
"media_pool.safe_relink and safe_unlink with path/clip validation",
|
|
9471
9532
|
"proxy link/unlink through MediaPoolItem APIs",
|
|
9472
|
-
"media_pool.
|
|
9533
|
+
"media_pool.check_proxy_media_compatibility with ffprobe/source signature diagnostics",
|
|
9534
|
+
"media_pool.link_proxy_checked with file validation and optional compatibility guard",
|
|
9473
9535
|
"full-resolution media link where Resolve 20 exposes it",
|
|
9474
9536
|
"media_pool.link_full_resolution_checked with version/path validation",
|
|
9475
9537
|
],
|
|
@@ -9954,6 +10016,466 @@ def _safe_unlink(mp, root, p: Dict[str, Any]):
|
|
|
9954
10016
|
return {"success": bool(mp.UnlinkClips(clips)), "count": len(clips), "missing": missing}
|
|
9955
10017
|
|
|
9956
10018
|
|
|
10019
|
+
def _parse_rate(value):
|
|
10020
|
+
if value in (None, "", "0/0", "N/A"):
|
|
10021
|
+
return None
|
|
10022
|
+
if isinstance(value, (int, float)):
|
|
10023
|
+
return float(value)
|
|
10024
|
+
text = str(value).strip()
|
|
10025
|
+
if not text:
|
|
10026
|
+
return None
|
|
10027
|
+
if "/" in text:
|
|
10028
|
+
left, right = text.split("/", 1)
|
|
10029
|
+
try:
|
|
10030
|
+
numerator = float(left.strip())
|
|
10031
|
+
denominator = float(right.strip())
|
|
10032
|
+
return numerator / denominator if denominator else None
|
|
10033
|
+
except (TypeError, ValueError):
|
|
10034
|
+
return None
|
|
10035
|
+
match = re.search(r"\d+(?:\.\d+)?", text.replace(",", "."))
|
|
10036
|
+
return float(match.group(0)) if match else None
|
|
10037
|
+
|
|
10038
|
+
|
|
10039
|
+
def _parse_int_text(value):
|
|
10040
|
+
if value in (None, "", "N/A"):
|
|
10041
|
+
return None
|
|
10042
|
+
if isinstance(value, bool):
|
|
10043
|
+
return None
|
|
10044
|
+
if isinstance(value, int):
|
|
10045
|
+
return value
|
|
10046
|
+
if isinstance(value, float):
|
|
10047
|
+
return int(value)
|
|
10048
|
+
match = re.search(r"\d+", str(value).replace(",", ""))
|
|
10049
|
+
return int(match.group(0)) if match else None
|
|
10050
|
+
|
|
10051
|
+
|
|
10052
|
+
def _parse_resolution(value):
|
|
10053
|
+
if value in (None, "", "N/A"):
|
|
10054
|
+
return None
|
|
10055
|
+
if isinstance(value, (list, tuple)) and len(value) >= 2:
|
|
10056
|
+
width = _parse_int_text(value[0])
|
|
10057
|
+
height = _parse_int_text(value[1])
|
|
10058
|
+
return (width, height) if width and height else None
|
|
10059
|
+
match = re.search(r"(\d+)\s*[xX]\s*(\d+)", str(value))
|
|
10060
|
+
if not match:
|
|
10061
|
+
return None
|
|
10062
|
+
return int(match.group(1)), int(match.group(2))
|
|
10063
|
+
|
|
10064
|
+
|
|
10065
|
+
def _normalize_media_token(value):
|
|
10066
|
+
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
|
|
10067
|
+
|
|
10068
|
+
|
|
10069
|
+
def _media_token_matches(actual, expected):
|
|
10070
|
+
expected_token = _normalize_media_token(expected)
|
|
10071
|
+
if not expected_token:
|
|
10072
|
+
return True
|
|
10073
|
+
actual_token = _normalize_media_token(actual)
|
|
10074
|
+
return bool(actual_token) and (
|
|
10075
|
+
actual_token == expected_token
|
|
10076
|
+
or expected_token in actual_token
|
|
10077
|
+
or actual_token in expected_token
|
|
10078
|
+
)
|
|
10079
|
+
|
|
10080
|
+
|
|
10081
|
+
def _clip_media_signature(clip):
|
|
10082
|
+
properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "")
|
|
10083
|
+
properties = properties if isinstance(properties, dict) else {}
|
|
10084
|
+
resolution = _parse_resolution(
|
|
10085
|
+
properties.get("Resolution")
|
|
10086
|
+
or properties.get("Video Resolution")
|
|
10087
|
+
or properties.get("Image Size")
|
|
10088
|
+
)
|
|
10089
|
+
signature = {
|
|
10090
|
+
"name": _safe_media_pool_item_name(clip),
|
|
10091
|
+
"id": _safe_media_pool_item_id(clip),
|
|
10092
|
+
"file_path": properties.get("File Path") or properties.get("FilePath"),
|
|
10093
|
+
"resolution": {"width": resolution[0], "height": resolution[1]} if resolution else None,
|
|
10094
|
+
"fps": _parse_rate(
|
|
10095
|
+
properties.get("FPS")
|
|
10096
|
+
or properties.get("Frame Rate")
|
|
10097
|
+
or properties.get("Video Frame Rate")
|
|
10098
|
+
),
|
|
10099
|
+
"frames": _parse_int_text(
|
|
10100
|
+
properties.get("Frames")
|
|
10101
|
+
or properties.get("Frame Count")
|
|
10102
|
+
or properties.get("Duration Frames")
|
|
10103
|
+
),
|
|
10104
|
+
"sample_rate": _parse_int_text(
|
|
10105
|
+
properties.get("Sample Rate")
|
|
10106
|
+
or properties.get("Audio Sample Rate")
|
|
10107
|
+
),
|
|
10108
|
+
}
|
|
10109
|
+
if properties_error:
|
|
10110
|
+
signature["properties_error"] = properties_error
|
|
10111
|
+
return signature
|
|
10112
|
+
|
|
10113
|
+
|
|
10114
|
+
def _probe_media_file(path: str):
|
|
10115
|
+
ffprobe = shutil.which("ffprobe")
|
|
10116
|
+
if not ffprobe:
|
|
10117
|
+
return {
|
|
10118
|
+
"success": False,
|
|
10119
|
+
"code": "FFPROBE_MISSING",
|
|
10120
|
+
"error": "ffprobe is not installed or not on PATH",
|
|
10121
|
+
"path": path,
|
|
10122
|
+
}
|
|
10123
|
+
try:
|
|
10124
|
+
proc = subprocess.run(
|
|
10125
|
+
[
|
|
10126
|
+
ffprobe,
|
|
10127
|
+
"-v",
|
|
10128
|
+
"error",
|
|
10129
|
+
"-show_streams",
|
|
10130
|
+
"-of",
|
|
10131
|
+
"json",
|
|
10132
|
+
path,
|
|
10133
|
+
],
|
|
10134
|
+
capture_output=True,
|
|
10135
|
+
encoding="utf-8",
|
|
10136
|
+
errors="replace",
|
|
10137
|
+
timeout=20,
|
|
10138
|
+
stdin=subprocess.DEVNULL,
|
|
10139
|
+
)
|
|
10140
|
+
except subprocess.TimeoutExpired:
|
|
10141
|
+
return {"success": False, "code": "FFPROBE_TIMEOUT", "error": "ffprobe timed out", "path": path}
|
|
10142
|
+
except Exception as exc:
|
|
10143
|
+
return {"success": False, "code": "FFPROBE_FAILED", "error": str(exc), "path": path}
|
|
10144
|
+
if proc.returncode != 0:
|
|
10145
|
+
return {
|
|
10146
|
+
"success": False,
|
|
10147
|
+
"code": "FFPROBE_FAILED",
|
|
10148
|
+
"returncode": proc.returncode,
|
|
10149
|
+
"error": (proc.stderr or proc.stdout or "").strip(),
|
|
10150
|
+
"path": path,
|
|
10151
|
+
}
|
|
10152
|
+
try:
|
|
10153
|
+
payload = json.loads(proc.stdout or "{}")
|
|
10154
|
+
except json.JSONDecodeError as exc:
|
|
10155
|
+
return {"success": False, "code": "FFPROBE_JSON_ERROR", "error": str(exc), "path": path}
|
|
10156
|
+
streams = payload.get("streams") if isinstance(payload, dict) else []
|
|
10157
|
+
streams = streams if isinstance(streams, list) else []
|
|
10158
|
+
video = next((stream for stream in streams if stream.get("codec_type") == "video"), {})
|
|
10159
|
+
audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), {})
|
|
10160
|
+
return {"success": True, "path": path, "video": video, "audio": audio, "stream_count": len(streams)}
|
|
10161
|
+
|
|
10162
|
+
|
|
10163
|
+
def _proxy_media_signature(probe: Dict[str, Any]):
|
|
10164
|
+
video = probe.get("video") if isinstance(probe.get("video"), dict) else {}
|
|
10165
|
+
audio = probe.get("audio") if isinstance(probe.get("audio"), dict) else {}
|
|
10166
|
+
width = _parse_int_text(video.get("width"))
|
|
10167
|
+
height = _parse_int_text(video.get("height"))
|
|
10168
|
+
return {
|
|
10169
|
+
"path": probe.get("path"),
|
|
10170
|
+
"codec": video.get("codec_name"),
|
|
10171
|
+
"codec_long_name": video.get("codec_long_name"),
|
|
10172
|
+
"profile": video.get("profile"),
|
|
10173
|
+
"resolution": {"width": width, "height": height} if width and height else None,
|
|
10174
|
+
"fps": _parse_rate(video.get("avg_frame_rate") or video.get("r_frame_rate")),
|
|
10175
|
+
"frames": _parse_int_text(video.get("nb_frames") or (video.get("tags") or {}).get("NUMBER_OF_FRAMES")),
|
|
10176
|
+
"sample_rate": _parse_int_text(audio.get("sample_rate")),
|
|
10177
|
+
"channels": _parse_int_text(audio.get("channels")),
|
|
10178
|
+
}
|
|
10179
|
+
|
|
10180
|
+
|
|
10181
|
+
def _add_proxy_resolution_mismatch(result: Dict[str, Any], source, proxy):
|
|
10182
|
+
"""Flag resolution only when the aspect ratio differs.
|
|
10183
|
+
|
|
10184
|
+
Proxies are routinely lower resolution than the source — Resolve links a
|
|
10185
|
+
half/quarter-res proxy happily (that is the point of proxies; verified live
|
|
10186
|
+
on Resolve Studio 21). Only a different aspect ratio means the file cannot
|
|
10187
|
+
be a proxy of this source.
|
|
10188
|
+
"""
|
|
10189
|
+
if not source or not proxy:
|
|
10190
|
+
return
|
|
10191
|
+
src_w, src_h = source.get("width"), source.get("height")
|
|
10192
|
+
pxy_w, pxy_h = proxy.get("width"), proxy.get("height")
|
|
10193
|
+
if not (src_w and src_h and pxy_w and pxy_h):
|
|
10194
|
+
return
|
|
10195
|
+
if (src_w, src_h) == (pxy_w, pxy_h):
|
|
10196
|
+
return
|
|
10197
|
+
src_aspect = src_w / src_h
|
|
10198
|
+
pxy_aspect = pxy_w / pxy_h
|
|
10199
|
+
if abs(src_aspect - pxy_aspect) / src_aspect <= 0.02:
|
|
10200
|
+
result["warnings"].append(
|
|
10201
|
+
f"Proxy is a same-aspect downscale ({src_w}x{src_h} -> {pxy_w}x{pxy_h})"
|
|
10202
|
+
)
|
|
10203
|
+
return
|
|
10204
|
+
result["mismatches"].append({"field": "resolution", "source": source, "proxy": proxy})
|
|
10205
|
+
|
|
10206
|
+
|
|
10207
|
+
def _add_proxy_mismatch(mismatches: List[Dict[str, Any]], field: str, source, proxy, *, tolerance: float = 0.0):
|
|
10208
|
+
if source is None or proxy is None:
|
|
10209
|
+
return
|
|
10210
|
+
if isinstance(source, (int, float)) and isinstance(proxy, (int, float)):
|
|
10211
|
+
if abs(float(source) - float(proxy)) <= tolerance:
|
|
10212
|
+
return
|
|
10213
|
+
elif source == proxy:
|
|
10214
|
+
return
|
|
10215
|
+
mismatches.append({"field": field, "source": source, "proxy": proxy})
|
|
10216
|
+
|
|
10217
|
+
|
|
10218
|
+
def _check_proxy_media_compatibility(
|
|
10219
|
+
clip,
|
|
10220
|
+
proxy_path: str,
|
|
10221
|
+
*,
|
|
10222
|
+
expected_codec: Optional[str] = None,
|
|
10223
|
+
expected_profile: Optional[str] = None,
|
|
10224
|
+
):
|
|
10225
|
+
source = _clip_media_signature(clip)
|
|
10226
|
+
probe = _probe_media_file(proxy_path)
|
|
10227
|
+
proxy = _proxy_media_signature(probe) if probe.get("success") else {"path": proxy_path}
|
|
10228
|
+
result = {
|
|
10229
|
+
"success": bool(probe.get("success")),
|
|
10230
|
+
"compatible": False,
|
|
10231
|
+
"source": source,
|
|
10232
|
+
"proxy": proxy,
|
|
10233
|
+
"mismatches": [],
|
|
10234
|
+
"warnings": [],
|
|
10235
|
+
}
|
|
10236
|
+
if not probe.get("success"):
|
|
10237
|
+
result["error"] = probe.get("error", "Unable to probe proxy media")
|
|
10238
|
+
result["code"] = probe.get("code", "FFPROBE_FAILED")
|
|
10239
|
+
return result
|
|
10240
|
+
|
|
10241
|
+
_add_proxy_resolution_mismatch(result, source.get("resolution"), proxy.get("resolution"))
|
|
10242
|
+
_add_proxy_mismatch(result["mismatches"], "fps", source.get("fps"), proxy.get("fps"), tolerance=0.01)
|
|
10243
|
+
_add_proxy_mismatch(result["mismatches"], "frames", source.get("frames"), proxy.get("frames"), tolerance=1)
|
|
10244
|
+
_add_proxy_mismatch(result["mismatches"], "sample_rate", source.get("sample_rate"), proxy.get("sample_rate"))
|
|
10245
|
+
|
|
10246
|
+
if expected_codec and not (
|
|
10247
|
+
_media_token_matches(proxy.get("codec"), expected_codec)
|
|
10248
|
+
or _media_token_matches(proxy.get("codec_long_name"), expected_codec)
|
|
10249
|
+
):
|
|
10250
|
+
result["mismatches"].append({
|
|
10251
|
+
"field": "expected_codec",
|
|
10252
|
+
"expected": expected_codec,
|
|
10253
|
+
"proxy": proxy.get("codec") or proxy.get("codec_long_name"),
|
|
10254
|
+
})
|
|
10255
|
+
if expected_profile and not _media_token_matches(proxy.get("profile"), expected_profile):
|
|
10256
|
+
result["mismatches"].append({
|
|
10257
|
+
"field": "expected_profile",
|
|
10258
|
+
"expected": expected_profile,
|
|
10259
|
+
"proxy": proxy.get("profile"),
|
|
10260
|
+
})
|
|
10261
|
+
|
|
10262
|
+
result["compatible"] = not result["mismatches"]
|
|
10263
|
+
if source.get("resolution") is None:
|
|
10264
|
+
result["warnings"].append("Source clip resolution was not available from GetClipProperty")
|
|
10265
|
+
if source.get("fps") is None:
|
|
10266
|
+
result["warnings"].append("Source clip FPS was not available from GetClipProperty")
|
|
10267
|
+
if proxy.get("frames") is None:
|
|
10268
|
+
result["warnings"].append("Proxy frame count was not available from ffprobe")
|
|
10269
|
+
return result
|
|
10270
|
+
|
|
10271
|
+
|
|
10272
|
+
def _proxy_link_readback(clip):
|
|
10273
|
+
properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "")
|
|
10274
|
+
properties = properties if isinstance(properties, dict) else {}
|
|
10275
|
+
readback = {"clip_properties": properties}
|
|
10276
|
+
if properties_error:
|
|
10277
|
+
readback["properties_error"] = properties_error
|
|
10278
|
+
proxy_keys = [
|
|
10279
|
+
"Proxy",
|
|
10280
|
+
"Proxy Media",
|
|
10281
|
+
"Proxy Media Path",
|
|
10282
|
+
"Proxy Path",
|
|
10283
|
+
"Proxy Status",
|
|
10284
|
+
"ProxyMediaPath",
|
|
10285
|
+
]
|
|
10286
|
+
observed = {key: properties.get(key) for key in proxy_keys if key in properties}
|
|
10287
|
+
if observed:
|
|
10288
|
+
readback["proxy_fields"] = observed
|
|
10289
|
+
return readback
|
|
10290
|
+
|
|
10291
|
+
|
|
10292
|
+
def _normalized_path_for_compare(path):
|
|
10293
|
+
if not path or not isinstance(path, str):
|
|
10294
|
+
return None
|
|
10295
|
+
return os.path.normcase(os.path.abspath(os.path.expanduser(path)))
|
|
10296
|
+
|
|
10297
|
+
|
|
10298
|
+
def _proxy_readback_matches_path(readback: Dict[str, Any], proxy_path: str):
|
|
10299
|
+
expected = _normalized_path_for_compare(proxy_path)
|
|
10300
|
+
if not expected:
|
|
10301
|
+
return False
|
|
10302
|
+
proxy_fields = readback.get("proxy_fields") if isinstance(readback, dict) else {}
|
|
10303
|
+
proxy_fields = proxy_fields if isinstance(proxy_fields, dict) else {}
|
|
10304
|
+
for value in proxy_fields.values():
|
|
10305
|
+
if _normalized_path_for_compare(value) == expected:
|
|
10306
|
+
return True
|
|
10307
|
+
return False
|
|
10308
|
+
|
|
10309
|
+
|
|
10310
|
+
def _proxy_link_journal_event(event_type: str, clip, proxy_path: str, *, success=None, verified=False, compatibility=None):
|
|
10311
|
+
event = {
|
|
10312
|
+
"type": event_type,
|
|
10313
|
+
"clip_id": _safe_media_pool_item_id(clip),
|
|
10314
|
+
"clip_name": _safe_media_pool_item_name(clip),
|
|
10315
|
+
"proxy_path": proxy_path,
|
|
10316
|
+
"success": success,
|
|
10317
|
+
"verified": bool(verified),
|
|
10318
|
+
}
|
|
10319
|
+
if compatibility is not None:
|
|
10320
|
+
event["compatible"] = bool(compatibility.get("compatible"))
|
|
10321
|
+
if compatibility.get("mismatches"):
|
|
10322
|
+
event["mismatch_fields"] = [row.get("field") for row in compatibility.get("mismatches", [])]
|
|
10323
|
+
return event
|
|
10324
|
+
|
|
10325
|
+
|
|
10326
|
+
def _verified_operation(
|
|
10327
|
+
name: str,
|
|
10328
|
+
*,
|
|
10329
|
+
requested: Dict[str, Any],
|
|
10330
|
+
preflight: Dict[str, Any],
|
|
10331
|
+
execution: Dict[str, Any],
|
|
10332
|
+
verification_status: str,
|
|
10333
|
+
readback=None,
|
|
10334
|
+
journal_event=None,
|
|
10335
|
+
):
|
|
10336
|
+
return {
|
|
10337
|
+
"name": name,
|
|
10338
|
+
"preflight": preflight,
|
|
10339
|
+
"requested": requested,
|
|
10340
|
+
"execution": execution,
|
|
10341
|
+
"readback": readback,
|
|
10342
|
+
"verified": verification_status == "readback_verified",
|
|
10343
|
+
"verification_status": verification_status,
|
|
10344
|
+
"journal_event": journal_event,
|
|
10345
|
+
}
|
|
10346
|
+
|
|
10347
|
+
|
|
10348
|
+
def _check_proxy_media_compatibility_checked(root, p: Dict[str, Any]):
|
|
10349
|
+
clip = _find_clip(root, p.get("clip_id", ""))
|
|
10350
|
+
if not clip:
|
|
10351
|
+
return _err(f"Clip not found: {p.get('clip_id')}")
|
|
10352
|
+
proxy_path = p.get("proxy_path") or p.get("path")
|
|
10353
|
+
path_err = _path_error(proxy_path, must_be_file=True)
|
|
10354
|
+
if path_err:
|
|
10355
|
+
return _err(path_err)
|
|
10356
|
+
return _check_proxy_media_compatibility(
|
|
10357
|
+
clip,
|
|
10358
|
+
proxy_path,
|
|
10359
|
+
expected_codec=p.get("expected_codec") or p.get("codec"),
|
|
10360
|
+
expected_profile=p.get("expected_profile") or p.get("profile"),
|
|
10361
|
+
)
|
|
10362
|
+
|
|
10363
|
+
|
|
10364
|
+
def _timeline_append_readback_snapshot(proj):
|
|
10365
|
+
try:
|
|
10366
|
+
tl = proj.GetCurrentTimeline() if proj else None
|
|
10367
|
+
except Exception as exc:
|
|
10368
|
+
return {"available": False, "error": f"GetCurrentTimeline failed: {exc}"}
|
|
10369
|
+
if not tl:
|
|
10370
|
+
return {"available": False, "error": "No current timeline"}
|
|
10371
|
+
|
|
10372
|
+
timeline = {}
|
|
10373
|
+
try:
|
|
10374
|
+
timeline["name"] = tl.GetName()
|
|
10375
|
+
except Exception:
|
|
10376
|
+
timeline["name"] = None
|
|
10377
|
+
try:
|
|
10378
|
+
timeline["id"] = tl.GetUniqueId()
|
|
10379
|
+
except Exception:
|
|
10380
|
+
timeline["id"] = None
|
|
10381
|
+
|
|
10382
|
+
tracks = {}
|
|
10383
|
+
item_count = 0
|
|
10384
|
+
warnings = []
|
|
10385
|
+
for track_type in ("video", "audio", "subtitle"):
|
|
10386
|
+
track_count = _timeline_track_count(tl, track_type)
|
|
10387
|
+
rows = []
|
|
10388
|
+
for track_index in range(1, track_count + 1):
|
|
10389
|
+
try:
|
|
10390
|
+
items = tl.GetItemListInTrack(track_type, track_index) or []
|
|
10391
|
+
except Exception as exc:
|
|
10392
|
+
warnings.append(f"GetItemListInTrack({track_type}, {track_index}) failed: {exc}")
|
|
10393
|
+
items = []
|
|
10394
|
+
item_count += len(items)
|
|
10395
|
+
rows.append({
|
|
10396
|
+
"track_index": track_index,
|
|
10397
|
+
"item_count": len(items),
|
|
10398
|
+
"item_ids": _timeline_item_ids(items),
|
|
10399
|
+
})
|
|
10400
|
+
tracks[track_type] = {"track_count": track_count, "tracks": rows}
|
|
10401
|
+
return {
|
|
10402
|
+
"available": True,
|
|
10403
|
+
"timeline": timeline,
|
|
10404
|
+
"item_count": item_count,
|
|
10405
|
+
"tracks": tracks,
|
|
10406
|
+
"warnings": warnings,
|
|
10407
|
+
}
|
|
10408
|
+
|
|
10409
|
+
|
|
10410
|
+
def _compare_timeline_append_readback(before, observed, expected_count: int):
|
|
10411
|
+
before_count = before.get("item_count") if isinstance(before, dict) and before.get("available") else None
|
|
10412
|
+
after_count = observed.get("item_count") if isinstance(observed, dict) and observed.get("available") else None
|
|
10413
|
+
delta = after_count - before_count if before_count is not None and after_count is not None else None
|
|
10414
|
+
return {
|
|
10415
|
+
"verified": delta is not None and delta >= expected_count and expected_count > 0,
|
|
10416
|
+
"before_item_count": before_count,
|
|
10417
|
+
"after_item_count": after_count,
|
|
10418
|
+
"item_count_delta": delta,
|
|
10419
|
+
"expected_item_count_delta": expected_count,
|
|
10420
|
+
}
|
|
10421
|
+
|
|
10422
|
+
|
|
10423
|
+
def _append_to_timeline_with_verification(proj, mp, append_payload, requested: Dict[str, Any], expected_count: int):
|
|
10424
|
+
raw_result = {"value": None}
|
|
10425
|
+
before = _timeline_append_readback_snapshot(proj)
|
|
10426
|
+
|
|
10427
|
+
def mutate():
|
|
10428
|
+
raw_result["value"] = mp.AppendToTimeline(append_payload)
|
|
10429
|
+
return bool(raw_result["value"])
|
|
10430
|
+
|
|
10431
|
+
verification = verify_by_readback(
|
|
10432
|
+
mutate=mutate,
|
|
10433
|
+
observe=lambda: _timeline_append_readback_snapshot(proj),
|
|
10434
|
+
snapshot=lambda: before,
|
|
10435
|
+
compare=lambda before_snapshot, observed: _compare_timeline_append_readback(
|
|
10436
|
+
before_snapshot,
|
|
10437
|
+
observed,
|
|
10438
|
+
expected_count,
|
|
10439
|
+
),
|
|
10440
|
+
intent=requested,
|
|
10441
|
+
label="media_pool.append_to_timeline",
|
|
10442
|
+
)
|
|
10443
|
+
return raw_result["value"], verification, before
|
|
10444
|
+
|
|
10445
|
+
|
|
10446
|
+
def _append_to_timeline_verified_operation(requested: Dict[str, Any], verification: Dict[str, Any], before):
|
|
10447
|
+
success = bool(verification.get("success_raw"))
|
|
10448
|
+
verified = bool(verification.get("verified"))
|
|
10449
|
+
verification_status = "readback_verified" if verified else ("api_failed" if not success else "api_success_unverified")
|
|
10450
|
+
readback = {
|
|
10451
|
+
"before": before,
|
|
10452
|
+
"after": verification.get("observed"),
|
|
10453
|
+
"before_item_count": verification.get("before_item_count"),
|
|
10454
|
+
"after_item_count": verification.get("after_item_count"),
|
|
10455
|
+
"item_count_delta": verification.get("item_count_delta"),
|
|
10456
|
+
"expected_item_count_delta": verification.get("expected_item_count_delta"),
|
|
10457
|
+
}
|
|
10458
|
+
return _verified_operation(
|
|
10459
|
+
"media_pool.append_to_timeline",
|
|
10460
|
+
requested=requested,
|
|
10461
|
+
preflight={
|
|
10462
|
+
"ok": True,
|
|
10463
|
+
"current_timeline_available": bool(isinstance(before, dict) and before.get("available")),
|
|
10464
|
+
"expected_item_count_delta": requested.get("expected_count"),
|
|
10465
|
+
},
|
|
10466
|
+
execution={"api": "MediaPool.AppendToTimeline", "attempted": True, "success": success},
|
|
10467
|
+
readback=readback,
|
|
10468
|
+
verification_status=verification_status,
|
|
10469
|
+
journal_event={
|
|
10470
|
+
"type": "timeline.appended" if verified else ("timeline.append.failed" if not success else "timeline.append.unverified"),
|
|
10471
|
+
"success": success,
|
|
10472
|
+
"verified": verified,
|
|
10473
|
+
"expected_count": requested.get("expected_count"),
|
|
10474
|
+
"item_count_delta": verification.get("item_count_delta"),
|
|
10475
|
+
},
|
|
10476
|
+
)
|
|
10477
|
+
|
|
10478
|
+
|
|
9957
10479
|
def _link_proxy_checked(root, p: Dict[str, Any]):
|
|
9958
10480
|
clip = _find_clip(root, p.get("clip_id", ""))
|
|
9959
10481
|
if not clip:
|
|
@@ -9962,9 +10484,123 @@ def _link_proxy_checked(root, p: Dict[str, Any]):
|
|
|
9962
10484
|
path_err = _path_error(proxy_path, must_be_file=True)
|
|
9963
10485
|
if path_err:
|
|
9964
10486
|
return _err(path_err)
|
|
10487
|
+
missing = _requires_method(clip, "LinkProxyMedia", "17.0")
|
|
10488
|
+
if missing:
|
|
10489
|
+
return missing
|
|
10490
|
+
check_compatibility = bool(
|
|
10491
|
+
p.get("check_compatibility")
|
|
10492
|
+
or p.get("require_compatible")
|
|
10493
|
+
or p.get("expected_codec")
|
|
10494
|
+
or p.get("expected_profile")
|
|
10495
|
+
or p.get("codec")
|
|
10496
|
+
or p.get("profile")
|
|
10497
|
+
)
|
|
10498
|
+
compatibility = None
|
|
10499
|
+
if check_compatibility:
|
|
10500
|
+
compatibility = _check_proxy_media_compatibility(
|
|
10501
|
+
clip,
|
|
10502
|
+
proxy_path,
|
|
10503
|
+
expected_codec=p.get("expected_codec") or p.get("codec"),
|
|
10504
|
+
expected_profile=p.get("expected_profile") or p.get("profile"),
|
|
10505
|
+
)
|
|
10506
|
+
requested = {
|
|
10507
|
+
"clip_id": p.get("clip_id"),
|
|
10508
|
+
"proxy_path": proxy_path,
|
|
10509
|
+
"dry_run": bool(p.get("dry_run")),
|
|
10510
|
+
"check_compatibility": check_compatibility,
|
|
10511
|
+
"require_compatible": bool(p.get("require_compatible")),
|
|
10512
|
+
"expected_codec": p.get("expected_codec") or p.get("codec"),
|
|
10513
|
+
"expected_profile": p.get("expected_profile") or p.get("profile"),
|
|
10514
|
+
}
|
|
10515
|
+
preflight = {
|
|
10516
|
+
"ok": True,
|
|
10517
|
+
"clip_found": True,
|
|
10518
|
+
"proxy_path_valid": True,
|
|
10519
|
+
"method_available": True,
|
|
10520
|
+
"compatibility_checked": compatibility is not None,
|
|
10521
|
+
}
|
|
10522
|
+
if compatibility is not None:
|
|
10523
|
+
preflight["compatible"] = bool(compatibility.get("compatible"))
|
|
9965
10524
|
if p.get("dry_run"):
|
|
9966
|
-
|
|
9967
|
-
|
|
10525
|
+
out = _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path)
|
|
10526
|
+
if compatibility is not None:
|
|
10527
|
+
out["compatibility"] = compatibility
|
|
10528
|
+
out["verified_operation"] = _verified_operation(
|
|
10529
|
+
"media_pool.link_proxy_checked",
|
|
10530
|
+
requested=requested,
|
|
10531
|
+
preflight=preflight,
|
|
10532
|
+
execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": False, "success": None},
|
|
10533
|
+
readback=None,
|
|
10534
|
+
verification_status="dry_run",
|
|
10535
|
+
journal_event=_proxy_link_journal_event(
|
|
10536
|
+
"proxy.link.preview",
|
|
10537
|
+
clip,
|
|
10538
|
+
proxy_path,
|
|
10539
|
+
success=None,
|
|
10540
|
+
verified=False,
|
|
10541
|
+
compatibility=compatibility,
|
|
10542
|
+
),
|
|
10543
|
+
)
|
|
10544
|
+
return out
|
|
10545
|
+
if p.get("require_compatible") and compatibility is not None and not compatibility.get("compatible"):
|
|
10546
|
+
out = _err(
|
|
10547
|
+
"Proxy media is not compatible with the source clip; refusing LinkProxyMedia",
|
|
10548
|
+
code="PROXY_INCOMPATIBLE",
|
|
10549
|
+
category="invalid_input",
|
|
10550
|
+
remediation="Use a proxy with matching resolution, FPS, frame count, sample rate, and requested codec/profile.",
|
|
10551
|
+
)
|
|
10552
|
+
out["compatibility"] = compatibility
|
|
10553
|
+
out["verified_operation"] = _verified_operation(
|
|
10554
|
+
"media_pool.link_proxy_checked",
|
|
10555
|
+
requested=requested,
|
|
10556
|
+
preflight={**preflight, "ok": False},
|
|
10557
|
+
execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": False, "success": None},
|
|
10558
|
+
readback=None,
|
|
10559
|
+
verification_status="blocked",
|
|
10560
|
+
journal_event=_proxy_link_journal_event(
|
|
10561
|
+
"proxy.link.blocked",
|
|
10562
|
+
clip,
|
|
10563
|
+
proxy_path,
|
|
10564
|
+
success=False,
|
|
10565
|
+
verified=False,
|
|
10566
|
+
compatibility=compatibility,
|
|
10567
|
+
),
|
|
10568
|
+
)
|
|
10569
|
+
return out
|
|
10570
|
+
verification = verify_by_readback(
|
|
10571
|
+
mutate=lambda: clip.LinkProxyMedia(proxy_path),
|
|
10572
|
+
observe=lambda: _proxy_link_readback(clip),
|
|
10573
|
+
compare=lambda _before, observed: {
|
|
10574
|
+
"verified": _proxy_readback_matches_path(observed, proxy_path),
|
|
10575
|
+
},
|
|
10576
|
+
intent={"clip_id": p.get("clip_id"), "proxy_path": proxy_path},
|
|
10577
|
+
label="media_pool.link_proxy_checked",
|
|
10578
|
+
)
|
|
10579
|
+
success = bool(verification.get("success_raw"))
|
|
10580
|
+
readback = verification.get("observed")
|
|
10581
|
+
verified = bool(verification.get("verified"))
|
|
10582
|
+
verification_status = "readback_verified" if verified else ("api_failed" if not success else "api_success_unverified")
|
|
10583
|
+
event_type = "proxy.linked" if success else "proxy.link.failed"
|
|
10584
|
+
out = {"success": success, "readback": readback}
|
|
10585
|
+
if compatibility is not None:
|
|
10586
|
+
out["compatibility"] = compatibility
|
|
10587
|
+
out["verified_operation"] = _verified_operation(
|
|
10588
|
+
"media_pool.link_proxy_checked",
|
|
10589
|
+
requested=requested,
|
|
10590
|
+
preflight=preflight,
|
|
10591
|
+
execution={"api": "MediaPoolItem.LinkProxyMedia", "attempted": True, "success": success},
|
|
10592
|
+
readback=readback,
|
|
10593
|
+
verification_status=verification_status,
|
|
10594
|
+
journal_event=_proxy_link_journal_event(
|
|
10595
|
+
event_type,
|
|
10596
|
+
clip,
|
|
10597
|
+
proxy_path,
|
|
10598
|
+
success=success,
|
|
10599
|
+
verified=verified,
|
|
10600
|
+
compatibility=compatibility,
|
|
10601
|
+
),
|
|
10602
|
+
)
|
|
10603
|
+
return out
|
|
9968
10604
|
|
|
9969
10605
|
|
|
9970
10606
|
def _link_full_resolution_checked(root, p: Dict[str, Any]):
|
|
@@ -12548,6 +13184,35 @@ class _SpecLiveExecutor:
|
|
|
12548
13184
|
self._spec = spec
|
|
12549
13185
|
self._proj = pm.GetCurrentProject()
|
|
12550
13186
|
|
|
13187
|
+
def _media_pool_bin_paths(self) -> List[str]:
|
|
13188
|
+
if not self._proj or not getattr(self._spec, "bins", None):
|
|
13189
|
+
return []
|
|
13190
|
+
try:
|
|
13191
|
+
mp = self._proj.GetMediaPool()
|
|
13192
|
+
root = mp.GetRootFolder() if mp else None
|
|
13193
|
+
except Exception:
|
|
13194
|
+
return []
|
|
13195
|
+
if root is None:
|
|
13196
|
+
return []
|
|
13197
|
+
paths: List[str] = []
|
|
13198
|
+
|
|
13199
|
+
def walk(folder, prefix: str) -> None:
|
|
13200
|
+
paths.append(prefix)
|
|
13201
|
+
try:
|
|
13202
|
+
subfolders = folder.GetSubFolderList() or []
|
|
13203
|
+
except Exception:
|
|
13204
|
+
subfolders = []
|
|
13205
|
+
for subfolder in subfolders:
|
|
13206
|
+
try:
|
|
13207
|
+
name = subfolder.GetName()
|
|
13208
|
+
except Exception:
|
|
13209
|
+
name = ""
|
|
13210
|
+
if name:
|
|
13211
|
+
walk(subfolder, f"{prefix}/{name}")
|
|
13212
|
+
|
|
13213
|
+
walk(root, "Master")
|
|
13214
|
+
return paths
|
|
13215
|
+
|
|
12551
13216
|
def live_state(self) -> Dict[str, Any]:
|
|
12552
13217
|
proj = self._proj
|
|
12553
13218
|
projects = list(self._pm.GetProjectListInCurrentFolder() or [])
|
|
@@ -12597,6 +13262,7 @@ class _SpecLiveExecutor:
|
|
|
12597
13262
|
"project": proj.GetName() if proj else None,
|
|
12598
13263
|
"projects": projects,
|
|
12599
13264
|
"settings": settings,
|
|
13265
|
+
"bins": self._media_pool_bin_paths(),
|
|
12600
13266
|
"timelines": timelines,
|
|
12601
13267
|
}
|
|
12602
13268
|
|
|
@@ -12618,6 +13284,18 @@ class _SpecLiveExecutor:
|
|
|
12618
13284
|
except Exception:
|
|
12619
13285
|
return False
|
|
12620
13286
|
|
|
13287
|
+
def ensure_bin(self, path: str) -> bool:
|
|
13288
|
+
if not self._proj:
|
|
13289
|
+
return False
|
|
13290
|
+
try:
|
|
13291
|
+
mp = self._proj.GetMediaPool()
|
|
13292
|
+
except Exception:
|
|
13293
|
+
return False
|
|
13294
|
+
if mp is None:
|
|
13295
|
+
return False
|
|
13296
|
+
_, err = _ensure_folder_path(mp, path)
|
|
13297
|
+
return err is None
|
|
13298
|
+
|
|
12621
13299
|
def ensure_timeline(self, name: str, fps: Optional[float]) -> bool:
|
|
12622
13300
|
if not self._proj:
|
|
12623
13301
|
return False
|
|
@@ -12746,13 +13424,36 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
|
|
|
12746
13424
|
except Exception:
|
|
12747
13425
|
pass
|
|
12748
13426
|
item_count = 0
|
|
13427
|
+
video_item_count = 0
|
|
13428
|
+
audio_item_count = 0
|
|
13429
|
+
subtitle_item_count = 0
|
|
12749
13430
|
try:
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
13431
|
+
for track_type, key in (
|
|
13432
|
+
("video", "video_item_count"),
|
|
13433
|
+
("audio", "audio_item_count"),
|
|
13434
|
+
("subtitle", "subtitle_item_count"),
|
|
13435
|
+
):
|
|
13436
|
+
track_total = 0
|
|
13437
|
+
track_count = int(tl.GetTrackCount(track_type) or 0)
|
|
13438
|
+
for ti in range(1, track_count + 1):
|
|
13439
|
+
track_total += len(tl.GetItemListInTrack(track_type, ti) or [])
|
|
13440
|
+
if key == "video_item_count":
|
|
13441
|
+
video_item_count = track_total
|
|
13442
|
+
elif key == "audio_item_count":
|
|
13443
|
+
audio_item_count = track_total
|
|
13444
|
+
elif key == "subtitle_item_count":
|
|
13445
|
+
subtitle_item_count = track_total
|
|
13446
|
+
item_count += track_total
|
|
12753
13447
|
except Exception:
|
|
12754
13448
|
pass
|
|
12755
|
-
timelines.append({
|
|
13449
|
+
timelines.append({
|
|
13450
|
+
"name": tl.GetName(),
|
|
13451
|
+
"fps": fps,
|
|
13452
|
+
"item_count": item_count,
|
|
13453
|
+
"video_item_count": video_item_count,
|
|
13454
|
+
"audio_item_count": audio_item_count,
|
|
13455
|
+
"subtitle_item_count": subtitle_item_count,
|
|
13456
|
+
})
|
|
12756
13457
|
state["timelines"] = timelines
|
|
12757
13458
|
settings: Dict[str, Any] = {}
|
|
12758
13459
|
try:
|
|
@@ -13818,7 +14519,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13818
14519
|
metadata_field_inventory(clip_ids|selected, include_values?) -> {items, ui_group_names}
|
|
13819
14520
|
safe_relink(clip_ids|selected, folder_path, dry_run?) -> {success}
|
|
13820
14521
|
safe_unlink(clip_ids|selected, dry_run?) -> {success}
|
|
13821
|
-
|
|
14522
|
+
check_proxy_media_compatibility(clip_id, proxy_path|path, expected_codec?, expected_profile?) -> {compatible, mismatches}
|
|
14523
|
+
link_proxy_checked(clip_id, proxy_path|path, dry_run?, check_compatibility?, require_compatible?, expected_codec?, expected_profile?) -> {success}
|
|
13822
14524
|
link_full_resolution_checked(clip_id, path|full_res_media_path, dry_run?) -> {success}
|
|
13823
14525
|
set_clip_marks(clip_ids|selected, mark_in, mark_out, type?, dry_run?) -> {success, results}
|
|
13824
14526
|
clear_clip_marks(clip_ids|selected, type?, dry_run?) -> {success, results}
|
|
@@ -13960,7 +14662,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13960
14662
|
if row_err:
|
|
13961
14663
|
return row_err
|
|
13962
14664
|
built.append(row)
|
|
13963
|
-
|
|
14665
|
+
requested = {
|
|
14666
|
+
"mode": "clip_infos",
|
|
14667
|
+
"clip_info_count": len(raw),
|
|
14668
|
+
"expected_count": len(built),
|
|
14669
|
+
}
|
|
14670
|
+
result, verification, before = _append_to_timeline_with_verification(
|
|
14671
|
+
proj,
|
|
14672
|
+
mp,
|
|
14673
|
+
built,
|
|
14674
|
+
requested,
|
|
14675
|
+
len(built),
|
|
14676
|
+
)
|
|
13964
14677
|
if not result:
|
|
13965
14678
|
return _err("Failed to append clip_infos to timeline")
|
|
13966
14679
|
items_out = []
|
|
@@ -13969,14 +14682,38 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13969
14682
|
if item_err:
|
|
13970
14683
|
return item_err
|
|
13971
14684
|
items_out.append(item_out)
|
|
13972
|
-
|
|
14685
|
+
out = _ok(count=len(result), items=items_out)
|
|
14686
|
+
out["verified_operation"] = _append_to_timeline_verified_operation(
|
|
14687
|
+
requested,
|
|
14688
|
+
verification,
|
|
14689
|
+
before,
|
|
14690
|
+
)
|
|
14691
|
+
return out
|
|
13973
14692
|
clip_ids = p.get("clip_ids")
|
|
13974
14693
|
if not clip_ids:
|
|
13975
14694
|
return _err("Provide clip_ids (simple append) or clip_infos (positioned append)")
|
|
13976
14695
|
clips = [_find_clip(root, cid) for cid in clip_ids]
|
|
13977
14696
|
clips = [c for c in clips if c]
|
|
13978
|
-
|
|
13979
|
-
|
|
14697
|
+
requested = {
|
|
14698
|
+
"mode": "clip_ids",
|
|
14699
|
+
"clip_ids": list(clip_ids),
|
|
14700
|
+
"resolved_clip_count": len(clips),
|
|
14701
|
+
"expected_count": len(clips),
|
|
14702
|
+
}
|
|
14703
|
+
result, verification, before = _append_to_timeline_with_verification(
|
|
14704
|
+
proj,
|
|
14705
|
+
mp,
|
|
14706
|
+
clips,
|
|
14707
|
+
requested,
|
|
14708
|
+
len(clips),
|
|
14709
|
+
)
|
|
14710
|
+
out = _ok(count=len(result) if result else 0)
|
|
14711
|
+
out["verified_operation"] = _append_to_timeline_verified_operation(
|
|
14712
|
+
requested,
|
|
14713
|
+
verification,
|
|
14714
|
+
before,
|
|
14715
|
+
)
|
|
14716
|
+
return out
|
|
13980
14717
|
elif action == "import_media":
|
|
13981
14718
|
if p.get("clip_infos") is not None:
|
|
13982
14719
|
raw = p["clip_infos"]
|
|
@@ -14081,6 +14818,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14081
14818
|
return _safe_relink(mp, root, p)
|
|
14082
14819
|
elif action == "safe_unlink":
|
|
14083
14820
|
return _safe_unlink(mp, root, p)
|
|
14821
|
+
elif action == "check_proxy_media_compatibility":
|
|
14822
|
+
return _check_proxy_media_compatibility_checked(root, p)
|
|
14084
14823
|
elif action == "link_proxy_checked":
|
|
14085
14824
|
return _link_proxy_checked(root, p)
|
|
14086
14825
|
elif action == "link_full_resolution_checked":
|
|
@@ -15758,6 +16497,506 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
15758
16497
|
return _err(f"Unknown action: {action}")
|
|
15759
16498
|
|
|
15760
16499
|
|
|
16500
|
+
def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
16501
|
+
"""Serialize video timeline items with the source mapping the planner needs."""
|
|
16502
|
+
rows: List[Dict[str, Any]] = []
|
|
16503
|
+
track_count = int(tl.GetTrackCount("video") or 0)
|
|
16504
|
+
indices = [int(track_index)] if track_index else range(1, track_count + 1)
|
|
16505
|
+
for index in indices:
|
|
16506
|
+
try:
|
|
16507
|
+
items = tl.GetItemListInTrack("video", index) or []
|
|
16508
|
+
except Exception:
|
|
16509
|
+
continue
|
|
16510
|
+
for item in items:
|
|
16511
|
+
try:
|
|
16512
|
+
row: Dict[str, Any] = {
|
|
16513
|
+
"track_index": index,
|
|
16514
|
+
"item_name": item.GetName(),
|
|
16515
|
+
"timeline_start_frame": _frame_int(item.GetStart()),
|
|
16516
|
+
"timeline_end_frame": _frame_int(item.GetEnd()),
|
|
16517
|
+
}
|
|
16518
|
+
except Exception:
|
|
16519
|
+
continue
|
|
16520
|
+
source_start = None
|
|
16521
|
+
if _has_method(item, "GetSourceStartFrame"):
|
|
16522
|
+
try:
|
|
16523
|
+
source_start = _frame_int(item.GetSourceStartFrame())
|
|
16524
|
+
except Exception:
|
|
16525
|
+
source_start = None
|
|
16526
|
+
if source_start is None and _has_method(item, "GetLeftOffset"):
|
|
16527
|
+
try:
|
|
16528
|
+
source_start = _frame_int(item.GetLeftOffset())
|
|
16529
|
+
except Exception:
|
|
16530
|
+
source_start = None
|
|
16531
|
+
row["source_start_frame"] = source_start or 0
|
|
16532
|
+
try:
|
|
16533
|
+
mpi = item.GetMediaPoolItem()
|
|
16534
|
+
if mpi is not None:
|
|
16535
|
+
row["media_ref"] = mpi.GetUniqueId()
|
|
16536
|
+
try:
|
|
16537
|
+
row["media_path"] = mpi.GetClipProperty("File Path")
|
|
16538
|
+
except Exception:
|
|
16539
|
+
pass
|
|
16540
|
+
except Exception:
|
|
16541
|
+
pass
|
|
16542
|
+
rows.append(row)
|
|
16543
|
+
return rows
|
|
16544
|
+
|
|
16545
|
+
|
|
16546
|
+
def _edit_engine_timeline_fps(tl) -> float:
|
|
16547
|
+
try:
|
|
16548
|
+
fps = float(tl.GetSetting("timelineFrameRate") or 0)
|
|
16549
|
+
return fps if fps > 0 else 24.0
|
|
16550
|
+
except Exception:
|
|
16551
|
+
return 24.0
|
|
16552
|
+
|
|
16553
|
+
|
|
16554
|
+
def _edit_engine_capture(tl) -> Dict[str, Any]:
|
|
16555
|
+
"""Duration/clip-count readback for execute results."""
|
|
16556
|
+
out: Dict[str, Any] = {}
|
|
16557
|
+
try:
|
|
16558
|
+
out["duration_seconds"] = _brain_edits.capture_metric(_brain_edits.METRIC_DURATION_SECONDS, tl)
|
|
16559
|
+
out["clip_count"] = _brain_edits.capture_metric(_brain_edits.METRIC_CLIP_COUNT, tl)
|
|
16560
|
+
except Exception:
|
|
16561
|
+
pass
|
|
16562
|
+
return out
|
|
16563
|
+
|
|
16564
|
+
|
|
16565
|
+
@mcp.tool()
|
|
16566
|
+
@_destructive_op("edit_engine")
|
|
16567
|
+
def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16568
|
+
"""Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
|
|
16569
|
+
|
|
16570
|
+
Every loop is plan → confirm → execute. plan_* actions are dry-run by
|
|
16571
|
+
construction: they query the DB-canonical analysis store and return a
|
|
16572
|
+
per-decision rationale plus a stored plan_id. execute_* actions require a
|
|
16573
|
+
confirm_token, run under the version-on-mutate hook (the timeline is
|
|
16574
|
+
archived first), and return before/after metrics as readback.
|
|
16575
|
+
|
|
16576
|
+
Actions:
|
|
16577
|
+
- plan_selects(min_select_potential?, max_duration_seconds?, timeline_name?,
|
|
16578
|
+
max_shots?, handle_seconds?, analysis_root?) — rank shots by deep-tier
|
|
16579
|
+
select potential (clip-level fallback), story-spine order. Additive.
|
|
16580
|
+
- execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
|
|
16581
|
+
from the plan's clip in/out ranges. Nothing existing is touched.
|
|
16582
|
+
- plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
|
|
16583
|
+
handle_seconds?) — dead-air lifts from transcript-gap evidence for the
|
|
16584
|
+
current (or named) timeline.
|
|
16585
|
+
- execute_tighten(plan_id, confirm_token?) — duplicates the timeline and
|
|
16586
|
+
applies the lifts (ripple) to the DUPLICATE, never the original.
|
|
16587
|
+
- plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
|
|
16588
|
+
— alternates for one timeline item via the similarity index.
|
|
16589
|
+
- execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
|
|
16590
|
+
item in place (lift + positioned append, same slot) on the current
|
|
16591
|
+
timeline (version-archived first).
|
|
16592
|
+
- list_plans(limit?) / get_plan(plan_id)
|
|
16593
|
+
"""
|
|
16594
|
+
p = params or {}
|
|
16595
|
+
|
|
16596
|
+
def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
|
|
16597
|
+
# An explicit analysis_root always wins — the evidence DB the caller
|
|
16598
|
+
# analyzed into may differ from the provider's derived root (e.g. a
|
|
16599
|
+
# headless analysis keyed by project name only).
|
|
16600
|
+
analysis_root = p.get("analysis_root") or p.get("analysisRoot")
|
|
16601
|
+
explicit_root = str(analysis_root) if analysis_root and os.path.isdir(str(analysis_root)) else None
|
|
16602
|
+
vctx = _destructive_versioning_provider()
|
|
16603
|
+
if vctx is not None:
|
|
16604
|
+
r, proj, project_root, _name = vctx
|
|
16605
|
+
return r, proj, explicit_root or project_root, None
|
|
16606
|
+
if not need_resolve and explicit_root:
|
|
16607
|
+
return None, None, explicit_root, None
|
|
16608
|
+
return None, None, None, _err(
|
|
16609
|
+
"No project context — open a Resolve project (or pass analysis_root for plan_selects)."
|
|
16610
|
+
)
|
|
16611
|
+
|
|
16612
|
+
if action == "list_plans":
|
|
16613
|
+
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
16614
|
+
if err:
|
|
16615
|
+
return err
|
|
16616
|
+
return _edit_engine_mod.list_plans(project_root, limit=int(p.get("limit") or 20))
|
|
16617
|
+
|
|
16618
|
+
if action == "get_plan":
|
|
16619
|
+
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
16620
|
+
if err:
|
|
16621
|
+
return err
|
|
16622
|
+
plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
|
|
16623
|
+
if not plan:
|
|
16624
|
+
return _err("plan not found")
|
|
16625
|
+
if plan.get("_corrupt"):
|
|
16626
|
+
return _err("plan file failed its fingerprint check — re-plan")
|
|
16627
|
+
return {"success": True, "plan": plan}
|
|
16628
|
+
|
|
16629
|
+
if action == "plan_selects":
|
|
16630
|
+
_r, _proj, project_root, err = _project_context(need_resolve=False)
|
|
16631
|
+
if err:
|
|
16632
|
+
return err
|
|
16633
|
+
return _edit_engine_mod.plan_selects(
|
|
16634
|
+
project_root,
|
|
16635
|
+
timeline_name=p.get("timeline_name") or p.get("timelineName"),
|
|
16636
|
+
max_duration_seconds=p.get("max_duration_seconds") or p.get("maxDurationSeconds"),
|
|
16637
|
+
min_select_potential=str(p.get("min_select_potential") or p.get("minSelectPotential") or "medium"),
|
|
16638
|
+
handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
|
|
16639
|
+
max_shots=int(p.get("max_shots") or p.get("maxShots") or 60),
|
|
16640
|
+
)
|
|
16641
|
+
|
|
16642
|
+
if action == "plan_tighten":
|
|
16643
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
16644
|
+
if err:
|
|
16645
|
+
return err
|
|
16646
|
+
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())
|
|
16647
|
+
if not tl:
|
|
16648
|
+
return _err("Timeline not found")
|
|
16649
|
+
items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
|
|
16650
|
+
return _edit_engine_mod.plan_tighten(
|
|
16651
|
+
project_root,
|
|
16652
|
+
items=items,
|
|
16653
|
+
timeline_name=tl.GetName(),
|
|
16654
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
16655
|
+
target_ratio=p.get("target_ratio") or p.get("targetRatio"),
|
|
16656
|
+
min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
|
|
16657
|
+
handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
|
|
16658
|
+
)
|
|
16659
|
+
|
|
16660
|
+
if action == "plan_swap":
|
|
16661
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
16662
|
+
if err:
|
|
16663
|
+
return err
|
|
16664
|
+
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())
|
|
16665
|
+
if not tl:
|
|
16666
|
+
return _err("Timeline not found")
|
|
16667
|
+
items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
|
|
16668
|
+
target = None
|
|
16669
|
+
wanted_start = p.get("timeline_start_frame") if p.get("timeline_start_frame") is not None else p.get("timelineStartFrame")
|
|
16670
|
+
wanted_name = p.get("item_name") or p.get("itemName")
|
|
16671
|
+
for row in items:
|
|
16672
|
+
if wanted_start is not None and int(row["timeline_start_frame"]) == int(wanted_start):
|
|
16673
|
+
target = row
|
|
16674
|
+
break
|
|
16675
|
+
if wanted_name and row.get("item_name") == wanted_name:
|
|
16676
|
+
target = row
|
|
16677
|
+
break
|
|
16678
|
+
if target is None:
|
|
16679
|
+
return _err(
|
|
16680
|
+
"Target item not found — pass timeline_start_frame or item_name "
|
|
16681
|
+
f"(items: {[{'name': r.get('item_name'), 'start': r['timeline_start_frame']} for r in items[:20]]})"
|
|
16682
|
+
)
|
|
16683
|
+
return _edit_engine_mod.plan_swap(
|
|
16684
|
+
project_root,
|
|
16685
|
+
item=target,
|
|
16686
|
+
timeline_name=tl.GetName(),
|
|
16687
|
+
timeline_fps=_edit_engine_timeline_fps(tl),
|
|
16688
|
+
kind=str(p.get("kind") or "visual"),
|
|
16689
|
+
limit=int(p.get("limit") or 5),
|
|
16690
|
+
)
|
|
16691
|
+
|
|
16692
|
+
if action == "execute_selects":
|
|
16693
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
16694
|
+
if err:
|
|
16695
|
+
return err
|
|
16696
|
+
plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
|
|
16697
|
+
if not plan or plan.get("_corrupt"):
|
|
16698
|
+
return _err("plan not found (or failed its fingerprint check) — re-plan")
|
|
16699
|
+
if plan.get("kind") != "selects":
|
|
16700
|
+
return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
|
|
16701
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
16702
|
+
return _issue_confirm_token(
|
|
16703
|
+
action="edit_engine.execute_selects", params=p,
|
|
16704
|
+
preview={
|
|
16705
|
+
"operation": "edit_engine.execute_selects",
|
|
16706
|
+
"warning": "Creates a NEW selects timeline; no existing timeline is modified.",
|
|
16707
|
+
"timeline_name": plan.get("timeline_name"),
|
|
16708
|
+
"decision_count": len(plan.get("decisions") or []),
|
|
16709
|
+
"estimated_duration_seconds": plan.get("estimated_duration_seconds"),
|
|
16710
|
+
},
|
|
16711
|
+
)
|
|
16712
|
+
blocked = _consume_confirm_token(action="edit_engine.execute_selects", params=p)
|
|
16713
|
+
if blocked:
|
|
16714
|
+
return blocked
|
|
16715
|
+
mp = proj.GetMediaPool()
|
|
16716
|
+
if not mp:
|
|
16717
|
+
return _err("No media pool")
|
|
16718
|
+
root_folder = mp.GetRootFolder()
|
|
16719
|
+
name = str(plan.get("timeline_name") or "Selects")
|
|
16720
|
+
if _find_timeline_by_name(proj, name)[0]:
|
|
16721
|
+
name = f"{name} {time.strftime('%H%M%S')}"
|
|
16722
|
+
tl = mp.CreateEmptyTimeline(name)
|
|
16723
|
+
if not tl:
|
|
16724
|
+
return _err("Failed to create selects timeline")
|
|
16725
|
+
try:
|
|
16726
|
+
proj.SetCurrentTimeline(tl)
|
|
16727
|
+
except Exception:
|
|
16728
|
+
pass
|
|
16729
|
+
timeline_start = _timeline_start_frame(tl)
|
|
16730
|
+
built = []
|
|
16731
|
+
build_errors = []
|
|
16732
|
+
record_cursor = 0
|
|
16733
|
+
for i, ci in enumerate(plan.get("clip_infos") or []):
|
|
16734
|
+
append_ci = dict(ci)
|
|
16735
|
+
# Sequential assembly: each select lands after the previous one.
|
|
16736
|
+
append_ci.setdefault("record_frame", record_cursor)
|
|
16737
|
+
append_ci.setdefault("track_index", 1)
|
|
16738
|
+
row, row_err = _build_append_clip_info_dict(root_folder, append_ci, i, timeline_start)
|
|
16739
|
+
if row_err:
|
|
16740
|
+
build_errors.append({"index": i, "error": row_err.get("error")})
|
|
16741
|
+
continue
|
|
16742
|
+
built.append(row)
|
|
16743
|
+
record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
|
|
16744
|
+
appended = mp.AppendToTimeline(built) if built else None
|
|
16745
|
+
readback = _edit_engine_capture(tl)
|
|
16746
|
+
run_id = _analysis_runs.current_run_id()
|
|
16747
|
+
try:
|
|
16748
|
+
_brain_edits.log_brain_edit(
|
|
16749
|
+
project_root=project_root,
|
|
16750
|
+
analysis_run_id=run_id or "edit-engine",
|
|
16751
|
+
edit_type="edit_engine.selects_result",
|
|
16752
|
+
tool_name="edit_engine",
|
|
16753
|
+
action_name="execute_selects",
|
|
16754
|
+
timeline_after=tl.GetName(),
|
|
16755
|
+
target_metric=_brain_edits.METRIC_CLIP_COUNT,
|
|
16756
|
+
metric_direction="target_value",
|
|
16757
|
+
after_value=readback.get("clip_count"),
|
|
16758
|
+
rationale=plan.get("summary"),
|
|
16759
|
+
params={"plan_id": plan.get("plan_id")},
|
|
16760
|
+
result_summary={"appended": len(built), "build_errors": build_errors},
|
|
16761
|
+
)
|
|
16762
|
+
except Exception:
|
|
16763
|
+
pass
|
|
16764
|
+
_edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
|
|
16765
|
+
"timeline_name": tl.GetName(),
|
|
16766
|
+
"appended": len(built),
|
|
16767
|
+
"build_errors": build_errors,
|
|
16768
|
+
**readback,
|
|
16769
|
+
})
|
|
16770
|
+
return {
|
|
16771
|
+
"success": bool(appended),
|
|
16772
|
+
"timeline_name": tl.GetName(),
|
|
16773
|
+
"appended": len(built),
|
|
16774
|
+
"build_errors": build_errors,
|
|
16775
|
+
"readback": readback,
|
|
16776
|
+
"plan_id": plan.get("plan_id"),
|
|
16777
|
+
}
|
|
16778
|
+
|
|
16779
|
+
if action == "execute_tighten":
|
|
16780
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
16781
|
+
if err:
|
|
16782
|
+
return err
|
|
16783
|
+
plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
|
|
16784
|
+
if not plan or plan.get("_corrupt"):
|
|
16785
|
+
return _err("plan not found (or failed its fingerprint check) — re-plan")
|
|
16786
|
+
if plan.get("kind") != "tighten":
|
|
16787
|
+
return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
|
|
16788
|
+
lifts = plan.get("lifts") or []
|
|
16789
|
+
keep_ranges = plan.get("keep_ranges") or []
|
|
16790
|
+
if not keep_ranges:
|
|
16791
|
+
return _err("plan has no keep_ranges — re-plan with this version")
|
|
16792
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
16793
|
+
return _issue_confirm_token(
|
|
16794
|
+
action="edit_engine.execute_tighten", params=p,
|
|
16795
|
+
preview={
|
|
16796
|
+
"operation": "edit_engine.execute_tighten",
|
|
16797
|
+
"warning": (
|
|
16798
|
+
"Assembles a tightened VARIANT timeline from the plan's keep "
|
|
16799
|
+
"ranges; the original timeline is not modified."
|
|
16800
|
+
),
|
|
16801
|
+
"timeline_name": plan.get("timeline_name"),
|
|
16802
|
+
"lift_count": len(lifts),
|
|
16803
|
+
"keep_range_count": len(keep_ranges),
|
|
16804
|
+
"estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
|
|
16805
|
+
},
|
|
16806
|
+
)
|
|
16807
|
+
blocked = _consume_confirm_token(action="edit_engine.execute_tighten", params=p)
|
|
16808
|
+
if blocked:
|
|
16809
|
+
return blocked
|
|
16810
|
+
source_tl, _src_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
|
|
16811
|
+
if not source_tl:
|
|
16812
|
+
return _err(f"Timeline '{plan.get('timeline_name')}' not found")
|
|
16813
|
+
before = _edit_engine_capture(source_tl)
|
|
16814
|
+
variant_name = f"{plan.get('timeline_name')} — tightened {time.strftime('%H%M%S')}"
|
|
16815
|
+
variant = _timeline_create_variant_from_ranges(proj, source_tl, {
|
|
16816
|
+
"ranges": keep_ranges,
|
|
16817
|
+
"name": variant_name,
|
|
16818
|
+
})
|
|
16819
|
+
if not variant.get("success"):
|
|
16820
|
+
return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
|
|
16821
|
+
new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
|
|
16822
|
+
after = _edit_engine_capture(new_tl) if new_tl else {}
|
|
16823
|
+
run_id = _analysis_runs.current_run_id()
|
|
16824
|
+
try:
|
|
16825
|
+
_brain_edits.log_brain_edit(
|
|
16826
|
+
project_root=project_root,
|
|
16827
|
+
analysis_run_id=run_id or "edit-engine",
|
|
16828
|
+
edit_type="edit_engine.tighten_result",
|
|
16829
|
+
tool_name="edit_engine",
|
|
16830
|
+
action_name="execute_tighten",
|
|
16831
|
+
timeline_before=plan.get("timeline_name"),
|
|
16832
|
+
timeline_after=variant.get("name") or variant_name,
|
|
16833
|
+
target_metric=_brain_edits.METRIC_DURATION_SECONDS,
|
|
16834
|
+
metric_direction="decrease",
|
|
16835
|
+
before_value=before.get("duration_seconds"),
|
|
16836
|
+
after_value=after.get("duration_seconds"),
|
|
16837
|
+
rationale=plan.get("summary"),
|
|
16838
|
+
params={"plan_id": plan.get("plan_id")},
|
|
16839
|
+
result_summary={"keep_ranges": len(keep_ranges), "lifts": len(lifts)},
|
|
16840
|
+
)
|
|
16841
|
+
except Exception:
|
|
16842
|
+
pass
|
|
16843
|
+
_edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
|
|
16844
|
+
"variant_timeline": variant.get("name") or variant_name,
|
|
16845
|
+
"keep_ranges": len(keep_ranges),
|
|
16846
|
+
"lifts": len(lifts),
|
|
16847
|
+
"before": before,
|
|
16848
|
+
"after": after,
|
|
16849
|
+
})
|
|
16850
|
+
return {
|
|
16851
|
+
"success": True,
|
|
16852
|
+
"original_timeline": plan.get("timeline_name"),
|
|
16853
|
+
"variant_timeline": variant.get("name") or variant_name,
|
|
16854
|
+
"lifts_applied": len(lifts),
|
|
16855
|
+
"keep_ranges": len(keep_ranges),
|
|
16856
|
+
"lift_rationales": [
|
|
16857
|
+
{"lift": [l["timeline_start_frame"], l["timeline_end_frame"]], "rationale": l.get("rationale")}
|
|
16858
|
+
for l in lifts
|
|
16859
|
+
],
|
|
16860
|
+
"readback": {
|
|
16861
|
+
"before": before,
|
|
16862
|
+
"after": after,
|
|
16863
|
+
"removed_seconds": (
|
|
16864
|
+
round(before["duration_seconds"] - after["duration_seconds"], 2)
|
|
16865
|
+
if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
|
|
16866
|
+
else None
|
|
16867
|
+
),
|
|
16868
|
+
},
|
|
16869
|
+
"plan_id": plan.get("plan_id"),
|
|
16870
|
+
}
|
|
16871
|
+
|
|
16872
|
+
if action == "execute_swap":
|
|
16873
|
+
_r, proj, project_root, err = _project_context(need_resolve=True)
|
|
16874
|
+
if err:
|
|
16875
|
+
return err
|
|
16876
|
+
plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
|
|
16877
|
+
if not plan or plan.get("_corrupt"):
|
|
16878
|
+
return _err("plan not found (or failed its fingerprint check) — re-plan")
|
|
16879
|
+
if plan.get("kind") != "swap":
|
|
16880
|
+
return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
|
|
16881
|
+
alternates = plan.get("alternates") or []
|
|
16882
|
+
try:
|
|
16883
|
+
alt_index = int(p.get("alternate_index") if p.get("alternate_index") is not None else p.get("alternateIndex"))
|
|
16884
|
+
except (TypeError, ValueError):
|
|
16885
|
+
return _err(f"execute_swap requires alternate_index (0-{len(alternates) - 1})")
|
|
16886
|
+
if not (0 <= alt_index < len(alternates)):
|
|
16887
|
+
return _err(f"alternate_index out of range (0-{len(alternates) - 1})")
|
|
16888
|
+
alternate = alternates[alt_index]
|
|
16889
|
+
item_block = plan.get("item") or {}
|
|
16890
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
16891
|
+
return _issue_confirm_token(
|
|
16892
|
+
action="edit_engine.execute_swap", params=p,
|
|
16893
|
+
preview={
|
|
16894
|
+
"operation": "edit_engine.execute_swap",
|
|
16895
|
+
"warning": (
|
|
16896
|
+
"Replaces one timeline item in place (lift + positioned append). "
|
|
16897
|
+
"A timeline version is archived first."
|
|
16898
|
+
),
|
|
16899
|
+
"timeline_name": plan.get("timeline_name"),
|
|
16900
|
+
"slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
|
|
16901
|
+
"replacement": {
|
|
16902
|
+
"clip_name": alternate.get("clip_name"),
|
|
16903
|
+
"shot_index": alternate.get("shot_index"),
|
|
16904
|
+
"score": alternate.get("score"),
|
|
16905
|
+
},
|
|
16906
|
+
},
|
|
16907
|
+
)
|
|
16908
|
+
blocked = _consume_confirm_token(action="edit_engine.execute_swap", params=p)
|
|
16909
|
+
if blocked:
|
|
16910
|
+
return blocked
|
|
16911
|
+
tl, _tl_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
|
|
16912
|
+
if not tl:
|
|
16913
|
+
return _err(f"Timeline '{plan.get('timeline_name')}' not found")
|
|
16914
|
+
try:
|
|
16915
|
+
proj.SetCurrentTimeline(tl)
|
|
16916
|
+
except Exception:
|
|
16917
|
+
pass
|
|
16918
|
+
before = _edit_engine_capture(tl)
|
|
16919
|
+
lift = _timeline_lift_range_impl(tl, {
|
|
16920
|
+
"start_frame": item_block.get("timeline_start_frame"),
|
|
16921
|
+
"end_frame": item_block.get("timeline_end_frame"),
|
|
16922
|
+
"allow_partial_item_delete": True,
|
|
16923
|
+
"ripple": False,
|
|
16924
|
+
})
|
|
16925
|
+
if not lift.get("success"):
|
|
16926
|
+
return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
|
|
16927
|
+
mp = proj.GetMediaPool()
|
|
16928
|
+
root_folder = mp.GetRootFolder()
|
|
16929
|
+
timeline_start = _timeline_start_frame(tl)
|
|
16930
|
+
frame_range = alternate.get("source_frame_range") or [0, 0]
|
|
16931
|
+
ci = {
|
|
16932
|
+
"clip_id": alternate.get("resolve_clip_id"),
|
|
16933
|
+
"start_frame": int(frame_range[0]),
|
|
16934
|
+
"end_frame": int(frame_range[1]),
|
|
16935
|
+
# Item starts come from item.GetStart() — absolute timeline frames.
|
|
16936
|
+
"record_frame": int(item_block.get("timeline_start_frame")),
|
|
16937
|
+
"record_frame_mode": "absolute",
|
|
16938
|
+
"track_index": int(item_block.get("track_index") or 1),
|
|
16939
|
+
}
|
|
16940
|
+
row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
|
|
16941
|
+
if row_err:
|
|
16942
|
+
return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
|
|
16943
|
+
appended = mp.AppendToTimeline([row])
|
|
16944
|
+
after = _edit_engine_capture(tl)
|
|
16945
|
+
run_id = _analysis_runs.current_run_id()
|
|
16946
|
+
try:
|
|
16947
|
+
_brain_edits.log_brain_edit(
|
|
16948
|
+
project_root=project_root,
|
|
16949
|
+
analysis_run_id=run_id or "edit-engine",
|
|
16950
|
+
edit_type="edit_engine.swap_result",
|
|
16951
|
+
tool_name="edit_engine",
|
|
16952
|
+
action_name="execute_swap",
|
|
16953
|
+
timeline_after=tl.GetName(),
|
|
16954
|
+
target_metric=_brain_edits.METRIC_CLIP_COUNT,
|
|
16955
|
+
metric_direction="target_value",
|
|
16956
|
+
before_value=before.get("clip_count"),
|
|
16957
|
+
after_value=after.get("clip_count"),
|
|
16958
|
+
rationale=(
|
|
16959
|
+
f"Swapped slot {item_block.get('timeline_start_frame')}-"
|
|
16960
|
+
f"{item_block.get('timeline_end_frame')} with "
|
|
16961
|
+
f"{alternate.get('clip_name')} shot {alternate.get('shot_index')}: "
|
|
16962
|
+
f"{alternate.get('rationale')}"
|
|
16963
|
+
),
|
|
16964
|
+
params={"plan_id": plan.get("plan_id"), "alternate_index": alt_index},
|
|
16965
|
+
result_summary={"appended": bool(appended)},
|
|
16966
|
+
)
|
|
16967
|
+
except Exception:
|
|
16968
|
+
pass
|
|
16969
|
+
_edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
|
|
16970
|
+
"alternate_index": alt_index,
|
|
16971
|
+
"replacement": alternate.get("clip_name"),
|
|
16972
|
+
"before": before,
|
|
16973
|
+
"after": after,
|
|
16974
|
+
})
|
|
16975
|
+
return {
|
|
16976
|
+
"success": bool(appended),
|
|
16977
|
+
"timeline_name": tl.GetName(),
|
|
16978
|
+
"replaced_slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
|
|
16979
|
+
"replacement": {
|
|
16980
|
+
"clip_name": alternate.get("clip_name"),
|
|
16981
|
+
"shot_index": alternate.get("shot_index"),
|
|
16982
|
+
"score": alternate.get("score"),
|
|
16983
|
+
},
|
|
16984
|
+
"readback": {"before": before, "after": after},
|
|
16985
|
+
"plan_id": plan.get("plan_id"),
|
|
16986
|
+
}
|
|
16987
|
+
|
|
16988
|
+
return _unknown(action, [
|
|
16989
|
+
"plan_selects",
|
|
16990
|
+
"execute_selects",
|
|
16991
|
+
"plan_tighten",
|
|
16992
|
+
"execute_tighten",
|
|
16993
|
+
"plan_swap",
|
|
16994
|
+
"execute_swap",
|
|
16995
|
+
"list_plans",
|
|
16996
|
+
"get_plan",
|
|
16997
|
+
])
|
|
16998
|
+
|
|
16999
|
+
|
|
15761
17000
|
@mcp.tool()
|
|
15762
17001
|
@_destructive_op("timeline")
|
|
15763
17002
|
def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -15825,6 +17064,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15825
17064
|
apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?) -> {success}
|
|
15826
17065
|
# example: action_help(name='<action_name>')
|
|
15827
17066
|
thumbnail_contact_sheet(frames?|max_samples?, analysis_root?) -> {path, samples}
|
|
17067
|
+
frames are relative to the timeline start (frame 0 = first frame), like marker frameIds.
|
|
15828
17068
|
marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance}
|
|
15829
17069
|
edit_kernel_capabilities() -> {supported, partially_supported, unsupported}
|
|
15830
17070
|
probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count}
|
|
@@ -16431,6 +17671,12 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
16431
17671
|
def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
16432
17672
|
"""Markers and playhead operations on the current timeline.
|
|
16433
17673
|
|
|
17674
|
+
Marker frames are RELATIVE to the timeline start: frame 0 is the first
|
|
17675
|
+
frame of the timeline, even when the timeline starts at 01:00:00:00.
|
|
17676
|
+
Timecode params are absolute timeline timecode as shown in the Resolve UI
|
|
17677
|
+
(timecodes before the start timecode are treated as elapsed time) and are
|
|
17678
|
+
converted to relative frames automatically.
|
|
17679
|
+
|
|
16434
17680
|
Actions:
|
|
16435
17681
|
add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame}
|
|
16436
17682
|
If frame/timecode is omitted, add uses the current playhead timecode.
|