davinci-resolve-mcp 2.47.0 → 2.48.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,34 @@
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.48.0
6
+
7
+ Edit-engine hardening: trustworthy execute readback ahead of the first
8
+ real-cut pilot. Live-validated end-to-end on a disposable synthetic-media
9
+ project (24/24 checks).
10
+
11
+ - **Added** `timeline_versioning(action="diff_timelines", params={from_timeline,
12
+ to_timeline})`: structural diff (added/removed/moved/trimmed + summary)
13
+ between two LIVE timelines by name — read-only, no archived snapshots
14
+ needed. Built for edit-engine variants, which are new-name timelines with
15
+ no shared version chain. The item walk and the snapshot comparison were
16
+ factored out of the version-snapshot path
17
+ (`capture_timeline_clip_usage` / `compare_usage_snapshots`) and reused.
18
+ - **Fixed** `execute_swap` audio accounting: the lift was video-only while
19
+ the replacement appended linked video+audio, so item counts drifted on
20
+ every swap. The lift is now scoped to the target's video track plus its
21
+ linked audio tracks (`GetLinkedItems`, with a media-id track-scan fallback;
22
+ items with no linked audio and audio-only timelines are handled
23
+ gracefully), and readback reports per-track-type `track_counts`
24
+ before/after plus an `audio_accounting` block.
25
+ - **Added** `execute_tighten` readback now includes `structural_diff`
26
+ (source vs variant); `execute_selects` readback includes a
27
+ `usage_summary` (per-track-type item counts — a diff against a source
28
+ timeline is meaningless for a fresh assembly).
29
+ - **Changed** the live edit-engine validation harness asserts the tighten
30
+ structural diff, `diff_timelines` agreement, and swap track-count
31
+ symmetry (24 checks, up from 20).
32
+
5
33
  ## What's New in v2.47.0
6
34
 
7
35
  Edit-engine plan browser in the control panel (Media → Edit Plans) — the
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.47.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.48.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/docs/SKILL.md CHANGED
@@ -699,7 +699,13 @@ clip-count readback plus `brain_edits` rationale rows.
699
699
  filtered to shots long enough to fill the slot exactly.
700
700
  `execute_swap(plan_id, alternate_index)` replaces the item in place
701
701
  (lift + positioned append at the same record frame) on the
702
- version-archived timeline.
702
+ version-archived timeline. v2.48.0+: the lift is scoped to the target's
703
+ video track plus its linked audio tracks (GetLinkedItems with a
704
+ media-id fallback), and `readback` carries per-track-type
705
+ `track_counts` plus an `audio_accounting` block so swap symmetry is
706
+ verifiable. `execute_tighten` readback gains `structural_diff` (source
707
+ vs variant, via the same engine as `diff_timelines`); `execute_selects`
708
+ readback gains a `usage_summary`.
703
709
  - `list_plans(limit?)` / `get_plan(plan_id)`.
704
710
 
705
711
  The engine needs the analysis substrate: analyzed clips in the DB (run
@@ -740,6 +746,11 @@ Key actions:
740
746
  lists clips kept in place but re-trimmed (carries `out_frame_before`); `summary`
741
747
  has per-bucket counts plus `before_clip_count`/`after_clip_count`. Clips are
742
748
  keyed by media_pool_item_id and timeline position.
749
+ - `diff_timelines(from_timeline, to_timeline)` (v2.48.0+) — the same
750
+ structural diff between two LIVE timelines by NAME, read-only, no archived
751
+ snapshots needed. Built for edit-engine variants (tighten/selects produce
752
+ new-name timelines with no shared version chain). For unrelated timelines
753
+ everything reports as added/removed.
743
754
  - `get_history(timeline_name?, analysis_run_id?, limit?)` — brain-edit rows
744
755
  with `edit_type`, `target_metric`, `before_value`, `after_value`, `delta`,
745
756
  `rationale`, and `initiator`. Filter by timeline or run; defaults to 50.
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.47.0"
38
+ VERSION = "2.48.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.47.0",
3
+ "version": "2.48.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.47.0"
83
+ VERSION = "2.48.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()}")
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.47.0"
14
+ VERSION = "2.48.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -16386,6 +16386,10 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16386
16386
  list_versions(timeline_name) -> [{version, archived_timeline_name, created_at, ...}]
16387
16387
  Version chain for `timeline_name`, oldest first. Includes any retention-
16388
16388
  collapsed versions (drt_export_path populated).
