davinci-resolve-mcp 2.58.0 → 2.60.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.60.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -745,6 +745,32 @@ resolve = None
745
745
  dvr_script = None
746
746
  _resolve_lock = threading.RLock()
747
747
 
748
+ # On Windows the fusionscript native bridge DLL must be locatable before the
749
+ # Python import machinery attempts to load it. Setting PYTHONHOME, prepending
750
+ # the Resolve install directory to PATH, and registering it with
751
+ # os.add_dll_directory() ensures the dynamic loader can find fusionscript.dll
752
+ # and its dependencies even when the server is launched from a virtual-env or
753
+ # a working directory that is not the Resolve install directory. These steps
754
+ # MUST happen before `import DaVinciResolveScript` or the import triggers a
755
+ # native access-violation on Windows (crash before port bind in network mode).
756
+ if sys.platform.startswith("win") and RESOLVE_LIB_PATH:
757
+ _resolve_install_dir = os.path.dirname(RESOLVE_LIB_PATH)
758
+ if os.path.isdir(_resolve_install_dir):
759
+ # Ensure Python's own runtime DLLs are discoverable by the bridge.
760
+ if not os.environ.get("PYTHONHOME"):
761
+ os.environ["PYTHONHOME"] = sys.base_prefix
762
+ # Prepend Resolve's install dir to PATH so the loader finds
763
+ # fusionscript.dll and sibling DLLs even without a system-wide install.
764
+ _cur_path = os.environ.get("PATH", "")
765
+ if _resolve_install_dir.lower() not in _cur_path.lower():
766
+ os.environ["PATH"] = _resolve_install_dir + os.pathsep + _cur_path
767
+ # os.add_dll_directory is available on Python 3.8+ (Windows only).
768
+ if hasattr(os, "add_dll_directory"):
769
+ try:
770
+ os.add_dll_directory(_resolve_install_dir)
771
+ except OSError:
772
+ pass
773
+
748
774
  try:
749
775
  import DaVinciResolveScript as dvr_script
750
776
  logger.info("DaVinciResolveScript module loaded")
