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 +28 -0
- package/README.md +1 -1
- package/docs/SKILL.md +12 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +177 -4
- package/src/utils/timeline_versioning.py +84 -25
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
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.
|
|
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":
|
|
16921
|
-
"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":
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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,
|
|
303
|
-
tt,
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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,
|