16389
+ diff_timelines(from_timeline, to_timeline) -> {added, removed, moved, trimmed, summary}
16390
+ Structural diff between two LIVE timelines by name (read-only; no
16391
+ archived versions needed). Built for edit-engine variants — tighten/
16392
+ selects produce new-name timelines with no shared version chain.
16389
16393
  get_history(timeline_name?, analysis_run_id?, limit?) -> [{edit_type, target_metric, before_value, after_value, delta, ...}]
16390
16394
  Brain-edit history. Filter by timeline_name or analysis_run_id; defaults
16391
16395
  to the most recent 50 across the project.
@@ -16453,6 +16457,14 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16453
16457
  to_version=int(p["to_version"]),
16454
16458
  ),
16455
16459
  }
16460
+ if action == "diff_timelines":
16461
+ if not p.get("from_timeline") or not p.get("to_timeline"):
16462
+ return _err("from_timeline and to_timeline required")
16463
+ return _timeline_versioning.diff_timelines(
16464
+ project=project_h,
16465
+ from_timeline=str(p["from_timeline"]),
16466
+ to_timeline=str(p["to_timeline"]),
16467
+ )
16456
16468
  if action == "get_history":
16457
16469
  rows = _brain_edits.get_brain_edit_history(
16458
16470
  project_root=project_root,
@@ -16562,6 +16574,105 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
16562
16574
  return out
16563
16575
 
16564
16576
 
16577
+ def _edit_engine_track_counts(tl) -> Dict[str, int]:
16578
+ """Per-track-type item counts — the swap symmetry signal in readback."""
16579
+ counts: Dict[str, int] = {}
16580
+ for tt in ("video", "audio"):
16581
+ total = 0
16582
+ try:
16583
+ n = int(tl.GetTrackCount(tt) or 0)
16584
+ except Exception:
16585
+ n = 0
16586
+ for ti in range(1, n + 1):
16587
+ try:
16588
+ total += len(tl.GetItemListInTrack(tt, ti) or [])
16589
+ except Exception:
16590
+ continue
16591
+ counts[tt] = total
16592
+ return counts
16593
+
16594
+
16595
+ def _edit_engine_find_slot_item(tl, track_index: int, slot_start: int, slot_end: int):
16596
+ """The video item occupying exactly [slot_start, slot_end] on a track."""
16597
+ try:
16598
+ for cand in (tl.GetItemListInTrack("video", int(track_index)) or []):
16599
+ if _frame_int(cand.GetStart()) == slot_start and _frame_int(cand.GetEnd()) == slot_end:
16600
+ return cand
16601
+ except Exception:
16602
+ pass
16603
+ return None
16604
+
16605
+
16606
+ def _edit_engine_linked_audio_tracks(
16607
+ tl, target_item, slot_start: int, slot_end: int,
16608
+ ) -> Tuple[List[int], str]:
16609
+ """Audio track indices carrying the target item's linked audio.
16610
+
16611
+ Prefers GetLinkedItems; falls back to matching slot-overlapping audio
16612
+ items that share the target's media-pool source. ([], note) when there is
16613
+ no linked audio (audio-only handling stays untouched) or no target item.
16614
+ """
16615
+ if target_item is None:
16616
+ return [], "target video item not found on its track; audio left untouched"
16617
+ linked_ids: set = set()
16618
+ get_linked = getattr(target_item, "GetLinkedItems", None)
16619
+ if callable(get_linked):
16620
+ try:
16621
+ for linked_item in (get_linked() or []):
16622
+ uid = getattr(linked_item, "GetUniqueId", None)
16623
+ if callable(uid):
16624
+ linked_ids.add(str(uid()))
16625
+ except Exception:
16626
+ linked_ids = set()
16627
+ target_media_id = None
16628
+ try:
16629
+ mpi = target_item.GetMediaPoolItem()
16630
+ if mpi is not None:
16631
+ target_media_id = str(mpi.GetUniqueId())
16632
+ except Exception:
16633
+ target_media_id = None
16634
+ if not linked_ids and not target_media_id:
16635
+ return [], "GetLinkedItems unavailable and no media id to match; audio left untouched"
16636
+
16637
+ indices: List[int] = []
16638
+ try:
16639
+ audio_tracks = int(tl.GetTrackCount("audio") or 0)
16640
+ except Exception:
16641
+ audio_tracks = 0
16642
+ for ti in range(1, audio_tracks + 1):
16643
+ try:
16644
+ items = tl.GetItemListInTrack("audio", ti) or []
16645
+ except Exception:
16646
+ continue
16647
+ for cand in items:
16648
+ try:
16649
+ c_start = _frame_int(cand.GetStart())
16650
+ c_end = _frame_int(cand.GetEnd())
16651
+ except Exception:
16652
+ continue
16653
+ if c_start is None or c_end is None or c_start >= slot_end or c_end <= slot_start:
16654
+ continue
16655
+ is_linked = False
16656
+ if linked_ids:
16657
+ uid = getattr(cand, "GetUniqueId", None)
16658
+ if callable(uid):
16659
+ try:
16660
+ is_linked = str(uid()) in linked_ids
16661
+ except Exception:
16662
+ is_linked = False
16663
+ if not is_linked and target_media_id:
16664
+ try:
16665
+ c_mpi = cand.GetMediaPoolItem()
16666
+ is_linked = c_mpi is not None and str(c_mpi.GetUniqueId()) == target_media_id
16667
+ except Exception:
16668
+ is_linked = False
16669
+ if is_linked and ti not in indices:
16670
+ indices.append(ti)
16671
+ if not indices:
16672
+ return [], "no linked audio found for the target item"
16673
+ return indices, ""
16674
+
16675
+
16565
16676
  @mcp.tool()
16566
16677
  @_destructive_op("edit_engine")
16567
16678
  def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -16743,6 +16854,16 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16743
16854
  record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
16744
16855
  appended = mp.AppendToTimeline(built) if built else None
16745
16856
  readback = _edit_engine_capture(tl)
16857
+ # A diff against a source timeline is meaningless for a fresh assembly;
16858
+ # a usage-snapshot summary is the structural readback that fits.
16859
+ try:
16860
+ usage = _timeline_versioning.capture_timeline_clip_usage(tl)
16861
+ by_type: Dict[str, int] = {}
16862
+ for usage_row in usage:
16863
+ by_type[usage_row["track_type"]] = by_type.get(usage_row["track_type"], 0) + 1
16864
+ readback["usage_summary"] = {"items": len(usage), "by_track_type": by_type}
16865
+ except Exception:
16866
+ pass
16746
16867
  run_id = _analysis_runs.current_run_id()
16747
16868
  try:
16748
16869
  _brain_edits.log_brain_edit(
@@ -16820,6 +16941,17 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16820
16941
  return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
16821
16942
  new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
16822
16943
  after = _edit_engine_capture(new_tl) if new_tl else {}
16944
+ # Cross-name structural diff (source vs variant): trustworthy readback
16945
+ # without archived version rows — variants are new-name timelines.
16946
+ structural_diff = None
16947
+ if new_tl is not None:
16948
+ try:
16949
+ structural_diff = _timeline_versioning.compare_usage_snapshots(
16950
+ _timeline_versioning.capture_timeline_clip_usage(source_tl),
16951
+ _timeline_versioning.capture_timeline_clip_usage(new_tl),
16952
+ )
16953
+ except Exception as diff_exc:
16954
+ structural_diff = {"error": f"{type(diff_exc).__name__}: {diff_exc}"}
16823
16955
  run_id = _analysis_runs.current_run_id()
16824
16956
  try:
16825
16957
  _brain_edits.log_brain_edit(
@@ -16865,6 +16997,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16865
16997
  if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
16866
16998
  else None
16867
16999
  ),
17000
+ "structural_diff": structural_diff,
16868
17001
  },
16869
17002
  "plan_id": plan.get("plan_id"),
16870
17003
  }