@@ -4340,6 +4366,7 @@ _TIMELINE_CONFORM_KERNEL_ACTIONS = [
4340
4366
  "source_range_report",
4341
4367
  "export_timeline_checked",
4342
4368
  "import_timeline_checked",
4369
+ "import_from_drp",
4343
4370
  "compare_timelines",
4344
4371
  "probe_interchange_roundtrip",
4345
4372
  "detect_missing_media",
@@ -5345,18 +5372,108 @@ def _timeline_media_coverage(tl) -> Dict[str, Any]:
5345
5372
  return {"total": total, "linked": linked, "offline": total - linked}
5346
5373
 
5347
5374
 
5375
+ def _collect_media_pool_items(tl):
5376
+ """Unique Media Pool Items referenced by a timeline's items (for RelinkClips)."""
5377
+ items = []
5378
+ seen = set()
5379
+ for track_type in ("video", "audio"):
5380
+ try:
5381
+ count = int(tl.GetTrackCount(track_type) or 0)
5382
+ except Exception:
5383
+ count = 0
5384
+ for i in range(1, count + 1):
5385
+ for item in (tl.GetItemListInTrack(track_type, i) or []):
5386
+ try:
5387
+ mpi = item.GetMediaPoolItem()
5388
+ except Exception:
5389
+ mpi = None
5390
+ if mpi is not None and id(mpi) not in seen:
5391
+ seen.add(id(mpi))
5392
+ items.append(mpi)
5393
+ return items
5394
+
5395
+
5396
+ def _binary_post_import_relink(tl, mp, search_roots):
5397
+ """Fuzzy-relink parity for binary interchange (AAF): after import, ask Resolve's media pool
5398
+ to relink the timeline's Media Pool Items against each search root. This is the media-pool
5399
+ analogue of the XML path-rewrite relink (which can't run on a binary file)."""
5400
+ roots = [r for r in (search_roots or []) if isinstance(r, str) and os.path.isdir(r)]
5401
+ if not roots:
5402
+ return {"attempted": False, "reason": "no existing search roots", "roots": search_roots or []}
5403
+ items = _collect_media_pool_items(tl)
5404
+ if not items:
5405
+ return {"attempted": False, "reason": "no Media Pool Items to relink", "roots": roots}
5406
+ before = _timeline_media_coverage(tl)
5407
+ calls = []
5408
+ for root in roots:
5409
+ try:
5410
+ ok = bool(mp.RelinkClips(items, root))
5411
+ calls.append({"root": root, "ok": ok})
5412
+ except Exception as e:
5413
+ calls.append({"root": root, "ok": False, "error": str(e)})
5414
+ after = _timeline_media_coverage(tl)
5415
+ return {
5416
+ "attempted": True,
5417
+ "roots": roots,
5418
+ "item_count": len(items),
5419
+ "calls": calls,
5420
+ "before": before,
5421
+ "after": after,
5422
+ "linked_delta": after["linked"] - before["linked"],
5423
+ }
5424
+
5425
+
5426
+ _PRPROJ_REFUSAL = (
5427
+ "Resolve has no native .prproj importer (Premiere's project format is gzip-compressed "
5428
+ "XML, not an interchange). Two offline routes, no Premiere needed: (1) read it with the "
5429
+ "advanced MCP — editorial.list_sequences / editorial.parse_interchange (format 'prproj'); "
5430
+ "(2) convert it to an importable interchange — editorial.convert_to_interchange "
5431
+ "(target 'otio'|'edl'|'drt') — then import that here with import_timeline_checked. "
5432
+ "Editorial timing/cuts/transitions/speed carry over; per-clip effects/Lumetri color do not. "
5433
+ "Alternatively export FCP7 XML / AAF / FCPXML from Premiere and conform that."
5434
+ )
5435
+
5436
+ # Binary interchange formats Resolve reads NATIVELY — the XML sanitize/relink pass
5437
+ # (which parses the file as text) does not apply. Relinking happens via the media
5438
+ # pool after import, not by rewriting the file.
5439
+ _BINARY_INTERCHANGE_EXTS = {".aaf"}
5440
+
5441
+
5348
5442
  def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5349
5443
  path = p.get("path")
5350
5444
  if not path:
5351
5445
  return _err("path is required")
5352
5446
  if not os.path.exists(path):
5353
5447
  return _err(f"path does not exist: {path}")
5448
+ ext = os.path.splitext(path)[1].lower()
5449
+ if ext == ".prproj":
5450
+ return _err(_PRPROJ_REFUSAL, category="invalid_input")
5451
+ # AAF (and any binary interchange) is read natively by Resolve. The XML
5452
+ # sanitize/relink path parses the file as text, so it must be SKIPPED for AAF
5453
+ # even when sanitize_media / relink_search_roots is passed; fuzzy XML relink is
5454
+ # N/A (media links through the media pool). We still detect the created
5455
+ # (possibly offline) timeline via the before/after id diff.
5456
+ is_binary = ext in _BINARY_INTERCHANGE_EXTS
5354
5457
  # sanitize_media (alias: relink_media) rewrites the XML to drop clipitems that
5355
5458
  # reference missing media or are generators (slug/solid w/ no pathurl) — both
5356
5459
  # abort Resolve's scripting-API import and leave the timeline fully offline.
5357
5460
  # The original is only read; the sanitized copy lands in a temp dir, so the
5358
5461
  # temp-path guard never applies to it.
5359
- sanitize = bool(p.get("sanitize_media") or p.get("relink_media"))
5462
+ sanitize_requested = bool(p.get("sanitize_media") or p.get("relink_media"))
5463
+ binary_relink_note = None
5464
+ _binary_has_roots = bool(p.get("relink_search_roots") or p.get("search_roots"))
5465
+ if is_binary and _binary_has_roots:
5466
+ binary_relink_note = (
5467
+ f"XML path-rewrite relink is N/A for {ext} (binary) — imported as-is, then relinked "
5468
+ "via the media pool against the search roots (see `relink`)."
5469
+ )
5470
+ elif is_binary and sanitize_requested:
5471
+ binary_relink_note = (
5472
+ f"sanitize_media is N/A for {ext} (binary) — Resolve reads it natively and media "
5473
+ "relinks via the media pool. Imported the file as-is; pass relink_search_roots to "
5474
+ "auto-relink after import."
5475
+ )
5476
+ sanitize = sanitize_requested and not is_binary
5360
5477
  if not sanitize and p.get("require_temp_path", True) and not _render_temp_path_ok(path):
5361
5478
  return _err(
5362
5479
  "path must be under the system temp directory unless require_temp_path=False",
@@ -5375,7 +5492,7 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5375
5492
  search_roots = p.get("relink_search_roots") or p.get("search_roots")
5376
5493
  if isinstance(search_roots, str):
5377
5494
  search_roots = [search_roots]
5378
- if search_roots and not sanitize:
5495
+ if search_roots and not sanitize and not is_binary:
5379
5496
  sanitize = True # relinking is meaningless without the sanitize/rewrite pass
5380
5497
 
5381
5498
  sanitize_report = None
@@ -5412,6 +5529,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5412
5529
  out = _ok(path=path, import_path=import_path, options=options, would_import=True)
5413
5530
  if sanitize_report is not None:
5414
5531
  out["sanitize"] = sanitize_report
5532
+ if binary_relink_note:
5533
+ out["note"] = binary_relink_note
5415
5534
  return out
5416
5535
 
5417
5536
  before_ids = set()
@@ -5432,15 +5551,32 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5432
5551
  if t and str(t.GetUniqueId()) not in before_ids:
5433
5552
  imported = t
5434
5553
  if not imported:
5554
+ if is_binary:
5555
+ remediation = (
5556
+ f"Resolve created no timeline from this {ext}. Verify it exports/opens in "
5557
+ "Resolve directly, or convert to FCP7 XML / FCPXML upstream and import that."
5558
+ )
5559
+ elif sanitize:
5560
+ remediation = None
5561
+ else:
5562
+ remediation = (
5563
+ "This XML may reference missing media or contain generator clips, which "
5564
+ "abort the scripting-API import. Retry with sanitize_media=True."
5565
+ )
5435
5566
  return _err(
5436
5567
  "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.",
5568
+ remediation=remediation,
5440
5569
  )
5441
5570
 
5442
5571
  imported_id = str(imported.GetUniqueId())
5443
5572
  media = _timeline_media_coverage(imported)
5573
+ # AAF fuzzy-relink parity: when search roots are given for a binary import, relink through
5574
+ # the media pool after import (the XML path-rewrite relink can't run on a binary file).
5575
+ relink_result = None
5576
+ if is_binary and search_roots:
5577
+ relink_result = _binary_post_import_relink(imported, mp, search_roots)
5578
+ if relink_result.get("after"):
5579
+ media = relink_result["after"]
5444
5580
  out = _ok(
5445
5581
  name=imported.GetName(),
5446
5582
  id=imported_id,
@@ -5451,15 +5587,166 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
5451
5587
  )
5452
5588
  if sanitize_report is not None:
5453
5589
  out["sanitize"] = sanitize_report
5590
+ if relink_result is not None:
5591
+ out["relink"] = relink_result
5592
+ if binary_relink_note:
5593
+ out["note"] = binary_relink_note
5454
5594
  if media["total"] and media["offline"]:
5455
5595
  msg = f"{media['offline']} of {media['total']} timeline items are offline (no linked media)."
5456
- if not sanitize:
5596
+ if is_binary:
5597
+ msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
5598
+ "at the media roots — AAF media links there, not by rewriting the file.")
5599
+ elif not sanitize:
5457
5600
  msg += (" Retry with sanitize_media=True to drop missing-media/generator "
5458
5601
  "clips so the remaining media links automatically.")
5459
5602
  out["warning"] = msg
5460
5603
  return out
5461
5604
 
5462
5605
 
5606
+ # A .drp/.drt is a zip of SeqContainer*.xml entries. Two on-disk naming conventions,
5607
+ # mirroring the Node drt-format parser: tool-authored `<folder>/SeqContainer<N>.xml`
5608
+ # and real-Resolve `SeqContainer/<uuid>.xml`. Never match MpFolder/project/Gallery.
5609
+ _SEQ_CONTAINER_RE = re.compile(r"(^|/)SeqContainer(\d*\.xml|/[^/]+\.xml)$")
5610
+
5611
+
5612
+ def _drp_seq_containers(zf) -> List[Dict[str, Any]]:
5613
+ """List a .drp/.drt zip's SeqContainers as [{entry, name, index}] (0-based, sorted)."""
5614
+ entries = sorted(n for n in zf.namelist() if not n.endswith("/") and _SEQ_CONTAINER_RE.search(n))
5615
+ out = []
5616
+ for index, entry in enumerate(entries):
5617
+ try:
5618
+ xml = zf.read(entry).decode("utf-8", "replace")
5619
+ except Exception:
5620
+ xml = ""
5621
+ m = re.search(r"<Name>([\s\S]*?)</Name>", xml)
5622
+ out.append({"entry": entry, "name": (m.group(1).strip() if m else None), "index": index})
5623
+ return out
5624
+
5625
+
5626
+ def _extract_seqcontainer_from_drp(drp_path: str, seq_entry: str, out_path: str) -> None:
5627
+ """Write a minimal .drt (zip) holding one SeqContainer as Primary1/SeqContainer1.xml."""
5628
+ import zipfile
5629
+
5630
+ with zipfile.ZipFile(drp_path, "r") as zf:
5631
+ xml = zf.read(seq_entry)
5632
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as out:
5633
+ out.writestr("Primary1/SeqContainer1.xml", xml)
5634
+ out.writestr(
5635
+ "metadata.json",
5636
+ json.dumps(
5637
+ {
5638
+ "source": "import_from_drp",
5639
+ "sourceDrp": drp_path,
5640
+ "sourceSeqContainer": seq_entry,
5641
+ "exportedFrom": "davinci-resolve-mcp timeline.import_from_drp",
5642
+ },
5643
+ indent=2,
5644
+ ),
5645
+ )
5646
+
5647
+
5648
+ def _import_from_drp(proj, mp, p: Dict[str, Any]):
5649
+ """
5650
+ Extract the chosen timelines from a .drp (offline zip surgery) and import each into the
5651
+ running Resolve. Requires Resolve open. Mirrors: drt.extract_from_drp → import_timeline_checked,
5652
+ but as one call returning per-timeline results.
5653
+ """
5654
+ import zipfile
5655
+
5656
+ drp_path = p.get("drpPath") or p.get("drp_path") or p.get("path")
5657
+ if not drp_path:
5658
+ return _err("drpPath is required")
5659
+ if not os.path.exists(drp_path):
5660
+ return _err(f"drpPath does not exist: {drp_path}")
5661
+ ext = os.path.splitext(drp_path)[1].lower()
5662
+ if ext == ".prproj":
5663
+ return _err(_PRPROJ_REFUSAL, category="invalid_input")
5664
+
5665
+ try:
5666
+ with zipfile.ZipFile(drp_path, "r") as zf:
5667
+ containers = _drp_seq_containers(zf)
5668
+ except zipfile.BadZipFile:
5669
+ return _err(f"Not a valid .drp/.drt (not a zip archive): {drp_path}", category="invalid_input")
5670
+ if not containers:
5671
+ return _err(f"No SeqContainer entries found in {drp_path} — is this a .drp/.drt?", category="invalid_input")
5672
+
5673
+ # Selection: by name(s), by 0-based index(es), or ALL when neither is given.
5674
+ want_names = p.get("timelineNames") or p.get("timeline_names")
5675
+ if isinstance(want_names, str):
5676
+ want_names = [want_names]
5677
+ want_indexes = p.get("timelineIndexes") or p.get("timeline_indexes")
5678
+ if isinstance(want_indexes, int):
5679
+ want_indexes = [want_indexes]
5680
+
5681
+ selected: List[Dict[str, Any]] = []
5682
+ available = [{"name": c["name"], "index": c["index"]} for c in containers]
5683
+ if want_names:
5684
+ by_name = {}
5685
+ for c in containers:
5686
+ if c["name"]:
5687
+ by_name.setdefault(c["name"], c)
5688
+ for nm in want_names:
5689
+ c = by_name.get(nm)
5690
+ if not c:
5691
+ return _err(
5692
+ f"Timeline named {nm!r} not found in .drp",
5693
+ remediation=f"Available: {[a['name'] for a in available]}",
5694
+ category="invalid_input",
5695
+ )
5696
+ selected.append(c)
5697
+ elif want_indexes:
5698
+ for i in want_indexes:
5699
+ match = next((c for c in containers if c["index"] == int(i)), None)
5700
+ if not match:
5701
+ return _err(f"timelineIndex {i} out of range (0..{len(containers) - 1})", category="invalid_input")
5702
+ selected.append(match)
5703
+ else:
5704
+ selected = list(containers)
5705
+
5706
+ # Per-timeline options passthrough (importSourceClips etc.) minus selection keys.
5707
+ base = {
5708
+ k: v
5709
+ for k, v in p.items()
5710
+ if k not in ("drpPath", "drp_path", "path", "timelineNames", "timeline_names", "timelineIndexes", "timeline_indexes", "dry_run")
5711
+ }
5712
+
5713
+ tmp_dir = tempfile.mkdtemp(prefix="drp_import_")
5714
+ results = []
5715
+ imported_count = 0
5716
+ for c in selected:
5717
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "_", c["name"] or f"seq{c['index'] + 1}")
5718
+ drt_path = os.path.join(tmp_dir, f"{c['index'] + 1}_{safe}.drt")
5719
+ entry = {"requested": c["name"], "index": c["index"]}
5720
+ try:
5721
+ _extract_seqcontainer_from_drp(drp_path, c["entry"], drt_path)
5722
+ except Exception as e:
5723
+ entry.update(success=False, error=f"extract failed: {e}")
5724
+ results.append(entry)
5725
+ continue
5726
+ if p.get("dry_run"):
5727
+ entry.update(success=True, would_import=True, drt_path=drt_path)
5728
+ results.append(entry)
5729
+ continue
5730
+ sub = dict(base)
5731
+ sub["path"] = drt_path
5732
+ # .drt lives under a temp dir; the temp-path guard passes regardless.
5733
+ res = _import_timeline_checked(proj, mp, sub)
5734
+ ok = bool(res.get("success"))
5735
+ entry.update(res)
5736
+ if ok:
5737
+ imported_count += 1
5738
+ results.append(entry)
5739
+
5740
+ return _ok(
5741
+ drpPath=drp_path,
5742
+ selected=len(selected),
5743
+ imported=imported_count,
5744
+ available=available,
5745
+ results=results,
5746
+ dry_run=bool(p.get("dry_run")),
5747
+ )
5748
+
5749
+
5463
5750
  def _timeline_by_selector(proj, p: Dict[str, Any], *, prefix: str):
