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/CHANGELOG.md +92 -0
- package/README.md +4 -11
- package/docs/SKILL.md +4 -2
- package/install.py +12 -4
- package/package.json +1 -1
- package/resolve-advanced/README.md +6 -1
- package/resolve-advanced/server/aaf.mjs +104 -0
- package/resolve-advanced/server/author-interchange.mjs +152 -0
- package/resolve-advanced/server/editorial.mjs +17 -5
- package/resolve-advanced/server/prproj.mjs +262 -0
- package/resolve-advanced/server/sequences.mjs +86 -0
- package/resolve-advanced/server/tools/drt.mjs +9 -1
- package/resolve-advanced/server/tools/editorial.mjs +80 -7
- package/src/granular/common.py +1 -1
- package/src/server.py +375 -13
- package/src/utils/api_truth.py +24 -0
- package/src/utils/destructive_hook.py +4 -1
- package/src/utils/edit_engine.py +34 -3
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.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
|
-
|
|
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=
|
|
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
|
|
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
|
|
17744
|
-
timeline from the plan's keep ranges (video + mirrored
|
|
17745
|
-
mutating the original. Readback includes an audio_accounting
|
|
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
|
-
|
|
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":
|
|
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.
|
|
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:
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|