@@ -16916,14 +17049,44 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16916
17049
  except Exception:
16917
17050
  pass
16918
17051
  before = _edit_engine_capture(tl)
17052
+ before["track_counts"] = _edit_engine_track_counts(tl)
17053
+ slot_start = int(item_block.get("timeline_start_frame"))
17054
+ slot_end = int(item_block.get("timeline_end_frame"))
17055
+ target_track = int(item_block.get("track_index") or 1)
17056
+ # Capture the target's linked audio BEFORE the lift so the audio lift
17057
+ # is scoped to exactly those tracks (the replacement appends linked
17058
+ # video+audio, so both sides of the swap stay symmetric).
17059
+ target_item = _edit_engine_find_slot_item(tl, target_track, slot_start, slot_end)
17060
+ linked_audio_indices, audio_note = _edit_engine_linked_audio_tracks(
17061
+ tl, target_item, slot_start, slot_end,
17062
+ )
16919
17063
  lift = _timeline_lift_range_impl(tl, {
16920
- "start_frame": item_block.get("timeline_start_frame"),
16921
- "end_frame": item_block.get("timeline_end_frame"),
17064
+ "start_frame": slot_start,
17065
+ "end_frame": slot_end,
17066
+ "track_types": ["video"],
17067
+ "track_indices": [target_track],
16922
17068
  "allow_partial_item_delete": True,
16923
17069
  "ripple": False,
16924
17070
  })