5464
5751
  timeline_id = p.get(f"{prefix}_timeline_id") or p.get(f"{prefix}_id")
5465
5752
  timeline_index = p.get(f"{prefix}_timeline_index") or p.get(f"{prefix}_index")
@@ -17619,6 +17906,47 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
17619
17906
  return out
17620
17907
 
17621
17908
 
17909
+ def _compact_structural_diff(diff: Any, *, sample_n: int = 3) -> Any:
17910
+ """Trim a compare_usage_snapshots result for the default execute response.
17911
+
17912
+ The full added/removed/moved/trimmed rows (one per timeline item, each with
17913
+ media_pool_item_id + track + in/out frames) dominate large tighten
17914
+ responses — 226 KB for a 130-segment tighten (issue #84). The counts already
17915
+ live in `summary`; keep those plus a small head/tail sample per change kind.
17916
+ The full diff is persisted in the executed plan record (edit_engine
17917
+ get_plan(plan_id) → plan.execution_summary.structural_diff) and returned
17918
+ inline when the caller passes include_details=true.
17919
+ """
17920
+ if not isinstance(diff, dict):
17921
+ return diff
17922
+ # An error stand-in (diff failed) or an already-compact payload: pass through.
17923
+ if "summary" not in diff:
17924
+ return diff
17925
+ sample: Dict[str, Any] = {}
17926
+ omitted = 0
17927
+ for kind in ("added", "removed", "moved", "trimmed"):
17928
+ rows = diff.get(kind)
17929
+ if not isinstance(rows, list) or not rows:
17930
+ continue
17931
+ if len(rows) <= sample_n:
17932
+ sample[kind] = list(rows)
17933
+ continue
17934
+ # Head sample + final row so the caller sees both ends of the change set.
17935
+ sample[kind] = list(rows[: sample_n - 1]) + [rows[-1]]
17936
+ omitted += len(rows) - sample_n
17937
+ return {
17938
+ "summary": diff.get("summary"),
17939
+ "sample": sample,
17940
+ "truncated": True,
17941
+ "omitted_rows": omitted,
17942
+ "detail_hint": (
17943
+ "Full per-item diff persisted in the plan record — retrieve via "
17944
+ "edit_engine get_plan(plan_id) → plan.execution_summary.structural_diff, "
17945
+ "or re-run with include_details=true to inline it."
17946
+ ),
17947
+ }
17948
+
17949
+
17622
17950
  def _edit_engine_track_counts(tl) -> Dict[str, int]:
