davinci-resolve-mcp 2.27.2 → 2.28.1

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.
@@ -0,0 +1,428 @@
1
+ """Declarative project spec — "kubectl apply" for a Resolve project.
2
+
3
+ A `project.dvr.yaml` (or .json) describes the *desired* project: settings, color
4
+ preset, timelines, markers, and optional before/after hooks. `plan_spec` diffs
5
+ the desired state against live state and emits an ordered `Action` list **without
6
+ mutating**; `apply_spec` executes that plan through an injected executor.
7
+
8
+ Separation of concerns so it unit-tests without Resolve:
9
+ - load_spec / validation / plan_spec → pure (dicts + dataclasses)
10
+ - apply_spec → orchestrator over an injected executor
11
+ (the server provides a live executor;
12
+ tests provide a fake one)
13
+
14
+ Structure adapted from the MIT-licensed `mhadifilms/dvr` `spec.py`/`apply`:
15
+ SETTINGS_ORDER (color framework before dependent keys), preset-merge (explicit
16
+ settings override the named preset), marker idempotency, and error accumulation.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Callable, Dict, List, Optional
24
+
25
+ from src.utils import structural_diff
26
+
27
+
28
+ # ── Color presets ────────────────────────────────────────────────────────────
29
+ # Minimal, real starting set. Explicit `settings:` in the spec override these.
30
+ COLOR_PRESETS: Dict[str, Dict[str, str]] = {
31
+ "rec709_gamma24": {
32
+ "colorScienceMode": "davinciYRGBColorManagedv2",
33
+ "colorSpaceTimeline": "Rec.709 (Scene)",
34
+ "colorSpaceOutput": "Rec.709 Gamma 2.4",
35
+ },
36
+ "rec2020_pq_4000": {
37
+ "colorScienceMode": "davinciYRGBColorManagedv2",
38
+ "colorSpaceTimeline": "Rec.2020",
39
+ "colorSpaceOutput": "Rec.2020 ST2084 (4000 nits)",
40
+ "hdrMasteringLuminanceMax": "4000",
41
+ },
42
+ "aces_cct": {
43
+ "colorScienceMode": "acescct",
44
+ "colorAcesIDT": "ACES",
45
+ "colorAcesODT": "Rec.709",
46
+ },
47
+ }
48
+
49
+ # Keys applied first (in this relative order) so the color framework is enabled
50
+ # before dependent input/output/HDR keys. Everything not listed is applied after.
51
+ SETTINGS_ORDER: List[str] = [
52
+ "colorScienceMode",
53
+ "rcmPresetMode",
54
+ "colorVersion",
55
+ "colorAcesIDT",
56
+ "colorAcesNodeLUTProcessingSpace",
57
+ "colorAcesODT",
58
+ "colorSpaceInput",
59
+ "colorSpaceTimeline",
60
+ "colorSpaceOutput",
61
+ "hdrMasteringLuminanceMax",
62
+ ]
63
+
64
+
65
+ # ── Dataclasses ──────────────────────────────────────────────────────────────
66
+ @dataclass
67
+ class TimelineSpec:
68
+ name: str
69
+ fps: Optional[float] = None
70
+ settings: Dict[str, str] = field(default_factory=dict)
71
+ markers: List[Dict[str, Any]] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class Hook:
76
+ when: str # "before" | "after"
77
+ command: str
78
+ name: str = ""
79
+
80
+
81
+ @dataclass
82
+ class Spec:
83
+ project: str
84
+ color_preset: Optional[str] = None
85
+ settings: Dict[str, str] = field(default_factory=dict)
86
+ timelines: List[TimelineSpec] = field(default_factory=list)
87
+ hooks: List[Hook] = field(default_factory=list)
88
+
89
+
90
+ @dataclass
91
+ class Action:
92
+ op: str # create | ensure | set | noop
93
+ target: str # "project:Show", "timeline:Edit_v2/setting:timelineFrameRate", ...
94
+ detail: str = ""
95
+ payload: Dict[str, Any] = field(default_factory=dict)
96
+
97
+ def to_dict(self) -> Dict[str, Any]:
98
+ return {"op": self.op, "target": self.target, "detail": self.detail, "payload": self.payload}
99
+
100
+
101
+ class SpecError(Exception):
102
+ """Raised on malformed spec or accumulated apply failures.
103
+
104
+ Carries `state` so the server can surface it in the structured error envelope.
105
+ """
106
+
107
+ def __init__(self, message: str, *, state: Optional[Dict[str, Any]] = None):
108
+ super().__init__(message)
109
+ self.message = message
110
+ self.state = state or {}
111
+
112
+
113
+ # ── Loading & validation ─────────────────────────────────────────────────────
114
+ def _parse_hooks(raw: Any) -> List[Hook]:
115
+ hooks: List[Hook] = []
116
+ if not raw:
117
+ return hooks
118
+ for when in ("before", "after"):
119
+ for entry in (raw.get(when) or []) if isinstance(raw, dict) else []:
120
+ if isinstance(entry, str):
121
+ hooks.append(Hook(when=when, command=entry))
122
+ elif isinstance(entry, dict) and entry.get("command"):
123
+ hooks.append(Hook(when=when, command=str(entry["command"]), name=str(entry.get("name", ""))))
124
+ else:
125
+ raise SpecError(f"Invalid {when}-hook entry: {entry!r}")
126
+ return hooks
127
+
128
+
129
+ def spec_from_dict(data: Dict[str, Any]) -> Spec:
130
+ """Validate a plain dict into a `Spec`. Raises `SpecError` on bad shape."""
131
+ if not isinstance(data, dict):
132
+ raise SpecError("Spec must be a mapping at the top level.")
133
+ project = data.get("project")
134
+ if not project or not isinstance(project, str):
135
+ raise SpecError("Spec requires a non-empty string `project`.", state={"got": data.get("project")})
136
+
137
+ preset = data.get("color_preset")
138
+ if preset is not None and preset not in COLOR_PRESETS:
139
+ raise SpecError(
140
+ f"Unknown color_preset '{preset}'.",
141
+ state={"known_presets": sorted(COLOR_PRESETS)},
142
+ )
143
+
144
+ settings = data.get("settings") or {}
145
+ if not isinstance(settings, dict):
146
+ raise SpecError("`settings` must be a mapping.")
147
+
148
+ timelines: List[TimelineSpec] = []
149
+ for raw_tl in (data.get("timelines") or []):
150
+ if not isinstance(raw_tl, dict) or not raw_tl.get("name"):
151
+ raise SpecError(f"Each timeline needs a `name`: {raw_tl!r}")
152
+ timelines.append(TimelineSpec(
153
+ name=str(raw_tl["name"]),
154
+ fps=float(raw_tl["fps"]) if raw_tl.get("fps") is not None else None,
155
+ settings=dict(raw_tl.get("settings") or {}),
156
+ markers=list(raw_tl.get("markers") or []),
157
+ ))
158
+
159
+ return Spec(
160
+ project=str(project),
161
+ color_preset=preset,
162
+ settings={str(k): str(v) for k, v in settings.items()},
163
+ timelines=timelines,
164
+ hooks=_parse_hooks(data.get("hooks")),
165
+ )
166
+
167
+
168
+ def load_spec(path: str) -> Spec:
169
+ """Load a spec from a YAML or JSON file. YAML needs PyYAML; JSON always works."""
170
+ if not os.path.isfile(path):
171
+ raise SpecError(f"Spec file not found: {path}", state={"path": path})
172
+ with open(path, "r", encoding="utf-8") as fh:
173
+ text = fh.read()
174
+ data: Any
175
+ if path.endswith((".yaml", ".yml")):
176
+ try:
177
+ import yaml # type: ignore
178
+ except ImportError as exc: # pragma: no cover - env-dependent
179
+ raise SpecError(
180
+ "PyYAML is required to load .yaml specs (pip install pyyaml), "
181
+ "or provide the spec as .json.",
182
+ state={"path": path},
183
+ ) from exc
184
+ data = yaml.safe_load(text)
185
+ else:
186
+ data = json.loads(text)
187
+ return spec_from_dict(data)
188
+
189
+
190
+ # ── Effective settings (preset merge + ordering) ─────────────────────────────
191
+ def effective_settings(spec: Spec) -> "Dict[str, str]":
192
+ """Preset settings overlaid by explicit spec.settings (explicit wins)."""
193
+ merged: Dict[str, str] = {}
194
+ if spec.color_preset:
195
+ merged.update(COLOR_PRESETS[spec.color_preset])
196
+ merged.update(spec.settings)
197
+ return merged
198
+
199
+
200
+ def _ordered_setting_keys(keys: List[str]) -> List[str]:
201
+ head = [k for k in SETTINGS_ORDER if k in keys]
202
+ tail = sorted(k for k in keys if k not in SETTINGS_ORDER)
203
+ return head + tail
204
+
205
+
206
+ def _norm_setting_value(value: Any) -> str:
207
+ """Canonicalize a setting value for comparison. Resolve reports numeric
208
+ settings with float formatting (e.g. timelineFrameRate -> "24.0") even when
209
+ the spec says "24"; normalize both so reconcile actually converges (and apply
210
+ is idempotent). Non-numeric values are returned as plain strings."""
211
+ s = str(value)
212
+ try:
213
+ f = float(s)
214
+ except (TypeError, ValueError):
215
+ return s
216
+ return str(int(f)) if f == int(f) else repr(f)
217
+
218
+
219
+ def _settings_equal(a: Any, b: Any) -> bool:
220
+ return _norm_setting_value(a) == _norm_setting_value(b)
221
+
222
+
223
+ # ── Plan ─────────────────────────────────────────────────────────────────────
224
+ def plan_spec(spec: Spec, live: Dict[str, Any]) -> Dict[str, Any]:
225
+ """Compute the ordered action list + a structural diff. PURE — no mutation.
226
+
227
+ `live` is the current state:
228
+ {"project": str|None, "projects": [names],
229
+ "settings": {k: v},
230
+ "timelines": [{"name", "fps", "settings": {...}, "markers": [{frame,...}]}]}
231
+ """
232
+ live = live or {}
233
+ actions: List[Action] = []
234
+
235
+ # Project
236
+ known = set(live.get("projects") or [])
237
+ if spec.project in known or live.get("project") == spec.project:
238
+ actions.append(Action("noop", f"project:{spec.project}", "exists"))
239
+ else:
240
+ actions.append(Action("create", f"project:{spec.project}", "create project"))
241
+
242
+ # Project settings (preset-merged, dependency-ordered)
243
+ live_settings = live.get("settings") or {}
244
+ desired = effective_settings(spec)
245
+ for key in _ordered_setting_keys(list(desired)):
246
+ want = desired[key]
247
+ if _settings_equal(live_settings.get(key, ""), want):
248
+ actions.append(Action("noop", f"project:{spec.project}/setting:{key}", "matches"))
249
+ else:
250
+ actions.append(Action(
251
+ "set", f"project:{spec.project}/setting:{key}",
252
+ f"{live_settings.get(key)!r} -> {want!r}", {"key": key, "value": want},
253
+ ))
254
+
255
+ # Timelines + their settings + markers. NOTE: `fps` is a *creation-time*
256
+ # property handled by ensure_timeline — Resolve refuses SetSetting on
257
+ # timelineFrameRate after a timeline exists — so it is never emitted as a
258
+ # per-timeline `set` action.
259
+ live_tls = {tl.get("name"): tl for tl in (live.get("timelines") or [])}
260
+ for tl in spec.timelines:
261
+ live_tl = live_tls.get(tl.name)
262
+ if live_tl is None:
263
+ actions.append(Action("ensure", f"timeline:{tl.name}", "create timeline",
264
+ {"fps": tl.fps}))
265
+ else:
266
+ actions.append(Action("noop", f"timeline:{tl.name}", "exists"))
267
+
268
+ live_tl_settings = (live_tl or {}).get("settings") or {}
269
+ for key in _ordered_setting_keys(list(tl.settings)):
270
+ want = tl.settings[key]
271
+ if _settings_equal(live_tl_settings.get(key, ""), want):
272
+ actions.append(Action("noop", f"timeline:{tl.name}/setting:{key}", "matches"))
273
+ else:
274
+ actions.append(Action("set", f"timeline:{tl.name}/setting:{key}",
275
+ f"{live_tl_settings.get(key)!r} -> {want!r}",
276
+ {"key": key, "value": want}))
277
+
278
+ live_frames = {m.get("frame") for m in ((live_tl or {}).get("markers") or [])}
279
+ for marker in tl.markers:
280
+ frame = marker.get("frame")
281
+ if frame in live_frames:
282
+ actions.append(Action("noop", f"timeline:{tl.name}/marker:{frame}", "exists"))
283
+ else:
284
+ actions.append(Action("set", f"timeline:{tl.name}/marker:{frame}",
285
+ "add marker", {"marker": marker}))
286
+
287
+ diff = structural_diff.compare(
288
+ _spec_normalized_state(live, spec),
289
+ _spec_desired_state(spec),
290
+ left_label="live", right_label="spec",
291
+ )
292
+ return {
293
+ "actions": [a.to_dict() for a in actions],
294
+ "diff": diff.to_dict(),
295
+ "change_count": sum(1 for a in actions if a.op != "noop"),
296
+ }
297
+
298
+
299
+ def _spec_desired_state(spec: Spec) -> Dict[str, Any]:
300
+ return {
301
+ "project": spec.project,
302
+ "settings": {k: _norm_setting_value(v) for k, v in effective_settings(spec).items()},
303
+ "timelines": [
304
+ {
305
+ "name": tl.name,
306
+ "settings": {k: _norm_setting_value(v) for k, v in tl.settings.items()},
307
+ "markers": [{"frame": m.get("frame"), **{k: v for k, v in m.items() if k != "frame"}}
308
+ for m in tl.markers],
309
+ }
310
+ for tl in spec.timelines
311
+ ],
312
+ }
313
+
314
+
315
+ def _spec_normalized_state(live: Dict[str, Any], spec: Spec) -> Dict[str, Any]:
316
+ """Project live state onto only the keys the spec cares about, so the diff
317
+ reports drift toward the spec rather than every unrelated live setting.
318
+ Values are normalized so numeric formatting (24 vs 24.0) is not a phantom
319
+ diff."""
320
+ desired = effective_settings(spec)
321
+ live_settings = live.get("settings") or {}
322
+ spec_tl_names = {tl.name for tl in spec.timelines}
323
+ live_tls = {tl.get("name"): tl for tl in (live.get("timelines") or [])}
324
+ return {
325
+ "project": live.get("project"),
326
+ "settings": {k: _norm_setting_value(live_settings.get(k)) for k in desired if k in live_settings},
327
+ "timelines": [
328
+ {
329
+ "name": name,
330
+ "settings": {k: _norm_setting_value((live_tls[name].get("settings") or {}).get(k))
331
+ for k in (next((t.settings for t in spec.timelines if t.name == name), {}))
332
+ if k in (live_tls[name].get("settings") or {})},
333
+ "markers": live_tls[name].get("markers") or [],
334
+ }
335
+ for name in spec_tl_names if name in live_tls
336
+ ],
337
+ }
338
+
339
+
340
+ # ── Apply (orchestrator over an injected executor) ───────────────────────────
341
+ def apply_spec(
342
+ spec: Spec,
343
+ executor: Any,
344
+ *,
345
+ dry_run: bool = False,
346
+ run_hooks: bool = False,
347
+ continue_on_error: bool = False,
348
+ run_hook: Optional[Callable[[Hook], Any]] = None,
349
+ ) -> Dict[str, Any]:
350
+ """Reconcile live state toward `spec` through `executor`.
351
+
352
+ `executor` is duck-typed; it must provide:
353
+ live_state() -> dict
354
+ ensure_project(name) -> bool
355
+ set_project_setting(key, value) -> bool
356
+ ensure_timeline(name, fps) -> bool
357
+ set_timeline_setting(timeline_name, key, value) -> bool
358
+ add_marker(timeline_name, marker: dict) -> bool
359
+
360
+ Hooks execute only when `run_hooks=True` AND a `run_hook` callable is given —
361
+ arbitrary shell from a spec stays opt-in. Failures accumulate when
362
+ `continue_on_error`; otherwise the first failure raises `SpecError`.
363
+ """
364
+ live = executor.live_state()
365
+ plan = plan_spec(spec, live)
366
+ if dry_run:
367
+ return {"success": True, "dry_run": True, **plan}
368
+
369
+ failures: List[Dict[str, Any]] = []
370
+ applied: List[Dict[str, Any]] = []
371
+
372
+ def _record(ok: bool, target: str, detail: str = "") -> None:
373
+ entry = {"target": target, "detail": detail}
374
+ if ok:
375
+ applied.append(entry)
376
+ else:
377
+ failures.append(entry)
378
+ if not continue_on_error:
379
+ raise SpecError(
380
+ f"apply failed at {target}",
381
+ state={"project": spec.project, "failures": failures, "applied": applied},
382
+ )
383
+
384
+ befores = [h for h in spec.hooks if h.when == "before"]
385
+ afters = [h for h in spec.hooks if h.when == "after"]
386
+
387
+ if run_hooks and run_hook:
388
+ for h in befores:
389
+ _record(bool(run_hook(h)), f"hook:before:{h.name or h.command}")
390
+
391
+ # Project + settings
392
+ _record(bool(executor.ensure_project(spec.project)), f"project:{spec.project}")
393
+ desired = effective_settings(spec)
394
+ for key in _ordered_setting_keys(list(desired)):
395
+ if _settings_equal((live.get("settings") or {}).get(key, ""), desired[key]):
396
+ continue
397
+ _record(bool(executor.set_project_setting(key, desired[key])),
398
+ f"project:{spec.project}/setting:{key}", str(desired[key]))
399
+
400
+ # Timelines. `fps` is creation-time only (ensure_timeline); never set as a
401
+ # post-creation timelineFrameRate setting — Resolve refuses that.
402
+ live_tls = {tl.get("name"): tl for tl in (live.get("timelines") or [])}
403
+ for tl in spec.timelines:
404
+ _record(bool(executor.ensure_timeline(tl.name, tl.fps)), f"timeline:{tl.name}")
405
+ live_tl_settings = (live_tls.get(tl.name) or {}).get("settings") or {}
406
+ for key in _ordered_setting_keys(list(tl.settings)):
407
+ if _settings_equal(live_tl_settings.get(key, ""), tl.settings[key]):
408
+ continue
409
+ _record(bool(executor.set_timeline_setting(tl.name, key, tl.settings[key])),
410
+ f"timeline:{tl.name}/setting:{key}", str(tl.settings[key]))
411
+ live_frames = {m.get("frame") for m in ((live_tls.get(tl.name) or {}).get("markers") or [])}
412
+ for marker in tl.markers:
413
+ if marker.get("frame") in live_frames:
414
+ continue
415
+ _record(bool(executor.add_marker(tl.name, marker)),
416
+ f"timeline:{tl.name}/marker:{marker.get('frame')}")
417
+
418
+ if run_hooks and run_hook:
419
+ for h in afters:
420
+ _record(bool(run_hook(h)), f"hook:after:{h.name or h.command}")
421
+
422
+ return {
423
+ "success": not failures,
424
+ "applied_count": len(applied),
425
+ "applied": applied,
426
+ "failures": failures,
427
+ "project": spec.project,
428
+ }
@@ -0,0 +1,175 @@
1
+ """Structural diff over plain JSON-able state (snapshots, specs).
2
+
3
+ A general recursive diff that classifies every leaf change as added / removed /
4
+ changed, and — crucially — aligns *lists* by a stable identity key before
5
+ comparing, so a reordered or trimmed element reads as a move/change instead of a
6
+ wholesale delete+add. This is the substrate the timeline-version diff and the
7
+ declarative-spec `plan` both build on.
8
+
9
+ Pure and Resolve-free: every function takes plain dicts/lists and returns plain
10
+ data, so it unit-tests without a live Resolve instance.
11
+
12
+ Design ported (with adaptation) from the MIT-licensed `mhadifilms/dvr` `diff.py`
13
+ `_walk` smart-alignment idea; the identity-key precedence here is tuned for our
14
+ snapshots (stable media id / shot id first, then frame/index, then name).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ # Identity keys tried in order when aligning two lists of dicts. The first key
22
+ # present in an element wins; alignment then matches elements sharing that key's
23
+ # value. Falls back to positional (index) alignment when no key is shared.
24
+ DEFAULT_LIST_KEYS: Tuple[str, ...] = (
25
+ "clip_hash", # our rename-stable canonical hash (issue #51)
26
+ "media_pool_item_id", # Resolve GetUniqueId — stable across renames
27
+ "shot_id",
28
+ "id",
29
+ "uid",
30
+ "frame", # markers
31
+ "name",
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class Change:
37
+ """One leaf-level difference. `op` ∈ {added, removed, changed}.
38
+
39
+ `path` is a dotted/bracketed location, e.g. ``tracks.video[1].name`` or
40
+ ``markers[frame=0].color``. `before`/`after` are the scalar values (None on
41
+ the side where the element/leaf is absent).
42
+ """
43
+
44
+ op: str
45
+ path: str
46
+ before: Any = None
47
+ after: Any = None
48
+
49
+ def to_dict(self) -> Dict[str, Any]:
50
+ return {"op": self.op, "path": self.path, "before": self.before, "after": self.after}
51
+
52
+
53
+ @dataclass
54
+ class Diff:
55
+ changes: List[Change] = field(default_factory=list)
56
+ left_label: str = "before"
57
+ right_label: str = "after"
58
+
59
+ def added(self) -> List[Change]:
60
+ return [c for c in self.changes if c.op == "added"]
61
+
62
+ def removed(self) -> List[Change]:
63
+ return [c for c in self.changes if c.op == "removed"]
64
+
65
+ def changed(self) -> List[Change]:
66
+ return [c for c in self.changes if c.op == "changed"]
67
+
68
+ def is_empty(self) -> bool:
69
+ return not self.changes
70
+
71
+ def summary(self) -> Dict[str, int]:
72
+ return {
73
+ "added": len(self.added()),
74
+ "removed": len(self.removed()),
75
+ "changed": len(self.changed()),
76
+ "total": len(self.changes),
77
+ }
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ return {
81
+ "left_label": self.left_label,
82
+ "right_label": self.right_label,
83
+ "summary": self.summary(),
84
+ "changes": [c.to_dict() for c in self.changes],
85
+ }
86
+
87
+
88
+ def _identity_key(item: Any, list_keys: Tuple[str, ...]) -> Optional[Tuple[str, Any]]:
89
+ """Return (key_name, value) for the first identity key present on a dict item."""
90
+ if not isinstance(item, dict):
91
+ return None
92
+ for key in list_keys:
93
+ if key in item and item[key] is not None:
94
+ return (key, item[key])
95
+ return None
96
+
97
+
98
+ def _align_lists(
99
+ left: List[Any], right: List[Any], list_keys: Tuple[str, ...]
100
+ ) -> List[Tuple[Optional[Any], Optional[Any], str]]:
101
+ """Pair up elements of two lists.
102
+
103
+ Returns a list of (left_item, right_item, label) triples. When a shared
104
+ identity key exists, elements are matched by it (so reorders don't read as
105
+ delete+add); otherwise alignment is positional. `label` is the path segment
106
+ used for the pair (``[key=value]`` for keyed, ``[i]`` for positional).
107
+ """
108
+ # Try keyed alignment first: only viable if *both* sides expose the same key.
109
+ left_keyed = [(_identity_key(x, list_keys), x) for x in left]
110
+ right_keyed = [(_identity_key(x, list_keys), x) for x in right]
111
+ if all(k is not None for k, _ in left_keyed) and all(k is not None for k, _ in right_keyed):
112
+ pairs: List[Tuple[Optional[Any], Optional[Any], str]] = []
113
+ right_by_key: Dict[Tuple[str, Any], Any] = {k: v for k, v in right_keyed} # type: ignore[misc]
114
+ consumed: set = set()
115
+ for k, lv in left_keyed:
116
+ label = f"[{k[0]}={k[1]}]" # type: ignore[index]
117
+ if k in right_by_key:
118
+ pairs.append((lv, right_by_key[k], label))
119
+ consumed.add(k)
120
+ else:
121
+ pairs.append((lv, None, label))
122
+ for k, rv in right_keyed:
123
+ if k not in consumed:
124
+ pairs.append((None, rv, f"[{k[0]}={k[1]}]")) # type: ignore[index]
125
+ return pairs
126
+
127
+ # Positional fallback.
128
+ pairs = []
129
+ for i in range(max(len(left), len(right))):
130
+ lv = left[i] if i < len(left) else None
131
+ rv = right[i] if i < len(right) else None
132
+ pairs.append((lv, rv, f"[{i}]"))
133
+ return pairs
134
+
135
+
136
+ def _walk(left: Any, right: Any, path: str, out: List[Change], list_keys: Tuple[str, ...]) -> None:
137
+ # Type mismatch or one side missing → record as a changed/added/removed leaf.
138
+ if isinstance(left, dict) and isinstance(right, dict):
139
+ for key in sorted(set(left) | set(right)):
140
+ child_path = f"{path}.{key}" if path else key
141
+ if key not in left:
142
+ out.append(Change("added", child_path, None, right[key]))
143
+ elif key not in right:
144
+ out.append(Change("removed", child_path, left[key], None))
145
+ else:
146
+ _walk(left[key], right[key], child_path, out, list_keys)
147
+ return
148
+
149
+ if isinstance(left, list) and isinstance(right, list):
150
+ for lv, rv, seg in _align_lists(left, right, list_keys):
151
+ child_path = f"{path}{seg}"
152
+ if lv is None:
153
+ out.append(Change("added", child_path, None, rv))
154
+ elif rv is None:
155
+ out.append(Change("removed", child_path, lv, None))
156
+ else:
157
+ _walk(lv, rv, child_path, out, list_keys)
158
+ return
159
+
160
+ if left != right:
161
+ out.append(Change("changed", path or "", left, right))
162
+
163
+
164
+ def compare(
165
+ left: Any,
166
+ right: Any,
167
+ *,
168
+ left_label: str = "before",
169
+ right_label: str = "after",
170
+ list_keys: Tuple[str, ...] = DEFAULT_LIST_KEYS,
171
+ ) -> Diff:
172
+ """Diff two JSON-able structures into a `Diff` of leaf-level changes."""
173
+ out: List[Change] = []
174
+ _walk(left, right, "", out, list_keys)
175
+ return Diff(changes=out, left_label=left_label, right_label=right_label)
@@ -347,32 +347,54 @@ def diff_versions(
347
347
 
348
348
  before = _snapshot(from_version)
349
349
  after = _snapshot(to_version)
350
- # Key on (media_pool_item_id, track_type, track_index, in_frame) a clip
351
- # moved to a different track or trimmed differently counts as a change.
350
+ # Position key: (media id, track type, track index, in_frame). Same key in
351
+ # both versions = same placement; a differing out_frame on that same key is
352
+ # a *trim*. A differing track/in_frame for the same media id is a *move*.
352
353
  def _key(row: Dict[str, Any]) -> Tuple[str, str, int, int]:
353
354
  return (row["media_pool_item_id"], row["track_type"], row["track_index"], row["in_frame"])
354
355
 
355
- before_keys = {_key(r) for r in before}
356
- after_keys = {_key(r) for r in after}
356
+ before_by_key = {_key(r): r for r in before}
357
+ after_by_key = {_key(r): r for r in after}
358
+ before_keys = set(before_by_key)
359
+ after_keys = set(after_by_key)
360
+
357
361
  added = [r for r in after if _key(r) not in before_keys]
358
362
  removed = [r for r in before if _key(r) not in after_keys]
359
- # "moved" approximation: same media id, different (track or in_frame).
363
+
364
+ # Trimmed: same placement key in both, but the out_frame differs.
365
+ trimmed: List[Dict[str, Any]] = []
366
+ for key in before_keys & after_keys:
367
+ if before_by_key[key]["out_frame"] != after_by_key[key]["out_frame"]:
368
+ row = dict(after_by_key[key])
369
+ row["out_frame_before"] = before_by_key[key]["out_frame"]
370
+ trimmed.append(row)
371
+
372
+ # Moved: same media id present on both sides but at a different placement
373
+ # (so it shows up in both `added` and `removed` above). Report it once.
360
374
  before_by_id: Dict[str, List[Dict[str, Any]]] = {}
361
375
  for r in before:
362
376
  before_by_id.setdefault(r["media_pool_item_id"], []).append(r)
363
377
  moved: List[Dict[str, Any]] = []
364
- for r in after:
378
+ for r in added:
365
379
  prevs = before_by_id.get(r["media_pool_item_id"])
366
- if not prevs:
367
- continue
368
- if any(_key(p) != _key(r) for p in prevs):
380
+ if prevs and any(_key(p) != _key(r) for p in prevs):
369
381
  moved.append(r)
382
+
370
383
  return {
371
384
  "from_version": from_version,
372
385
  "to_version": to_version,
373
386
  "added": added,
374
387
  "removed": removed,
375
388
  "moved": moved,
389
+ "trimmed": trimmed,
390
+ "summary": {
391
+ "added": len(added),
392
+ "removed": len(removed),
393
+ "moved": len(moved),
394
+ "trimmed": len(trimmed),
395
+ "before_clip_count": len(before),
396
+ "after_clip_count": len(after),
397
+ },
376
398
  }
377
399
 
378
400