16925
17071
  if not lift.get("success"):
16926
17072
  return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
17073
+ audio_lift = None
17074
+ if linked_audio_indices:
17075
+ audio_lift = _timeline_lift_range_impl(tl, {
17076
+ "start_frame": slot_start,
17077
+ "end_frame": slot_end,
17078
+ "track_types": ["audio"],
17079
+ "track_indices": linked_audio_indices,
17080
+ "allow_partial_item_delete": True,
17081
+ "ripple": False,
17082
+ })
17083
+ if not audio_lift.get("success"):
17084
+ return {
17085
+ "success": False,
17086
+ "error": f"linked-audio lift failed: {audio_lift.get('error')}",
17087
+ "lift": lift,
17088
+ "audio_lift": audio_lift,
17089
+ }
16927
17090
  mp = proj.GetMediaPool()
16928
17091
  root_folder = mp.GetRootFolder()
16929
17092
  timeline_start = _timeline_start_frame(tl)
@@ -16940,8 +17103,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16940
17103
  row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
16941
17104
  if row_err:
16942
17105
  return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
17106
+ # No mediaType on the clip info: the replacement appends video+audio
17107
+ # linked, mirroring the video + linked-audio lift above.
16943
17108
  appended = mp.AppendToTimeline([row])
16944
17109
  after = _edit_engine_capture(tl)
17110
+ after["track_counts"] = _edit_engine_track_counts(tl)
16945
17111
  run_id = _analysis_runs.current_run_id()
16946
17112
  try:
16947
17113
  _brain_edits.log_brain_edit(
@@ -16972,6 +17138,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16972
17138
  "before": before,
16973
17139
  "after": after,
16974
17140
  })
17141
+ readback: Dict[str, Any] = {"before": before, "after": after}
17142
+ readback["audio_accounting"] = {
17143
+ "linked_audio_tracks_lifted": linked_audio_indices,
17144
+ "audio_items_lifted": (audio_lift or {}).get("deleted", 0),
17145
+ "video_items_lifted": lift.get("deleted", 0),
17146
+ **({"note": audio_note} if audio_note else {}),
17147
+ }
16975
17148
  return {
16976
17149
  "success": bool(appended),
16977
17150
  "timeline_name": tl.GetName(),
@@ -16981,7 +17154,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16981
17154
  "shot_index": alternate.get("shot_index"),
16982
17155
  "score": alternate.get("score"),
16983
17156
  },
16984
- "readback": {"before": before, "after": after},
17157
+ "readback": readback,
16985
17158
  "plan_id": plan.get("plan_id"),
16986
17159
  }
16987
17160
 
@@ -261,22 +261,11 @@ def _resolve_media_pool_item_id(item: Any) -> Optional[str]:
261
261
  return None
262
262
 
263
263
 
264
- def _snapshot_timeline_clip_usage(
265
- *,
266
- project_root: str,
267
- timeline: Any,
268
- timeline_name: str,
269
- timeline_version: int,
270
- analysis_run_id: Optional[str],
271
- ) -> int:
272
- """Walk every track/item on the timeline and INSERT a row per clip.
273
-
274
- Returns the number of rows written. Safe to call repeatedly for the same
275
- (timeline, version) — version is part of the row, not unique, so diffs
276
- between two versions are a SQL JOIN on media_pool_item_id.
277
- """
278
- observed_at = _now_iso()
279
- rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = []
264
+ def capture_timeline_clip_usage(timeline: Any) -> List[Dict[str, Any]]:
265
+ """Walk every track/item on a LIVE timeline into structural-usage rows
266
+ (no DB writes). Shared by the version snapshot writer and the cross-name
267
+ live diff."""
268
+ rows: List[Dict[str, Any]] = []
280
269
  for tt in ("video", "audio", "subtitle"):
281
270
  try:
282
271
  count = timeline.GetTrackCount(tt)
