davinci-resolve-mcp 2.45.0 → 2.46.1
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 +41 -0
- package/README.md +1 -1
- 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 +759 -18
- package/src/utils/project_cleanup.py +73 -0
- package/src/utils/project_lint.py +10 -1
- package/src/utils/project_spec.py +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.46.1
|
|
6
|
+
|
|
7
|
+
Test and hygiene hardening; no public tool surface changes.
|
|
8
|
+
|
|
9
|
+
- **Fixed** the three `InventoryCacheReuseTests` failures that appeared
|
|
10
|
+
whenever a live Resolve instance was running: the reuse/build-path tests
|
|
11
|
+
now stub the read-only Resolve probe, so the suite is green with Resolve
|
|
12
|
+
open or closed.
|
|
13
|
+
- **Added** `src/utils/project_cleanup.delete_project_safely`: a retrying
|
|
14
|
+
delete helper for disposable Resolve projects (switch away from the target,
|
|
15
|
+
retry `DeleteProject` once after a pause, report the leftover by name on
|
|
16
|
+
persistent failure). The live edit-engine validation harness now uses it
|
|
17
|
+
during cleanup, so disposable pilot projects stop lingering in the library.
|
|
18
|
+
|
|
19
|
+
## What's New in v2.46.0
|
|
20
|
+
|
|
21
|
+
Community PR bundle: five contributed fixes and features (#62–#66), live-validated
|
|
22
|
+
on Resolve Studio 21.
|
|
23
|
+
|
|
24
|
+
- **Fixed** timeline marker frames are now correctly relative to the timeline
|
|
25
|
+
start (#66): timecode params and the playhead default rebase by
|
|
26
|
+
`GetStartFrame()`, so markers on hour-start timelines land where the UI
|
|
27
|
+
shows them instead of an hour past the end. Raw `frame` params pass through
|
|
28
|
+
unchanged (documented as relative). Contact sheets and marker thumbnail
|
|
29
|
+
review rebase the other way with a legacy-absolute guard.
|
|
30
|
+
- **Fixed** project lint no longer flags audio-only timelines as empty (#62);
|
|
31
|
+
live lint state now reports per-type `video/audio/subtitle_item_count`.
|
|
32
|
+
- **Added** `media_pool.check_proxy_media_compatibility` (#63): ffprobe-vs-source
|
|
33
|
+
signature diagnostics (resolution/fps/frames/sample rate, expected
|
|
34
|
+
codec/profile). Same-aspect downscales count as compatible — half-res
|
|
35
|
+
proxies are the normal workflow.
|
|
36
|
+
- **Added** readback verification envelopes (`verified_operation`) around
|
|
37
|
+
`media_pool.link_proxy_checked` (#63) and `media_pool.append_to_timeline`
|
|
38
|
+
(#64): preflight, execution, post-state readback, verification status, and a
|
|
39
|
+
journal event. `link_proxy_checked` gains `check_compatibility` /
|
|
40
|
+
`require_compatible` guards that refuse incompatible proxies before linking.
|
|
41
|
+
- **Added** `bins` to declarative project specs (#65): missing media-pool bin
|
|
42
|
+
paths plan as `ensure` actions and are created idempotently; existing bins
|
|
43
|
+
are noops. Bin paths normalize to the `Master/` prefix so unprefixed spec
|
|
44
|
+
bins converge instead of reporting perpetual drift.
|
|
45
|
+
|
|
5
46
|
## What's New in v2.45.0
|
|
6
47
|
|
|
7
48
|
The edit engine — Phase E, the final phase of the analysis + edit-engine
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.46.1"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.46.1"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/granular/timeline.py
CHANGED
|
@@ -461,7 +461,8 @@ def timeline_add_marker(frame_id: int, color: str, name: str, note: str = "", du
|
|
|
461
461
|
"""Add a marker to the current timeline.
|
|
462
462
|
|
|
463
463
|
Args:
|
|
464
|
-
frame_id: Frame number for the marker
|
|
464
|
+
frame_id: Frame number for the marker, relative to the timeline start
|
|
465
|
+
(frame 0 = first frame, even when the timeline starts at 01:00:00:00).
|
|
465
466
|
color: Marker color (Blue, Cyan, Green, Yellow, Red, Pink, Purple, Fuchsia, Rose, Lavender, Sky, Mint, Lemon, Sand, Cocoa, Cream).
|
|
466
467
|
name: Marker name.
|
|
467
468
|
note: Marker note. Default: empty.
|
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.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -1347,6 +1347,46 @@ def _timeline_timecode_to_frame_id(tl, timecode):
|
|
|
1347
1347
|
return _timecode_to_frame_id(timecode, fps)
|
|
1348
1348
|
|
|
1349
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
|
+
|
|
1350
1390
|
def _frame_id_to_timecode(frame: int, fps: float, separator: str = ":") -> str:
|
|
1351
1391
|
nominal_fps = max(1, int(round(float(fps))))
|
|
1352
1392
|
frame = max(0, int(frame))
|
|
@@ -1364,6 +1404,7 @@ def _timeline_frame_id_to_timecode(tl, frame: int) -> Tuple[Optional[str], Optio
|
|
|
1364
1404
|
|
|
1365
1405
|
|
|
1366
1406
|
def _current_timeline_frame_id(tl):
|
|
1407
|
+
"""Absolute frame at the playhead, matching TimelineItem.GetStart()."""
|
|
1367
1408
|
if tl is None:
|
|
1368
1409
|
return None, _err("current playhead marker requires a timeline")
|
|
1369
1410
|
try:
|
|
@@ -1375,23 +1416,37 @@ def _current_timeline_frame_id(tl):
|
|
|
1375
1416
|
return _timeline_timecode_to_frame_id(tl, timecode)
|
|
1376
1417
|
|
|
1377
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
|
+
|
|
1378
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
|
+
"""
|
|
1379
1434
|
raw_timecode = _first_param(p, "timecode", "time_code", "tc")
|
|
1380
1435
|
if raw_timecode is not None:
|
|
1381
|
-
return
|
|
1436
|
+
return _marker_timecode_to_frame_id(tl, raw_timecode)
|
|
1382
1437
|
|
|
1383
1438
|
raw_frame = _first_param(p, "frame", "frame_id", "frameId", "frame_num", "frameNum")
|
|
1384
1439
|
if raw_frame is not None:
|
|
1385
1440
|
if isinstance(raw_frame, str):
|
|
1386
1441
|
lowered = raw_frame.strip().lower()
|
|
1387
1442
|
if lowered in {"current", "playhead", "current_playhead", "now"}:
|
|
1388
|
-
return
|
|
1443
|
+
return _current_timeline_marker_frame_id(tl)
|
|
1389
1444
|
if ":" in raw_frame or ";" in raw_frame:
|
|
1390
|
-
return
|
|
1445
|
+
return _marker_timecode_to_frame_id(tl, raw_frame)
|
|
1391
1446
|
return _coerce_marker_number(raw_frame, "frame")
|
|
1392
1447
|
|
|
1393
1448
|
if default_to_current:
|
|
1394
|
-
return
|
|
1449
|
+
return _current_timeline_marker_frame_id(tl)
|
|
1395
1450
|
return None, _err("Missing marker frame. Provide frame, frame_id/frameId, or timecode.")
|
|
1396
1451
|
|
|
1397
1452
|
|
|
@@ -4821,7 +4876,7 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
|
|
|
4821
4876
|
sampled = []
|
|
4822
4877
|
try:
|
|
4823
4878
|
for sample in samples:
|
|
4824
|
-
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"]))
|
|
4825
4880
|
if tc_err:
|
|
4826
4881
|
sample["error"] = tc_err.get("error")
|
|
4827
4882
|
sampled.append(sample)
|
|
@@ -6139,6 +6194,7 @@ _MEDIA_POOL_KERNEL_ACTIONS = [
|
|
|
6139
6194
|
"metadata_field_inventory",
|
|
6140
6195
|
"safe_relink",
|
|
6141
6196
|
"safe_unlink",
|
|
6197
|
+
"check_proxy_media_compatibility",
|
|
6142
6198
|
"link_proxy_checked",
|
|
6143
6199
|
"link_full_resolution_checked",
|
|
6144
6200
|
"set_clip_marks",
|
|
@@ -9474,7 +9530,8 @@ def _media_pool_ingest_capabilities():
|
|
|
9474
9530
|
"relink/unlink through Resolve MediaPool APIs",
|
|
9475
9531
|
"media_pool.safe_relink and safe_unlink with path/clip validation",
|
|
9476
9532
|
"proxy link/unlink through MediaPoolItem APIs",
|
|
9477
|
-
"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",
|
|
9478
9535
|
"full-resolution media link where Resolve 20 exposes it",
|
|
9479
9536
|
"media_pool.link_full_resolution_checked with version/path validation",
|
|
9480
9537
|
],
|
|
@@ -9959,6 +10016,466 @@ def _safe_unlink(mp, root, p: Dict[str, Any]):
|
|
|
9959
10016
|
return {"success": bool(mp.UnlinkClips(clips)), "count": len(clips), "missing": missing}
|
|
9960
10017
|
|
|
9961
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
|
+
|
|
9962
10479
|
def _link_proxy_checked(root, p: Dict[str, Any]):
|
|
9963
10480
|
clip = _find_clip(root, p.get("clip_id", ""))
|
|
9964
10481
|
if not clip:
|
|
@@ -9967,9 +10484,123 @@ def _link_proxy_checked(root, p: Dict[str, Any]):
|
|
|
9967
10484
|
path_err = _path_error(proxy_path, must_be_file=True)
|
|
9968
10485
|
if path_err:
|
|
9969
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"))
|
|
9970
10524
|
if p.get("dry_run"):
|
|
9971
|
-
|
|
9972
|
-
|
|
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
|
|
9973
10604
|
|
|
9974
10605
|
|
|
9975
10606
|
def _link_full_resolution_checked(root, p: Dict[str, Any]):
|
|
@@ -12553,6 +13184,35 @@ class _SpecLiveExecutor:
|
|
|
12553
13184
|
self._spec = spec
|
|
12554
13185
|
self._proj = pm.GetCurrentProject()
|
|
12555
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
|
+
|
|
12556
13216
|
def live_state(self) -> Dict[str, Any]:
|
|
12557
13217
|
proj = self._proj
|
|
12558
13218
|
projects = list(self._pm.GetProjectListInCurrentFolder() or [])
|
|
@@ -12602,6 +13262,7 @@ class _SpecLiveExecutor:
|
|
|
12602
13262
|
"project": proj.GetName() if proj else None,
|
|
12603
13263
|
"projects": projects,
|
|
12604
13264
|
"settings": settings,
|
|
13265
|
+
"bins": self._media_pool_bin_paths(),
|
|
12605
13266
|
"timelines": timelines,
|
|
12606
13267
|
}
|
|
12607
13268
|
|
|
@@ -12623,6 +13284,18 @@ class _SpecLiveExecutor:
|
|
|
12623
13284
|
except Exception:
|
|
12624
13285
|
return False
|
|
12625
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
|
+
|
|
12626
13299
|
def ensure_timeline(self, name: str, fps: Optional[float]) -> bool:
|
|
12627
13300
|
if not self._proj:
|
|
12628
13301
|
return False
|
|
@@ -12751,13 +13424,36 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
|
|
|
12751
13424
|
except Exception:
|
|
12752
13425
|
pass
|
|
12753
13426
|
item_count = 0
|
|
13427
|
+
video_item_count = 0
|
|
13428
|
+
audio_item_count = 0
|
|
13429
|
+
subtitle_item_count = 0
|
|
12754
13430
|
try:
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
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
|
|
12758
13447
|
except Exception:
|
|
12759
13448
|
pass
|
|
12760
|
-
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
|
+
})
|
|
12761
13457
|
state["timelines"] = timelines
|
|
12762
13458
|
settings: Dict[str, Any] = {}
|
|
12763
13459
|
try:
|
|
@@ -13823,7 +14519,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13823
14519
|
metadata_field_inventory(clip_ids|selected, include_values?) -> {items, ui_group_names}
|
|
13824
14520
|
safe_relink(clip_ids|selected, folder_path, dry_run?) -> {success}
|
|
13825
14521
|
safe_unlink(clip_ids|selected, dry_run?) -> {success}
|
|
13826
|
-
|
|
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}
|
|
13827
14524
|
link_full_resolution_checked(clip_id, path|full_res_media_path, dry_run?) -> {success}
|
|
13828
14525
|
set_clip_marks(clip_ids|selected, mark_in, mark_out, type?, dry_run?) -> {success, results}
|
|
13829
14526
|
clear_clip_marks(clip_ids|selected, type?, dry_run?) -> {success, results}
|
|
@@ -13965,7 +14662,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13965
14662
|
if row_err:
|
|
13966
14663
|
return row_err
|
|
13967
14664
|
built.append(row)
|
|
13968
|
-
|
|
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
|
+
)
|
|
13969
14677
|
if not result:
|
|
13970
14678
|
return _err("Failed to append clip_infos to timeline")
|
|
13971
14679
|
items_out = []
|
|
@@ -13974,14 +14682,38 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13974
14682
|
if item_err:
|
|
13975
14683
|
return item_err
|
|
13976
14684
|
items_out.append(item_out)
|
|
13977
|
-
|
|
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
|
|
13978
14692
|
clip_ids = p.get("clip_ids")
|
|
13979
14693
|
if not clip_ids:
|
|
13980
14694
|
return _err("Provide clip_ids (simple append) or clip_infos (positioned append)")
|
|
13981
14695
|
clips = [_find_clip(root, cid) for cid in clip_ids]
|
|
13982
14696
|
clips = [c for c in clips if c]
|
|
13983
|
-
|
|
13984
|
-
|
|
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
|
|
13985
14717
|
elif action == "import_media":
|
|
13986
14718
|
if p.get("clip_infos") is not None:
|
|
13987
14719
|
raw = p["clip_infos"]
|
|
@@ -14086,6 +14818,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14086
14818
|
return _safe_relink(mp, root, p)
|
|
14087
14819
|
elif action == "safe_unlink":
|
|
14088
14820
|
return _safe_unlink(mp, root, p)
|
|
14821
|
+
elif action == "check_proxy_media_compatibility":
|
|
14822
|
+
return _check_proxy_media_compatibility_checked(root, p)
|
|
14089
14823
|
elif action == "link_proxy_checked":
|
|
14090
14824
|
return _link_proxy_checked(root, p)
|
|
14091
14825
|
elif action == "link_full_resolution_checked":
|
|
@@ -16330,6 +17064,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
16330
17064
|
apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?) -> {success}
|
|
16331
17065
|
# example: action_help(name='<action_name>')
|
|
16332
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.
|
|
16333
17068
|
marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance}
|
|
16334
17069
|
edit_kernel_capabilities() -> {supported, partially_supported, unsupported}
|
|
16335
17070
|
probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count}
|
|
@@ -16936,6 +17671,12 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
16936
17671
|
def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
16937
17672
|
"""Markers and playhead operations on the current timeline.
|
|
16938
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
|
+
|
|
16939
17680
|
Actions:
|
|
16940
17681
|
add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame}
|
|
16941
17682
|
If frame/timecode is omitted, add uses the current playhead timecode.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Best-effort deletion of disposable Resolve projects.
|
|
2
|
+
|
|
3
|
+
DeleteProject is flaky on some Resolve builds: it silently returns False when
|
|
4
|
+
the target is (or was very recently) the current project, and occasionally on
|
|
5
|
+
the first attempt even when it isn't. Disposable test projects then linger in
|
|
6
|
+
the project library. This helper centralizes the mitigation so every
|
|
7
|
+
disposable-project flow gets it for free:
|
|
8
|
+
|
|
9
|
+
1. make sure the target is not the current project (load a fallback project,
|
|
10
|
+
or close the target if no fallback is available),
|
|
11
|
+
2. retry the delete once after a short pause,
|
|
12
|
+
3. report the leftover by name when it still fails, so callers can surface it
|
|
13
|
+
instead of silently leaking.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def delete_project_safely(
|
|
21
|
+
pm: Any,
|
|
22
|
+
name: str,
|
|
23
|
+
*,
|
|
24
|
+
switch_to: Optional[str] = None,
|
|
25
|
+
retries: int = 1,
|
|
26
|
+
delay_seconds: float = 1.0,
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""Delete project `name` via project-manager handle `pm`, working around
|
|
29
|
+
DeleteProject flakiness. Returns {success, attempts, leftover, detail}.
|
|
30
|
+
|
|
31
|
+
`switch_to`: project to load first when `name` is current (e.g. the
|
|
32
|
+
project that was open before the disposable one was created). Without it,
|
|
33
|
+
the current project is closed instead.
|
|
34
|
+
"""
|
|
35
|
+
attempts = 0
|
|
36
|
+
detail = ""
|
|
37
|
+
try:
|
|
38
|
+
current = None
|
|
39
|
+
try:
|
|
40
|
+
project = pm.GetCurrentProject()
|
|
41
|
+
current = project.GetName() if project else None
|
|
42
|
+
except Exception:
|
|
43
|
+
current = None
|
|
44
|
+
if current == name:
|
|
45
|
+
switched = False
|
|
46
|
+
if switch_to and switch_to != name:
|
|
47
|
+
try:
|
|
48
|
+
switched = bool(pm.LoadProject(switch_to))
|
|
49
|
+
except Exception:
|
|
50
|
+
switched = False
|
|
51
|
+
if not switched:
|
|
52
|
+
try:
|
|
53
|
+
project = pm.GetCurrentProject()
|
|
54
|
+
if project is not None:
|
|
55
|
+
pm.CloseProject(project)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
last_error = None
|
|
60
|
+
for attempt in range(1 + max(0, int(retries))):
|
|
61
|
+
attempts = attempt + 1
|
|
62
|
+
try:
|
|
63
|
+
if bool(pm.DeleteProject(name)):
|
|
64
|
+
return {"success": True, "attempts": attempts, "leftover": None, "detail": ""}
|
|
65
|
+
last_error = "DeleteProject returned False"
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
last_error = str(exc)
|
|
68
|
+
if attempt < retries:
|
|
69
|
+
time.sleep(max(0.0, delay_seconds))
|
|
70
|
+
detail = last_error or "DeleteProject failed"
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
detail = str(exc)
|
|
73
|
+
return {"success": False, "attempts": attempts, "leftover": name, "detail": detail}
|
|
@@ -50,6 +50,15 @@ def _color_science_unset(settings: Dict[str, Any]) -> bool:
|
|
|
50
50
|
return mode in ("", "davinciYRGB")
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _timeline_has_items(timeline: Dict[str, Any]) -> bool:
|
|
54
|
+
if int(timeline.get("item_count") or 0) > 0:
|
|
55
|
+
return True
|
|
56
|
+
for key in ("video_item_count", "audio_item_count", "subtitle_item_count"):
|
|
57
|
+
if int(timeline.get(key) or 0) > 0:
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
53
62
|
def lint_state(state: Dict[str, Any]) -> List[Issue]:
|
|
54
63
|
"""Return graded issues for a project state dict, most-severe first."""
|
|
55
64
|
issues: List[Issue] = []
|
|
@@ -73,7 +82,7 @@ def lint_state(state: Dict[str, Any]) -> List[Issue]:
|
|
|
73
82
|
))
|
|
74
83
|
|
|
75
84
|
for tl in timelines:
|
|
76
|
-
if (tl
|
|
85
|
+
if not _timeline_has_items(tl):
|
|
77
86
|
issues.append(Issue(
|
|
78
87
|
"warning", "empty_timeline",
|
|
79
88
|
f"Timeline '{tl.get('name')}' has no clips.",
|
|
@@ -83,6 +83,7 @@ class Spec:
|
|
|
83
83
|
project: str
|
|
84
84
|
color_preset: Optional[str] = None
|
|
85
85
|
settings: Dict[str, str] = field(default_factory=dict)
|
|
86
|
+
bins: List[str] = field(default_factory=list)
|
|
86
87
|
timelines: List[TimelineSpec] = field(default_factory=list)
|
|
87
88
|
hooks: List[Hook] = field(default_factory=list)
|
|
88
89
|
|
|
@@ -145,6 +146,20 @@ def spec_from_dict(data: Dict[str, Any]) -> Spec:
|
|
|
145
146
|
if not isinstance(settings, dict):
|
|
146
147
|
raise SpecError("`settings` must be a mapping.")
|
|
147
148
|
|
|
149
|
+
bins: List[str] = []
|
|
150
|
+
for raw_bin in data.get("bins") or []:
|
|
151
|
+
if isinstance(raw_bin, str) and raw_bin.strip():
|
|
152
|
+
bin_path = raw_bin.strip().strip("/")
|
|
153
|
+
elif isinstance(raw_bin, dict) and raw_bin.get("path"):
|
|
154
|
+
bin_path = str(raw_bin["path"]).strip().strip("/")
|
|
155
|
+
else:
|
|
156
|
+
raise SpecError(f"Each bin needs a non-empty path: {raw_bin!r}")
|
|
157
|
+
# Live bin paths are always reported Master-prefixed; normalize here so
|
|
158
|
+
# an unprefixed spec bin doesn't read as a perpetually-pending change.
|
|
159
|
+
if bin_path != "Master" and not bin_path.startswith("Master/"):
|
|
160
|
+
bin_path = f"Master/{bin_path}"
|
|
161
|
+
bins.append(bin_path)
|
|
162
|
+
|
|
148
163
|
timelines: List[TimelineSpec] = []
|
|
149
164
|
for raw_tl in (data.get("timelines") or []):
|
|
150
165
|
if not isinstance(raw_tl, dict) or not raw_tl.get("name"):
|
|
@@ -160,6 +175,7 @@ def spec_from_dict(data: Dict[str, Any]) -> Spec:
|
|
|
160
175
|
project=str(project),
|
|
161
176
|
color_preset=preset,
|
|
162
177
|
settings={str(k): str(v) for k, v in settings.items()},
|
|
178
|
+
bins=bins,
|
|
163
179
|
timelines=timelines,
|
|
164
180
|
hooks=_parse_hooks(data.get("hooks")),
|
|
165
181
|
)
|
|
@@ -252,6 +268,15 @@ def plan_spec(spec: Spec, live: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
252
268
|
f"{live_settings.get(key)!r} -> {want!r}", {"key": key, "value": want},
|
|
253
269
|
))
|
|
254
270
|
|
|
271
|
+
# Media Pool bins. Paths are slash-delimited from Master, e.g.
|
|
272
|
+
# "Master/Media/Scene_01"; creation is idempotent in the executor.
|
|
273
|
+
live_bins = set(live.get("bins") or [])
|
|
274
|
+
for bin_path in spec.bins:
|
|
275
|
+
if bin_path in live_bins:
|
|
276
|
+
actions.append(Action("noop", f"bin:{bin_path}", "exists"))
|
|
277
|
+
else:
|
|
278
|
+
actions.append(Action("ensure", f"bin:{bin_path}", "ensure media-pool bin", {"path": bin_path}))
|
|
279
|
+
|
|
255
280
|
# Timelines + their settings + markers. NOTE: `fps` is a *creation-time*
|
|
256
281
|
# property handled by ensure_timeline — Resolve refuses SetSetting on
|
|
257
282
|
# timelineFrameRate after a timeline exists — so it is never emitted as a
|
|
@@ -300,6 +325,7 @@ def _spec_desired_state(spec: Spec) -> Dict[str, Any]:
|
|
|
300
325
|
return {
|
|
301
326
|
"project": spec.project,
|
|
302
327
|
"settings": {k: _norm_setting_value(v) for k, v in effective_settings(spec).items()},
|
|
328
|
+
"bins": list(spec.bins),
|
|
303
329
|
"timelines": [
|
|
304
330
|
{
|
|
305
331
|
"name": tl.name,
|
|
@@ -324,6 +350,7 @@ def _spec_normalized_state(live: Dict[str, Any], spec: Spec) -> Dict[str, Any]:
|
|
|
324
350
|
return {
|
|
325
351
|
"project": live.get("project"),
|
|
326
352
|
"settings": {k: _norm_setting_value(live_settings.get(k)) for k in desired if k in live_settings},
|
|
353
|
+
"bins": [path for path in (live.get("bins") or []) if path in set(spec.bins)],
|
|
327
354
|
"timelines": [
|
|
328
355
|
{
|
|
329
356
|
"name": name,
|
|
@@ -356,6 +383,7 @@ def apply_spec(
|
|
|
356
383
|
ensure_timeline(name, fps) -> bool
|
|
357
384
|
set_timeline_setting(timeline_name, key, value) -> bool
|
|
358
385
|
add_marker(timeline_name, marker: dict) -> bool
|
|
386
|
+
ensure_bin(path) -> bool
|
|
359
387
|
|
|
360
388
|
Hooks execute only when `run_hooks=True` AND a `run_hook` callable is given —
|
|
361
389
|
arbitrary shell from a spec stays opt-in. Failures accumulate when
|
|
@@ -397,6 +425,9 @@ def apply_spec(
|
|
|
397
425
|
_record(bool(executor.set_project_setting(key, desired[key])),
|
|
398
426
|
f"project:{spec.project}/setting:{key}", str(desired[key]))
|
|
399
427
|
|
|
428
|
+
for bin_path in spec.bins:
|
|
429
|
+
_record(bool(executor.ensure_bin(bin_path)), f"bin:{bin_path}")
|
|
430
|
+
|
|
400
431
|
# Timelines. `fps` is creation-time only (ensure_timeline); never set as a
|
|
401
432
|
# post-creation timelineFrameRate setting — Resolve refuses that.
|
|
402
433
|
live_tls = {tl.get("name"): tl for tl in (live.get("timelines") or [])}
|