davinci-resolve-mcp 2.58.0 → 2.59.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/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.58.0"
14
+ VERSION = "2.59.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -4340,6 +4340,7 @@ _TIMELINE_CONFORM_KERNEL_ACTIONS = [
4340
4340
  "source_range_report",
4341
4341
  "export_timeline_checked",
4342
4342
  "import_timeline_checked",
4343
+ "import_from_drp",
4343
4344
  "compare_timelines",
4344
4345
  "probe_interchange_roundtrip",
4345
4346
  "detect_missing_media",
@@ -5345,18 +5346,108 @@ def _timeline_media_coverage(tl) -> Dict[str, Any]:
5345
5346
  return {"total": total, "linked": linked, "offline": total - linked}
5346
5347
 
5347
5348
 
5349
+ def _collect_media_pool_items(tl):
5350
+ """Unique Media Pool Items referenced by a timeline's items (for RelinkClips)."""
5351
+ items = []
5352
+ seen = set()
5353
+ for track_type in ("video", "audio"):
5354
+ try:
5355
+ count = int(tl.GetTrackCount(track_type) or 0)
5356
+ except Exception:
5357
+ count = 0
5358
+ for i in range(1, count + 1):
5359
+ for item in (tl.GetItemListInTrack(track_type, i) or []):
5360
+ try:
5361
+ mpi = item.GetMediaPoolItem()
5362
+ except Exception:
5363
+ mpi = None
5364
+ if mpi is not None and id(mpi) not in seen:
5365
+ seen.add(id(mpi))
5366
+ items.append(mpi)
5367
+ return items
5368
+
5369
+
5370
+ def _binary_post_import_relink(tl, mp, search_roots):
5371
+ """Fuzzy-relink parity for binary interchange (AAF): after import, ask Resolve's media pool
5372
+ to relink the timeline's Media Pool Items against each search root. This is the media-pool
5373
+ analogue of the XML path-rewrite relink (which can't run on a binary file)."""
5374
+ roots = [r for r in (search_roots or []) if isinstance(r, str) and os.path.isdir(r)]
5375
+ if not roots:
5376
+ return {"attempted": False, "reason": "no existing search roots", "roots": search_roots or []}
5377
+ items = _collect_media_pool_items(tl)
5378
+ if not items:
5379
+ return {"attempted": False, "reason": "no Media Pool Items to relink", "roots": roots}
5380
+ before = _timeline_media_coverage(tl)
5381
+ calls = []
5382
+ for root in roots:
5383
+ try:
5384
+ ok = bool(mp.RelinkClips(items, root))
5385
+ calls.append({"root": root, "ok": ok})
5386
+ except Exception as e:
5387
+ calls.append({"root": root, "ok": False, "error": str(e)})
5388
+ after = _timeline_media_coverage(tl)
5389
+ return {
5390
+ "attempted": True,
5391
+ "roots": roots,
5392
+ "item_count": len(items),
5393
+ "calls": calls,
5394
+ "before": before,
5395
+ "after": after,
5396
+ "linked_delta": after["linked"] - before["linked"],
5397
+ }
5398
+
5399
+
5400
+ _PRPROJ_REFUSAL = (
5401
+ "Resolve has no native .prproj importer (Premiere's project format is gzip-compressed "
5402
+ "XML, not an interchange). Two offline routes, no Premiere needed: (1) read it with the "
5403
+ "advanced MCP — editorial.list_sequences / editorial.parse_interchange (format 'prproj'); "
5404
+ "(2) convert it to an importable interchange — editorial.convert_to_interchange "
5405
+ "(target 'otio'|'edl'|'drt') — then import that here with import_timeline_checked. "
5406
+ "Editorial timing/cuts/transitions/speed carry over; per-clip effects/Lumetri color do not. "
5407
+ "Alternatively export FCP7 XML / AAF / FCPXML from Premiere and conform that."
5408
+ )
5409
+
5410
+ # Binary interchange formats Resolve reads NATIVELY — the XML sanitize/relink pass
5411
+ # (which parses the file as text) does not apply. Relinking happens via the media
5412
+ # pool after import, not by rewriting the file.
5413
+ _BINARY_INTERCHANGE_EXTS = {".aaf"}
5414
+
5415
+
5348
5416
  def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5349