17623
17951
  """Per-track-type item counts — the swap symmetry signal in readback."""
17624
17952
  counts: Dict[str, int] = {}
@@ -17740,9 +18068,12 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17740
18068
  evidence for the current (or named) timeline. Kept ranges mirror onto the
17741
18069
  items' linked audio tracks by default so the variant is audible; pass
17742
18070
  include_audio=false for a video-only (silent) assembly.
17743
- - execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
17744
- timeline from the plan's keep ranges (video + mirrored audio), never
17745
- mutating the original. Readback includes an audio_accounting block.
18071
+ - execute_tighten(plan_id, confirm_token?, include_details?) — assembles a
18072
+ tightened VARIANT timeline from the plan's keep ranges (video + mirrored
18073
+ audio), never mutating the original. Readback includes an audio_accounting
18074
+ block and a compact structural_diff (counts + a small sample); the full
18075
+ per-item diff is persisted in the plan record (get_plan → execution_summary)
18076
+ and returned inline only when include_details=true.
17746
18077
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
17747
18078
  — alternates for one timeline item via the similarity index.
17748
18079
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -17900,7 +18231,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17900
18231
  build_errors.append({"index": i, "error": row_err.get("error")})
17901
18232
  continue
17902
18233
  built.append(row)
17903
- record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
18234
+ # AppendToTimeline endFrame is exclusive: the item occupies
18235
+ # (end - start) frames. Advancing by +1 more leaves a 1-frame gap
18236
+ # (black flash) between consecutive selects. See api_truth endFrame entry.
18237
+ record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0))
17904
18238
  appended = mp.AppendToTimeline(built) if built else None