@@ -298,10 +287,39 @@ def _snapshot_timeline_clip_usage(
298
287
  out_frame = int(item.GetEnd())
299
288
  except Exception:
300
289
  continue
301
- rows.append((
302
- mpi_id, timeline_name, timeline_version,
303
- tt, ti, in_frame, out_frame, analysis_run_id, observed_at,
304
- ))
290
+ rows.append({
291
+ "media_pool_item_id": mpi_id,
292
+ "track_type": tt,
293
+ "track_index": ti,
294
+ "in_frame": in_frame,
295
+ "out_frame": out_frame,
296
+ })
297
+ return rows
298
+
299
+
300
+ def _snapshot_timeline_clip_usage(
301
+ *,
302
+ project_root: str,
303
+ timeline: Any,
304
+ timeline_name: str,
305
+ timeline_version: int,
306
+ analysis_run_id: Optional[str],
307
+ ) -> int:
308
+ """Walk every track/item on the timeline and INSERT a row per clip.
309
+
310
+ Returns the number of rows written. Safe to call repeatedly for the same
311
+ (timeline, version) — version is part of the row, not unique, so diffs
312
+ between two versions are a SQL JOIN on media_pool_item_id.
313
+ """
314
+ observed_at = _now_iso()
315
+ rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = [
316
+ (
317
+ usage["media_pool_item_id"], timeline_name, timeline_version,
318
+ usage["track_type"], usage["track_index"],
319
+ usage["in_frame"], usage["out_frame"], analysis_run_id, observed_at,
320
+ )
321
+ for usage in capture_timeline_clip_usage(timeline)
322
+ ]
305
323
 
306
324
  if not rows:
307
325
  return 0
@@ -348,9 +366,22 @@ def diff_versions(
348
366
 
349
367
  before = _snapshot(from_version)
350
368
  after = _snapshot(to_version)
351
- # Position key: (media id, track type, track index, in_frame). Same key in
352
- # both versions = same placement; a differing out_frame on that same key is
353
- # a *trim*. A differing track/in_frame for the same media id is a *move*.
369
+ return {
370
+ "from_version": from_version,
371
+ "to_version": to_version,
372
+ **compare_usage_snapshots(before, after),
373
+ }
374
+
375
+
376
+ def compare_usage_snapshots(
377
+ before: List[Dict[str, Any]], after: List[Dict[str, Any]],
378
+ ) -> Dict[str, Any]:
379
+ """added/removed/moved/trimmed between two structural-usage snapshots.
380
+
381
+ Position key: (media id, track type, track index, in_frame). Same key in
382
+ both snapshots = same placement; a differing out_frame on that same key is
383
+ a *trim*. A differing track/in_frame for the same media id is a *move*.
384
+ """
354
385
  def _key(row: Dict[str, Any]) -> Tuple[str, str, int, int]:
355
386
  return (row["media_pool_item_id"], row["track_type"], row["track_index"], row["in_frame"])
356
387
 
@@ -382,8 +413,6 @@ def diff_versions(
382
413
  moved.append(r)
383
414
 
384
415
  return {
385
- "from_version": from_version,
386
- "to_version": to_version,
387
416
  "added": added,
388
417
  "removed": removed,
389
418
  "moved": moved,
@@ -399,6 +428,36 @@ def diff_versions(
399
428
  }
400
429
 
401
430
 
431
+ def diff_timelines(
432
+ *,
433
+ project: Any,
434
+ from_timeline: str,
435
+ to_timeline: str,
436
+ ) -> Dict[str, Any]:
437
+ """Structural diff between two LIVE timelines by name (read-only).
438
+
439
+ Unlike diff_versions this needs no archived snapshots and works across
440
+ timeline NAMES — built for edit-engine variants (tighten/selects produce
441
+ new-name timelines that have no shared version chain with their source).
442
+ For moved/trimmed to mean anything the timelines should share source
443
+ clips; for unrelated timelines everything reports as added/removed.
444
+ """
445
+ from_tl = _find_timeline_by_name(project, from_timeline)
446
+ if from_tl is None:
447
+ return {"success": False, "error": f"Timeline '{from_timeline}' not found"}
448
+ to_tl = _find_timeline_by_name(project, to_timeline)
449
+ if to_tl is None:
450
+ return {"success": False, "error": f"Timeline '{to_timeline}' not found"}
451
+ before = capture_timeline_clip_usage(from_tl)
452
+ after = capture_timeline_clip_usage(to_tl)
453
+ return {
454
+ "success": True,
455
+ "from_timeline": from_timeline,
456
+ "to_timeline": to_timeline,
457
+ **compare_usage_snapshots(before, after),
458
+ }
459
+
460
+
402
461
  def ensure_versioned_before_mutation(
403
462
  *,
404
463
  resolve: Any,