5417
  path = p.get("path")
5350
5418
  if not path:
5351
5419
  return _err("path is required")
5352
5420
  if not os.path.exists(path):
5353
5421
  return _err(f"path does not exist: {path}")
5422
+ ext = os.path.splitext(path)[1].lower()
5423
+ if ext == ".prproj":
5424
+ return _err(_PRPROJ_REFUSAL, category="invalid_input")
5425
+ # AAF (and any binary interchange) is read natively by Resolve. The XML
5426
+ # sanitize/relink path parses the file as text, so it must be SKIPPED for AAF
5427
+ # even when sanitize_media / relink_search_roots is passed; fuzzy XML relink is
5428
+ # N/A (media links through the media pool). We still detect the created
5429
+ # (possibly offline) timeline via the before/after id diff.
5430
+ is_binary = ext in _BINARY_INTERCHANGE_EXTS
5354
5431
  # sanitize_media (alias: relink_media) rewrites the XML to drop clipitems that
5355
5432
  # reference missing media or are generators (slug/solid w/ no pathurl) — both
5356
5433
  # abort Resolve's scripting-API import and leave the timeline fully offline.
5357
5434
  # The original is only read; the sanitized copy lands in a temp dir, so the
5358
5435
  # temp-path guard never applies to it.
5359
- sanitize = bool(p.get("sanitize_media") or p.get("relink_media"))
5436
+ sanitize_requested = bool(p.get("sanitize_media") or p.get("relink_media"))
5437
+ binary_relink_note = None
5438
+ _binary_has_roots = bool(p.get("relink_search_roots") or p.get("search_roots"))
5439
+ if is_binary and _binary_has_roots:
5440
+ binary_relink_note = (
5441
+ f"XML path-rewrite relink is N/A for {ext} (binary) — imported as-is, then relinked "
5442
+ "via the media pool against the search roots (see `relink`)."
5443
+ )
5444
+ elif is_binary and sanitize_requested:
5445
+ binary_relink_note = (
5446
+ f"sanitize_media is N/A for {ext} (binary) — Resolve reads it natively and media "
5447
+ "relinks via the media pool. Imported the file as-is; pass relink_search_roots to "
5448
+ "auto-relink after import."
5449
+ )
5450
+ sanitize = sanitize_requested and not is_binary
5360
5451
  if not sanitize and p.get("require_temp_path", True) and not _render_temp_path_ok(path):