17905
18239
  readback = _edit_engine_capture(tl)
17906
18240
  # A diff against a source timeline is meaningless for a fresh assembly;
@@ -18030,12 +18364,20 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18030
18364
  )
18031
18365
  except Exception:
18032
18366
  pass
18367
+ # The full per-item structural diff is persisted in the plan record so it
18368
+ # stays retrievable (edit_engine get_plan) without bloating every
18369
+ # response — see issue #84. The MCP response carries a compact form by
18370
+ # default and the full diff only when include_details=true.
18371
+ include_details = _media_analysis_bool(
18372
+ p.get("include_details", p.get("includeDetails")), False
18373
+ )
18033
18374
  _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
18034
18375
  "variant_timeline": variant.get("name") or variant_name,
18035
18376
  "keep_ranges": len(keep_ranges),
18036
18377
  "lifts": len(lifts),
18037
18378
  "before": before,
18038
18379
  "after": after,
18380
+ "structural_diff": structural_diff,
18039
18381
  })
18040
18382
  return {
18041
18383
  "success": True,
@@ -18055,7 +18397,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18055
18397
  if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
18056
18398
  else None
18057
18399
  ),
18058
- "structural_diff": structural_diff,
18400
+ "structural_diff": (
18401
+ structural_diff if include_details
18402
+ else _compact_structural_diff(structural_diff)
18403
+ ),
18059
18404
  "audio_accounting": {
18060
18405
  "planned_audio_ranges": audio_keep_ranges,
18061
18406
  "planned_video_ranges": video_keep_ranges,
@@ -18366,7 +18711,14 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18366
18711
  source_range_report(handles?, merge?) -> {ranges, occurrences}
18367
18712
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?) -> {success, path, size}
18368
18713
  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}
