davinci-resolve-mcp 2.45.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 CHANGED
@@ -2,6 +2,33 @@
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.0
6
+
7
+ Community PR bundle: five contributed fixes and features (#62–#66), live-validated
8
+ on Resolve Studio 21.
9
+
10
+ - **Fixed** timeline marker frames are now correctly relative to the timeline
11
+ start (#66): timecode params and the playhead default rebase by
12
+ `GetStartFrame()`, so markers on hour-start timelines land where the UI
13
+ shows them instead of an hour past the end. Raw `frame` params pass through
14
+ unchanged (documented as relative). Contact sheets and marker thumbnail
15
+ review rebase the other way with a legacy-absolute guard.
16
+ - **Fixed** project lint no longer flags audio-only timelines as empty (#62);
17
+ live lint state now reports per-type `video/audio/subtitle_item_count`.
18
+ - **Added** `media_pool.check_proxy_media_compatibility` (#63): ffprobe-vs-source
19
+ signature diagnostics (resolution/fps/frames/sample rate, expected
20
+ codec/profile). Same-aspect downscales count as compatible — half-res
21
+ proxies are the normal workflow.
22
+ - **Added** readback verification envelopes (`verified_operation`) around
23
+ `media_pool.link_proxy_checked` (#63) and `media_pool.append_to_timeline`
24
+ (#64): preflight, execution, post-state readback, verification status, and a
25
+ journal event. `link_proxy_checked` gains `check_compatibility` /
26
+ `require_compatible` guards that refuse incompatible proxies before linking.
27
+ - **Added** `bins` to declarative project specs (#65): missing media-pool bin
28
+ paths plan as `ensure` actions and are created idempotently; existing bins
29
+ are noops. Bin paths normalize to the `Master/` prefix so unprefixed spec
30
+ bins converge instead of reporting perpetual drift.
31
+
5
32
  ## What's New in v2.45.0
6
33
 
7
34
  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
- [![Version](https://img.shields.io/badge/version-2.45.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.46.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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.45.0"
38
+ VERSION = "2.46.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.45.0",
3
+ "version": "2.46.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.45.0"
83
+ VERSION = "2.46.0"
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()}")
@@ -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.45.0"
14
+ VERSION = "2.46.0"
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 _timeline_timecode_to_frame_id(tl, raw_timecode)
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 _current_timeline_frame_id(tl)
1443
+ return _current_timeline_marker_frame_id(tl)
1389
1444
  if ":" in raw_frame or ";" in raw_frame:
1390
- return _timeline_timecode_to_frame_id(tl, raw_frame)
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 _current_timeline_frame_id(tl)
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, int(sample["frame"]))
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.link_proxy_checked with file validation",
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
- return _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path)
9972
- return {"success": bool(clip.LinkProxyMedia(proxy_path))}
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
- vc = int(tl.GetTrackCount("video") or 0)
12756
- for ti in range(1, vc + 1):
12757
- item_count += len(tl.GetItemListInTrack("video", ti) or [])
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({"name": tl.GetName(), "fps": fps, "item_count": item_count})
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
- link_proxy_checked(clip_id, proxy_path|path, dry_run?) -> {success}
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
- result = mp.AppendToTimeline(built)
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
- return _ok(count=len(result), items=items_out)
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
- result = mp.AppendToTimeline(clips)
13984
- return _ok(count=len(result) if result else 0)
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.
@@ -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.get("item_count") or 0) == 0:
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 [])}