5361
5452
  return _err(
5362
5453
  "path must be under the system temp directory unless require_temp_path=False",
@@ -5375,7 +5466,7 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5375
5466
  search_roots = p.get("relink_search_roots") or p.get("search_roots")
5376
5467
  if isinstance(search_roots, str):
5377
5468
  search_roots = [search_roots]
5378
- if search_roots and not sanitize:
5469
+ if search_roots and not sanitize and not is_binary:
5379
5470
  sanitize = True # relinking is meaningless without the sanitize/rewrite pass
5380
5471
 
5381
5472
  sanitize_report = None
@@ -5412,6 +5503,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5412
5503
  out = _ok(path=path, import_path=import_path, options=options, would_import=True)
5413
5504
  if sanitize_report is not None:
5414
5505
  out["sanitize"] = sanitize_report
5506
+ if binary_relink_note:
5507
+ out["note"] = binary_relink_note
5415
5508
  return out
5416
5509
 
5417
5510
  before_ids = set()
@@ -5432,15 +5525,32 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5432
5525
  if t and str(t.GetUniqueId()) not in before_ids:
5433
5526
  imported = t
5434
5527
  if not imported:
5528
+ if is_binary:
5529
+ remediation = (
5530
+ f"Resolve created no timeline from this {ext}. Verify it exports/opens in "
5531
+ "Resolve directly, or convert to FCP7 XML / FCPXML upstream and import that."
5532
+ )
5533
+ elif sanitize:
5534
+ remediation = None
5535
+ else:
5536
+ remediation = (
5537
+ "This XML may reference missing media or contain generator clips, which "
5538
+ "abort the scripting-API import. Retry with sanitize_media=True."
5539
+ )
5435
5540
  return _err(
5436
5541
  "Failed to import timeline (Resolve created no timeline)",
5437
- remediation=None if sanitize else
5438
- "This XML may reference missing media or contain generator clips, which "
5439
- "abort the scripting-API import. Retry with sanitize_media=True.",
5542
+ remediation=remediation,
5440
5543
  )
5441
5544
 
5442
5545
  imported_id = str(imported.GetUniqueId())
5443
5546
  media = _timeline_media_coverage(imported)
5547
+ # AAF fuzzy-relink parity: when search roots are given for a binary import, relink through
5548
+ # the media pool after import (the XML path-rewrite relink can't run on a binary file).
5549
+ relink_result = None
5550
+ if is_binary and search_roots:
5551
+ relink_result = _binary_post_import_relink(imported, mp, search_roots)
5552
+ if relink_result.get("after"):
5553
+ media = relink_result["after"]
5444
5554
  out = _ok(
5445
5555
  name=imported.GetName(),
5446
5556
  id=imported_id,
@@ -5451,15 +5561,166 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5451
5561
  )
5452
5562
  if sanitize_report is not None:
5453
5563
  out["sanitize"] = sanitize_report
5564
+ if relink_result is not None:
5565
+ out["relink"] = relink_result
5566
+ if binary_relink_note:
5567
+ out["note"] = binary_relink_note
5454
5568
  if media["total"] and media["offline"]:
5455
5569
  msg = f"{media['offline']} of {media['total']} timeline items are offline (no linked media)."
5456
- if not sanitize:
5570
+ if is_binary:
5571
+ msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
5572
+ "at the media roots — AAF media links there, not by rewriting the file.")
5573
+ elif not sanitize:
5457
5574
  msg += (" Retry with sanitize_media=True to drop missing-media/generator "
5458
5575
  "clips so the remaining media links automatically.")
5459
5576
  out["warning"] = msg
5460
5577
  return out
5461
5578
 
5462
5579
 