18714
+ Imports an FCP7 xmeml / FCPXML / AAF timeline. AAF (.aaf) is read natively by
18715
+ Resolve — the XML sanitize pass is SKIPPED for it (media relinks via the media
18716
+ pool, not by rewriting the file); a `note` says so. For AAF, passing
18717
+ relink_search_roots triggers a POST-import media-pool relink (RelinkClips against
18718
+ each root) — fuzzy-relink parity by a media-pool mechanism; the `relink` block
18719
+ reports before/after coverage. Premiere .prproj has no Resolve importer — refused
18720
+ with a message pointing to the advanced MCP's offline read + convert_to_interchange
18721
+ bridge (no Premiere needed). `media` reports {total, linked, offline}
18370
18722
  coverage; `warning` flags offline items. sanitize_media=True (recommended for
18371
18723
  Premiere/FCP exports) rewrites the XML first — dropping clipitems that reference
18372
18724
  MISSING media or are generators (slug/solid with no media file), both of which
@@ -18384,6 +18736,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18384
18736
  metric; a conflicting relink is VETOED — reverted and reported under
18385
18737
  `sanitize.flagged` for human review, never silently applied. verify_threshold
18386
18738
  (default 0.90) sets the structural-match bar.
18739
+ import_from_drp(drpPath, timelineNames?|timelineIndexes?, import_source_clips?, dry_run?) -> {success, selected, imported, available, results}
18740
+ Extract chosen timelines from a .drp (offline zip surgery → temp .drt each) and
18741
+ import each into the running Resolve. Omit the selector to import ALL. Enumerate
18742
+ first with the advanced MCP drt.list_sequences / editorial.list_sequences to get
18743
+ [{id, name, eventCount}] for a "which sequence?" picker. Requires Resolve open.
18387
18744
  compare_timelines(right_timeline_id?|right_timeline_index?|left_snapshot?, right_snapshot?) -> {match, differences}
