davinci-resolve-mcp 2.68.2 → 2.69.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.
@@ -0,0 +1,437 @@
1
+ """Conform lint — the problems an online editor finds the hard way.
2
+
3
+ Conforming is where every organizational mistake made upstream finally surfaces,
4
+ usually in someone else's suite, usually after picture lock, and always at the
5
+ point when fixing it is most expensive. The failures are well known and boringly
6
+ repetitive; online editors have been writing the same list for years:
7
+
8
+ - seven stacked video layers with unused footage buried underneath, tripling the
9
+ media the conform has to carry
10
+ - cards misnamed on set, so two different shots claim the same source timecode
11
+ and nothing relinks
12
+ - plug-ins and NLE-specific effects that silently do not survive XML or AAF
13
+ - effects baked in so hard the online editor can no longer improve them
14
+ - Premiere's "Scale to Frame Size", whose sizing data does not travel into
15
+ Resolve at all — every scaled shot has to be redone by hand
16
+ - dissolves with no handles behind them
17
+ - a frame rate that disagrees with the timeline it is sitting in
18
+
19
+ None of this needs a running Resolve to detect. It needs the timeline read once
20
+ and checked against a list, which is exactly the sort of thing a human stops
21
+ doing carefully at 2am on the fourth reel.
22
+
23
+ So this is a pure function over a **timeline snapshot** — a plain dict, no
24
+ Resolve object, no I/O — which is what makes it testable and what lets it run
25
+ against a snapshot captured earlier.
26
+
27
+ **It reports; it does not fix.** Most findings here are legitimate some of the
28
+ time: a shot really can run to the end of its media, a stack really can be an
29
+ intentional composite. The failure is not that these exist, it is that nobody
30
+ knew about them before turnover.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections import defaultdict
36
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
37
+
38
+ #: Severities. `blocker` means a turnover built on this will not conform.
39
+ SEVERITY_BLOCKER = "blocker"
40
+ SEVERITY_WARNING = "warning"
41
+ SEVERITY_INFO = "info"
42
+
43
+ _SEVERITY_RANK = {SEVERITY_BLOCKER: 0, SEVERITY_WARNING: 1, SEVERITY_INFO: 2}
44
+
45
+ #: Frame-rate equality tolerance. 23.976 and 24 are *different* rates and a
46
+ #: 0.1% drift over a two-hour programme is several seconds of sync — but float
47
+ #: noise from three different APIs should not read as a mismatch.
48
+ FPS_EPSILON = 0.01
49
+
50
+ #: Effects known not to survive interchange, lowercased substrings.
51
+ _FRAGILE_EFFECT_HINTS = (
52
+ "scale to frame size",
53
+ "set to frame size",
54
+ "warp stabilizer",
55
+ "morph cut",
56
+ "lumetri",
57
+ "neat video",
58
+ "sapphire",
59
+ "red giant",
60
+ "magic bullet",
61
+ )
62
+
63
+
64
+ def _finding(
65
+ code: str, severity: str, summary: str, *, detail: str, items: Optional[Sequence[Any]] = None
66
+ ) -> Dict[str, Any]:
67
+ return {
68
+ "code": code,
69
+ "severity": severity,
70
+ "summary": summary,
71
+ "detail": detail,
72
+ "items": list(items or []),
73
+ }
74
+
75
+
76
+ def _name(item: Mapping[str, Any]) -> str:
77
+ return str(item.get("item_name") or item.get("clip_name") or "(unnamed)")
78
+
79
+
80
+ # ── individual checks ────────────────────────────────────────────────────────
81
+
82
+
83
+ def check_frame_rates(items: Sequence[Mapping[str, Any]], timeline_fps: float) -> List[Dict[str, Any]]:
84
+ """A clip whose rate disagrees with the timeline will drift against sound."""
85
+ offenders = []
86
+ for item in items:
87
+ fps = item.get("clip_fps")
88
+ if isinstance(fps, (int, float)) and fps > 0 and abs(float(fps) - timeline_fps) > FPS_EPSILON:
89
+ offenders.append({"item": _name(item), "clip_fps": round(float(fps), 3)})
90
+ if not offenders:
91
+ return []
92
+ return [_finding(
93
+ "FPS_MISMATCH", SEVERITY_BLOCKER,
94
+ f"{len(offenders)} clips do not match the timeline rate ({timeline_fps})",
95
+ detail=(
96
+ "23.976 and 24 are different rates. Over a feature-length programme the "
97
+ "0.1% difference is several seconds of drift against the mix, and it is "
98
+ "usually found at the end, in the theatre. Confirm the intended rate "
99
+ "before conforming."
100
+ ),
101
+ items=offenders,
102
+ )]
103
+
104
+
105
+ def check_offline_media(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
106
+ offenders = [{"item": _name(i)} for i in items if not i.get("media_path") and not i.get("media_ref")]
107
+ if not offenders:
108
+ return []
109
+ return [_finding(
110
+ "OFFLINE_MEDIA", SEVERITY_BLOCKER,
111
+ f"{len(offenders)} items have no media reference",
112
+ detail=(
113
+ "These cannot be conformed or relinked because there is nothing to "
114
+ "relink to. Generators and titles legitimately appear here — check the "
115
+ "list before treating it as an error."
116
+ ),
117
+ items=offenders,
118
+ )]
119
+
120
+
121
+ def check_duplicate_source_timecode(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
122
+ """Two different sources claiming the same timecode = misnamed cards.
123
+
124
+ This is the single most common reason a conform will not relink, and it is
125
+ created on set, weeks before anyone finds out.
126
+ """
127
+ by_tc: Dict[Any, set] = defaultdict(set)
128
+ for item in items:
129
+ tc = item.get("source_start_timecode")
130
+ ref = item.get("media_path") or item.get("media_ref")
131
+ if tc and ref:
132
+ by_tc[tc].add(ref)
133
+ clashes = [
134
+ {"source_timecode": tc, "distinct_sources": sorted(str(r) for r in refs)}
135
+ for tc, refs in sorted(by_tc.items(), key=lambda kv: str(kv[0]))
136
+ if len(refs) > 1
137
+ ]
138
+ if not clashes:
139
+ return []
140
+ return [_finding(
141
+ "DUPLICATE_SOURCE_TC", SEVERITY_BLOCKER,
142
+ f"{len(clashes)} source timecodes are claimed by more than one file",
143
+ detail=(
144
+ "Repeating timecode across differently-named sources means cards were "
145
+ "misnamed or the camera clock was reset. A timecode-based relink cannot "
146
+ "choose between them and will pick wrong, silently."
147
+ ),
148
+ items=clashes,
149
+ )]
150
+
151
+
152
+ def check_reel_names(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
153
+ missing = [{"item": _name(i)} for i in items if i.get("media_path") and not i.get("reel_name")]
154
+ findings: List[Dict[str, Any]] = []
155
+ if missing:
156
+ findings.append(_finding(
157
+ "MISSING_REEL_NAME", SEVERITY_WARNING,
158
+ f"{len(missing)} items carry no reel name",
159
+ detail=(
160
+ "EDL-based conforms match on reel name plus timecode exclusively. "
161
+ "XML and AAF can fall back to filenames, so this is survivable — "
162
+ "until someone downstream needs an EDL."
163
+ ),
164
+ items=missing,
165
+ ))
166
+ overlong = [
167
+ {"item": _name(i), "reel_name": str(i["reel_name"])}
168
+ for i in items if i.get("reel_name") and len(str(i["reel_name"])) > 8
169
+ ]
170
+ if overlong:
171
+ findings.append(_finding(
172
+ "REEL_NAME_TOO_LONG", SEVERITY_INFO,
173
+ f"{len(overlong)} reel names exceed the 8-character CMX 3600 limit",
174
+ detail="Fine for XML/AAF. A CMX 3600 EDL will truncate them, and truncation can collide.",
175
+ items=overlong,
176
+ ))
177
+ return findings
178
+
179
+
180
+ def check_stacked_layers(items: Sequence[Mapping[str, Any]], *, max_expected_tracks: int = 3) -> List[Dict[str, Any]]:
181
+ """Video layers fully hidden behind opaque layers above them."""
182
+ findings: List[Dict[str, Any]] = []
183
+ tracks = sorted({int(i.get("track_index") or 1) for i in items})
184
+ if len(tracks) > max_expected_tracks:
185
+ findings.append(_finding(
186
+ "MANY_VIDEO_TRACKS", SEVERITY_INFO,
187
+ f"{len(tracks)} video tracks in use",
188
+ detail=(
189
+ "Not wrong, but every layer is media the conform has to carry and "
190
+ "the online editor has to account for. Worth confirming each one is "
191
+ "meant to be there."
192
+ ),
193
+ items=[{"track_index": t} for t in tracks],
194
+ ))
195
+
196
+ buried: List[Dict[str, Any]] = []
197
+ for item in items:
198
+ idx = int(item.get("track_index") or 1)
199
+ start, end = item.get("timeline_start_frame"), item.get("timeline_end_frame")
200
+ if start is None or end is None:
201
+ continue
202
+ for other in items:
203
+ if int(other.get("track_index") or 1) <= idx:
204
+ continue
205
+ if other.get("opacity") is not None and float(other["opacity"]) < 100:
206
+ continue
207
+ o_start, o_end = other.get("timeline_start_frame"), other.get("timeline_end_frame")
208
+ if o_start is None or o_end is None:
209
+ continue
210
+ if o_start <= start and o_end >= end:
211
+ buried.append({
212
+ "item": _name(item), "track_index": idx,
213
+ "covered_by": _name(other), "covering_track": int(other.get("track_index") or 1),
214
+ })
215
+ break
216
+ if buried:
217
+ findings.append(_finding(
218
+ "BURIED_ITEM", SEVERITY_WARNING,
219
+ f"{len(buried)} items are completely covered by an opaque layer above",
220
+ detail=(
221
+ "Invisible in the cut, but still conformed, still relinked, still "
222
+ "carried through every turnover. Usually leftovers from an earlier "
223
+ "version that were never cleaned up."
224
+ ),
225
+ items=buried,
226
+ ))
227
+ return findings
228
+
229
+
230
+ def check_gaps_and_overlaps(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
231
+ findings: List[Dict[str, Any]] = []
232
+ by_track: Dict[int, List[Mapping[str, Any]]] = defaultdict(list)
233
+ for item in items:
234
+ if item.get("timeline_start_frame") is None or item.get("timeline_end_frame") is None:
235
+ continue
236
+ by_track[int(item.get("track_index") or 1)].append(item)
237
+
238
+ overlaps: List[Dict[str, Any]] = []
239
+ for track, entries in sorted(by_track.items()):
240
+ ordered = sorted(entries, key=lambda i: i["timeline_start_frame"])
241
+ for prev, nxt in zip(ordered, ordered[1:]):
242
+ if nxt["timeline_start_frame"] < prev["timeline_end_frame"]:
243
+ overlaps.append({
244
+ "track_index": track,
245
+ "first": _name(prev), "second": _name(nxt),
246
+ "overlap_frames": prev["timeline_end_frame"] - nxt["timeline_start_frame"],
247
+ })
248
+ if overlaps:
249
+ findings.append(_finding(
250
+ "TRACK_OVERLAP", SEVERITY_WARNING,
251
+ f"{len(overlaps)} overlapping items on the same track",
252
+ detail=(
253
+ "Legitimate for a dissolve, and a sign of a bad edit otherwise. "
254
+ "Either way the overlap consumes real media on both sides — see "
255
+ "handle checks."
256
+ ),
257
+ items=overlaps,
258
+ ))
259
+ return findings
260
+
261
+
262
+ def check_fragile_effects(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
263
+ """Effects that will not survive XML/AAF, or that are already baked in."""
264
+ offenders: List[Dict[str, Any]] = []
265
+ for item in items:
266
+ names = item.get("effects") or []
267
+ if isinstance(names, str):
268
+ names = [names]
269
+ for effect in names:
270
+ low = str(effect).lower()
271
+ for hint in _FRAGILE_EFFECT_HINTS:
272
+ if hint in low:
273
+ offenders.append({"item": _name(item), "effect": str(effect)})
274
+ break
275
+ if not offenders:
276
+ return []
277
+ return [_finding(
278
+ "FRAGILE_EFFECT", SEVERITY_WARNING,
279
+ f"{len(offenders)} effects may not survive interchange",
280
+ detail=(
281
+ "NLE-specific effects and plug-ins do not travel through XML or AAF. "
282
+ "Premiere's 'Scale to Frame Size' is the classic: its sizing data does "
283
+ "not reach Resolve at all, so every scaled shot is silently full-frame "
284
+ "and has to be redone by hand. Decide per effect whether to bake it or "
285
+ "hand it over as a note — but decide before turnover, not after."
286
+ ),
287
+ items=offenders,
288
+ )]
289
+
290
+
291
+ def check_source_start_at_zero(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
292
+ """Shots starting on the first frame of their media have no head handle."""
293
+ offenders = [
294
+ {"item": _name(i)} for i in items
295
+ if i.get("source_start_frame") == 0 and (i.get("media_path") or i.get("media_ref"))
296
+ ]
297
+ if not offenders:
298
+ return []
299
+ return [_finding(
300
+ "NO_HEAD_HANDLE", SEVERITY_INFO,
301
+ f"{len(offenders)} items start on the first frame of their source",
302
+ detail=(
303
+ "No media in front of the cut, so the join cannot be dissolved or "
304
+ "slipped and audio has nothing to crossfade with. Often unavoidable — "
305
+ "the material simply starts there — but it must be known before "
306
+ "turnover rather than discovered in the mix."
307
+ ),
308
+ items=offenders,
309
+ )]
310
+
311
+
312
+ def check_duplicate_usage(items: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
313
+ """The same source range used more than once.
314
+
315
+ NLEs call this dupe detection and it is deliberately *not* an error — reuse
316
+ is legitimate constantly: a recurring motif, a flashback, a montage callback.
317
+ The editorial value is awareness. As the practice puts it: if the same moment
318
+ serves two scenes, maybe one of the scenes does not need it.
319
+
320
+ Only *overlapping* source ranges count. Two adjacent, non-overlapping pulls
321
+ from one clip are simply two shots, and flagging them would make the check
322
+ fire on every multi-take scene in existence.
323
+ """
324
+ by_source: Dict[Any, List[Mapping[str, Any]]] = defaultdict(list)
325
+ for item in items:
326
+ ref = item.get("media_path") or item.get("media_ref")
327
+ if ref and item.get("source_start_frame") is not None:
328
+ by_source[ref].append(item)
329
+
330
+ dupes: List[Dict[str, Any]] = []
331
+ for ref, entries in sorted(by_source.items(), key=lambda kv: str(kv[0])):
332
+ spans = []
333
+ for entry in entries:
334
+ start = int(entry.get("source_start_frame") or 0)
335
+ length = 0
336
+ if entry.get("timeline_start_frame") is not None and entry.get("timeline_end_frame") is not None:
337
+ length = int(entry["timeline_end_frame"]) - int(entry["timeline_start_frame"])
338
+ spans.append((start, start + max(0, length), _name(entry)))
339
+ spans.sort()
340
+ for (a_start, a_end, a_name), (b_start, b_end, b_name) in zip(spans, spans[1:]):
341
+ if b_start < a_end:
342
+ dupes.append({
343
+ "source": str(ref),
344
+ "first": a_name,
345
+ "second": b_name,
346
+ "overlap_frames": min(a_end, b_end) - b_start,
347
+ })
348
+ if not dupes:
349
+ return []
350
+ return [_finding(
351
+ "DUPLICATE_USAGE", SEVERITY_INFO,
352
+ f"{len(dupes)} source ranges are used more than once",
353
+ detail=(
354
+ "Not a rule violation — just awareness. Reuse is legitimate for a "
355
+ "recurring motif, a flashback or a montage callback. But if the same "
356
+ "moment is serving two scenes, it is worth asking whether one of the "
357
+ "scenes needs it."
358
+ ),
359
+ items=dupes,
360
+ )]
361
+
362
+
363
+ #: Every check, in the order a human would work through them.
364
+ CHECKS = (
365
+ check_duplicate_usage,
366
+ check_offline_media,
367
+ check_duplicate_source_timecode,
368
+ check_fragile_effects,
369
+ check_gaps_and_overlaps,
370
+ check_reel_names,
371
+ check_source_start_at_zero,
372
+ )
373
+
374
+
375
+ def lint_timeline(snapshot: Mapping[str, Any]) -> Dict[str, Any]:
376
+ """Run every conform check over a timeline snapshot.
377
+
378
+ `snapshot`: `{"timeline_name", "timeline_fps", "items": [...]}` where each
379
+ item may carry `track_index`, `item_name`, `timeline_start_frame`,
380
+ `timeline_end_frame`, `source_start_frame`, `source_start_timecode`,
381
+ `clip_fps`, `media_path`/`media_ref`, `reel_name`, `effects`, `opacity`.
382
+
383
+ Fields that are absent are **not checked**, and the result says which — a
384
+ check that could not run is not a check that passed, and a lint that quietly
385
+ skipped half its rules while reporting "clean" is worse than no lint.
386
+ """
387
+ items = list(snapshot.get("items") or [])
388
+ timeline_fps = float(snapshot.get("timeline_fps") or 0) or None
389
+
390
+ findings: List[Dict[str, Any]] = []
391
+ if timeline_fps:
392
+ findings.extend(check_frame_rates(items, timeline_fps))
393
+ for check in CHECKS:
394
+ findings.extend(check(items))
395
+ findings.extend(check_stacked_layers(items))
396
+
397
+ findings.sort(key=lambda f: (_SEVERITY_RANK.get(f["severity"], 9), f["code"]))
398
+ counts = {
399
+ severity: sum(1 for f in findings if f["severity"] == severity)
400
+ for severity in (SEVERITY_BLOCKER, SEVERITY_WARNING, SEVERITY_INFO)
401
+ }
402
+
403
+ return {
404
+ "success": True,
405
+ "kind": "conform_lint",
406
+ "timeline_name": snapshot.get("timeline_name"),
407
+ "item_count": len(items),
408
+ "finding_count": len(findings),
409
+ "counts": counts,
410
+ "findings": findings,
411
+ "not_checked": _unchecked(items, timeline_fps),
412
+ "clean": not findings,
413
+ "note": (
414
+ "Reports; does not fix. Most findings here are legitimate some of the "
415
+ "time — a shot really can run to the end of its media, a stack really "
416
+ "can be an intentional composite. The failure mode is not that they "
417
+ "exist, it is finding out about them after picture lock."
418
+ ),
419
+ }
420
+
421
+
422
+ def _unchecked(items: Sequence[Mapping[str, Any]], timeline_fps: Optional[float]) -> List[str]:
423
+ """Name the checks that could not run for want of data."""
424
+ gaps: List[str] = []
425
+ if not items:
426
+ return ["every check — the snapshot contains no items"]
427
+ if timeline_fps is None:
428
+ gaps.append("frame-rate mismatch (no timeline_fps in the snapshot)")
429
+ if not any(i.get("source_start_timecode") for i in items):
430
+ gaps.append("duplicate source timecode (no source_start_timecode captured)")
431
+ if not any(i.get("reel_name") for i in items):
432
+ gaps.append("reel-name consistency (no reel_name captured)")
433
+ if not any(i.get("effects") for i in items):
434
+ gaps.append("fragile effects (no effect list captured)")
435
+ if not any(i.get("clip_fps") for i in items):
436
+ gaps.append("per-clip frame rates (no clip_fps captured)")
437
+ return gaps