5580
+ # A .drp/.drt is a zip of SeqContainer*.xml entries. Two on-disk naming conventions,
5581
+ # mirroring the Node drt-format parser: tool-authored `<folder>/SeqContainer<N>.xml`
5582
+ # and real-Resolve `SeqContainer/<uuid>.xml`. Never match MpFolder/project/Gallery.
5583
+ _SEQ_CONTAINER_RE = re.compile(r"(^|/)SeqContainer(\d*\.xml|/[^/]+\.xml)$")
5584
+
5585
+
5586
+ def _drp_seq_containers(zf) -> List[Dict[str, Any]]:
5587
+ """List a .drp/.drt zip's SeqContainers as [{entry, name, index}] (0-based, sorted)."""
5588
+ entries = sorted(n for n in zf.namelist() if not n.endswith("/") and _SEQ_CONTAINER_RE.search(n))
5589
+ out = []
5590
+ for index, entry in enumerate(entries):
5591
+ try:
5592
+ xml = zf.read(entry).decode("utf-8", "replace")
5593
+ except Exception:
5594
+ xml = ""
5595
+ m = re.search(r"<Name>([\s\S]*?)</Name>", xml)
5596
+ out.append({"entry": entry, "name": (m.group(1).strip() if m else None), "index": index})
5597
+ return out
5598
+
5599
+
5600
+ def _extract_seqcontainer_from_drp(drp_path: str, seq_entry: str, out_path: str) -> None:
5601
+ """Write a minimal .drt (zip) holding one SeqContainer as Primary1/SeqContainer1.xml."""
5602
+ import zipfile
5603
+
5604
+ with zipfile.ZipFile(drp_path, "r") as zf:
5605
+ xml = zf.read(seq_entry)
5606
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as out:
5607
+ out.writestr("Primary1/SeqContainer1.xml", xml)
5608
+ out.writestr(
5609
+ "metadata.json",
5610
+ json.dumps(
5611
+ {
5612
+ "source": "import_from_drp",
5613
+ "sourceDrp": drp_path,
5614
+ "sourceSeqContainer": seq_entry,
5615
+ "exportedFrom": "davinci-resolve-mcp timeline.import_from_drp",
5616
+ },
5617
+ indent=2,
5618
+ ),
5619
+ )
5620
+
5621
+
5622
+ def _import_from_drp(proj, mp, p: Dict[str, Any]):
5623
+ """
5624
+ Extract the chosen timelines from a .drp (offline zip surgery) and import each into the
5625
+ running Resolve. Requires Resolve open. Mirrors: drt.extract_from_drp → import_timeline_checked,
5626
+ but as one call returning per-timeline results.
5627
+ """
5628
+ import zipfile
5629
+
5630
+ drp_path = p.get("drpPath") or p.get("drp_path") or p.get("path")
5631
+ if not drp_path:
5632
+ return _err("drpPath is required")
5633
+ if not os.path.exists(drp_path):
5634
+ return _err(f"drpPath does not exist: {drp_path}")
5635
+ ext = os.path.splitext(drp_path)[1].lower()
5636
+ if ext == ".prproj":
5637
+ return _err(_PRPROJ_REFUSAL, category="invalid_input")
5638
+
5639
+ try:
5640
+ with zipfile.ZipFile(drp_path, "r") as zf:
5641
+ containers = _drp_seq_containers(zf)
5642
+ except zipfile.BadZipFile:
5643
+ return _err(f"Not a valid .drp/.drt (not a zip archive): {drp_path}", category="invalid_input")
5644
+ if not containers:
5645
+ return _err(f"No SeqContainer entries found in {drp_path} — is this a .drp/.drt?", category="invalid_input")
5646
+
5647
+ # Selection: by name(s), by 0-based index(es), or ALL when neither is given.
5648
+ want_names = p.get("timelineNames") or p.get("timeline_names")
5649
+ if isinstance(want_names, str):
5650
+ want_names = [want_names]
5651
+ want_indexes = p.get("timelineIndexes") or p.get("timeline_indexes")
5652
+ if isinstance(want_indexes, int):
5653
+ want_indexes = [want_indexes]
5654
+
5655
+ selected: List[Dict[str, Any]] = []
5656
+ available = [{"name": c["name"], "index": c["index"]} for c in containers]
5657
+ if want_names:
5658
+ by_name = {}
5659
+ for c in containers:
5660
+ if c["name"]:
5661
+ by_name.setdefault(c["name"], c)
5662
+ for nm in want_names:
5663
+ c = by_name.get(nm)
5664
+ if not c:
5665
+ return _err(
5666
+ f"Timeline named {nm!r} not found in .drp",
5667
+ remediation=f"Available: {[a['name'] for a in available]}",
5668
+ category="invalid_input",
5669
+ )
5670
+ selected.append(c)
5671
+ elif want_indexes:
5672
+ for i in want_indexes:
5673
+ match = next((c for c in containers if c["index"] == int(i)), None)
5674
+ if not match:
5675
+ return _err(f"timelineIndex {i} out of range (0..{len(containers) - 1})", category="invalid_input")
5676
+ selected.append(match)
5677
+ else:
5678
+ selected = list(containers)
5679
+
5680
+ # Per-timeline options passthrough (importSourceClips etc.) minus selection keys.
5681
+ base = {
5682
+ k: v
5683
+ for k, v in p.items()
5684
+ if k not in ("drpPath", "drp_path", "path", "timelineNames", "timeline_names", "timelineIndexes", "timeline_indexes", "dry_run")
5685
+ }
5686
+
5687
+ tmp_dir = tempfile.mkdtemp(prefix="drp_import_")
5688
+ results = []
5689
+ imported_count = 0
5690
+ for c in selected:
5691
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "_", c["name"] or f"seq{c['index'] + 1}")
5692
+ drt_path = os.path.join(tmp_dir, f"{c['index'] + 1}_{safe}.drt")
5693
+ entry = {"requested": c["name"], "index": c["index"]}
5694
+ try:
5695
+ _extract_seqcontainer_from_drp(drp_path, c["entry"], drt_path)
5696
+ except Exception as e:
5697
+ entry.update(success=False, error=f"extract failed: {e}")
5698
+ results.append(entry)
5699
+ continue
5700
+ if p.get("dry_run"):
5701
+ entry.update(success=True, would_import=True, drt_path=drt_path)
5702
+ results.append(entry)
5703
+ continue
5704
+ sub = dict(base)
5705
+ sub["path"] = drt_path
5706
+ # .drt lives under a temp dir; the temp-path guard passes regardless.
5707
+ res = _import_timeline_checked(proj, mp, sub)
5708
+ ok = bool(res.get("success"))
5709
+ entry.update(res)
5710
+ if ok:
5711
+ imported_count += 1
5712
+ results.append(entry)
5713
+
5714
+ return _ok(
5715
+ drpPath=drp_path,
5716
+ selected=len(selected),
5717
+ imported=imported_count,
5718
+ available=available,
5719
+ results=results,
5720
+ dry_run=bool(p.get("dry_run")),
5721
+ )
5722
+
5723
+
5463
5724
  def _timeline_by_selector(proj, p: Dict[str, Any], *, prefix: str):