18388
18745
  probe_interchange_roundtrip(format?, output_dir?, cleanup_imported?) -> {success, export, import, comparison}
18389
18746
  detect_missing_media(sanitized?|sanitize_paths?, omit_raw_paths?) -> {missing, missing_count, diagnosis}
@@ -18438,6 +18795,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18438
18795
  if mp_err:
18439
18796
  return mp_err
18440
18797
  return _import_timeline_checked(proj, mp, p)
18798
+ elif action == "import_from_drp":
18799
+ _, _, mp, mp_err = _get_mp()
18800
+ if mp_err:
18801
+ return mp_err
18802
+ return _import_from_drp(proj, mp, p)
18441
18803
  elif action == "safe_auto_sync_audio":
18442
18804
  _, _, mp, mp_err = _get_mp()
18443
18805
  if mp_err:
@@ -508,6 +508,30 @@ API_TRUTH: List[Dict[str, Any]] = [
508
508
  "pass-through.",
509
509
  "tags": ["silent-failure", "color", "node-graph", "layout", "drx"],
510
510
  },
511
+ {
512
+ "symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
513
+ "object": "MediaPool",
514
+ "signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, "
515
+ "trackIndex, mediaType}]) -> [TimelineItem]",
516
+ "reality": "clipInfo endFrame is an EXCLUSIVE bound: the appended item's "
517
+ "duration is endFrame - startFrame frames, not "
518
+ "endFrame - startFrame + 1. Verified by readback probe on "
519
+ "Resolve Studio 21.0 — appending {startFrame: 6254, "
520
+ "endFrame: 6348} yields a 94-frame item, and 130 such "
521
+ "appends whose absolute recordFrames advance by exactly "
522
+ "(endFrame - startFrame) assemble a gap-free track whose "
523
+ "total length equals the sum of (end - start). Code that "
524
+ "assumes an inclusive bound drifts one frame per clip: "
525
+ "advancing a record cursor by (end - start + 1) leaves a "
526
+ "1-frame hole between consecutive clips, and converting a "
527
+ "half-open range with (end - 1) shaves the range's last "
528
+ "frame.",
529
+ "recommended": "Treat [startFrame, endFrame) as half-open everywhere: "
530
+ "duration = endFrame - startFrame; next recordFrame = "
531
+ "previous recordFrame + duration; no ±1 corrections "
532
+ "when mirroring keep-ranges into clipInfos.",
533
+ "tags": ["timeline", "edit", "off-by-one", "readback"],
534
+ },
511
535
  ]
512
536
 
513
537
 
@@ -99,7 +99,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
99
99
  "set_track_enable",
100
100
  "set_track_name",
101
101
  "set_voice_isolation_state",
102
- "set_name",
102
+ # NOTE: timeline.set_name (rename) is intentionally NOT here. A rename is
103
+ # content-preserving and trivially reversible, so version-on-mutate added
104
+ # no recovery value — it only spawned redundant `_archived` copies (one per
105
+ # rename, and renaming an archive archived the archive). Issue #83.
103
106
  "set_start_timecode",
104
107
  "set_setting",
105
108
  "set_mark_in_out",
@@ -262,7 +262,10 @@ def plan_selects(
262
262
  if isinstance(clip_duration, (int, float)) and clip_duration:
263
263
  src_end = min(src_end, float(clip_duration))
264
264
  start_frame = int(round(src_start * fps))
265
- end_frame = max(start_frame + 1, int(round(src_end * fps)) - 1)
265
+ # AppendToTimeline clipInfo endFrame is a half-open (exclusive) bound
266
+ # duration = endFrame - startFrame. See api_truth "AppendToTimeline
267
+ # clipInfo endFrame". No -1: that would shave the last frame of every select.
268
+ end_frame = max(start_frame + 1, int(round(src_end * fps)))
266
269
  decision = {k: v for k, v in candidate.items() if not k.startswith("_")}
267
270
  decision["source_frame_range"] = [start_frame, end_frame]
268
271
  decisions.append(decision)
@@ -339,6 +342,29 @@ def _gaps_in_range(
339
342
  return gaps
340
343
 
341
344
 
345
+ def _dedupe_skipped(skipped: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
346
+ """Collapse identical (item, reason) skip rows into one entry with a count.
347
+
348
+ A timeline layer cut into many segments from one unanalyzed source clip
349
+ otherwise reports the same skip once per segment (87 identical rows in a
350
+ real two-layer session), which bloats the plan payload without adding
351
+ signal. First occurrence order is preserved.
352
+ """
353
+ out: List[Dict[str, Any]] = []
354
+ index: Dict[Tuple[Any, Any], Dict[str, Any]] = {}
355
+ for row in skipped:
356
+ key = (row.get("item"), row.get("reason"))
357
+ hit = index.get(key)
358
+ if hit is None:
359
+ hit = dict(row)
360
+ hit["count"] = 1
361
+ index[key] = hit
362
+ out.append(hit)
363
+ else:
364
+ hit["count"] += 1
365
+ return out
366
+
367
+
342
368
  def plan_tighten(
343
369
  project_root: str,
344
370
  *,
@@ -440,6 +466,7 @@ def plan_tighten(
440
466
  },
441
467
  })
442
468
 
469
+ skipped = _dedupe_skipped(skipped)
443
470
  if not lifts:
444
471
  return {
445
472
  "success": False,
@@ -492,7 +519,9 @@ def plan_tighten(
492
519
  audio_indices = [1]
493
520
  for seg_start, seg_end in segments:
494
521
  start_frame = int(round(seg_start * clip_fps))
495
- end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
522
+ # Half-open (exclusive) endFrame duration = end - start. No -1, else
523
+ # every kept segment loses its last frame (see api_truth endFrame entry).
524
+ end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)))
496
525
  keep_ranges.append({
497
526
  "clip_id": spec["resolve_clip_id"],
498
527
  "start_frame": start_frame,
@@ -642,7 +671,9 @@ def plan_swap(
642
671
  alt = dict(alt_clip)
643
672
  alt_fps = _clip_fps(alt)
644
673
  start_frame = int(round(float(alt_start) * alt_fps))
645
- end_frame = start_frame + int(round(needed_seconds * alt_fps)) - 1
674
+ # Half-open (exclusive) endFrame duration = end - start fills the slot
675
+ # exactly. No -1, else the replacement lands one frame short of the slot.
676
+ end_frame = start_frame + int(round(needed_seconds * alt_fps))
646
677
  return {
647
678
  "score": score,
648
679
  "clip_uuid": clip_uuid,