5464
5725
  timeline_id = p.get(f"{prefix}_timeline_id") or p.get(f"{prefix}_id")
5465
5726
  timeline_index = p.get(f"{prefix}_timeline_index") or p.get(f"{prefix}_index")
@@ -18366,7 +18627,14 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18366
18627
  source_range_report(handles?, merge?) -> {ranges, occurrences}
18367
18628
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?) -> {success, path, size}
18368
18629
  import_timeline_checked(path, options?, timeline_name?, import_source_clips?, sanitize_media?, require_temp_path?, dry_run?) -> {success, name, id, media, sanitize?, warning?}
18369
- Imports an FCP7 xmeml / FCPXML timeline. `media` reports {total, linked, offline}
18630
+ Imports an FCP7 xmeml / FCPXML / AAF timeline. AAF (.aaf) is read natively by
18631
+ Resolve — the XML sanitize pass is SKIPPED for it (media relinks via the media
18632
+ pool, not by rewriting the file); a `note` says so. For AAF, passing
18633
+ relink_search_roots triggers a POST-import media-pool relink (RelinkClips against
18634
+ each root) — fuzzy-relink parity by a media-pool mechanism; the `relink` block
18635
+ reports before/after coverage. Premiere .prproj has no Resolve importer — refused
18636
+ with a message pointing to the advanced MCP's offline read + convert_to_interchange
18637
+ bridge (no Premiere needed). `media` reports {total, linked, offline}
18370
18638
  coverage; `warning` flags offline items. sanitize_media=True (recommended for
18371
18639
  Premiere/FCP exports) rewrites the XML first — dropping clipitems that reference
18372
18640
  MISSING media or are generators (slug/solid with no media file), both of which
@@ -18384,6 +18652,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18384
18652
  metric; a conflicting relink is VETOED — reverted and reported under
18385
18653
  `sanitize.flagged` for human review, never silently applied. verify_threshold
18386
18654
  (default 0.90) sets the structural-match bar.
18655
+ import_from_drp(drpPath, timelineNames?|timelineIndexes?, import_source_clips?, dry_run?) -> {success, selected, imported, available, results}
18656
+ Extract chosen timelines from a .drp (offline zip surgery → temp .drt each) and
18657
+ import each into the running Resolve. Omit the selector to import ALL. Enumerate
18658
+ first with the advanced MCP drt.list_sequences / editorial.list_sequences to get
18659
+ [{id, name, eventCount}] for a "which sequence?" picker. Requires Resolve open.
18387
18660
  compare_timelines(right_timeline_id?|right_timeline_index?|left_snapshot?, right_snapshot?) -> {match, differences}
18388
18661
  probe_interchange_roundtrip(format?, output_dir?, cleanup_imported?) -> {success, export, import, comparison}
18389
18662
  detect_missing_media(sanitized?|sanitize_paths?, omit_raw_paths?) -> {missing, missing_count, diagnosis}
@@ -18438,6 +18711,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18438
18711
  if mp_err:
18439
18712
  return mp_err
18440
18713
  return _import_timeline_checked(proj, mp, p)
18714
+ elif action == "import_from_drp":
18715
+ _, _, mp, mp_err = _get_mp()
18716
+ if mp_err:
18717
+ return mp_err
18718
+ return _import_from_drp(proj, mp, p)
18441
18719
  elif action == "safe_auto_sync_audio":
18442
18720
  _, _, mp, mp_err = _get_mp()
18443
18721
  if mp_err: