davinci-resolve-mcp 2.74.0 → 2.76.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,93 @@
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.76.0
6
+
7
+ Three AAF conform-fidelity fixes found by placing a full 83-minute Avid turnover and
8
+ cross-checking it against Resolve's own native import of the same file.
9
+
10
+ ### Fixed
11
+
12
+ - **AAF transitions did not subtract from record advancement.** In the AAF Edit
13
+ Protocol a Transition does not occupy record time, it *overlaps* its neighbours:
14
+ `sequence length == sum(components) − sum(transitions)`. The walker annotated the
15
+ clip after a dissolve but never rewound the record position, so **every later event
16
+ on that track was late by the cumulative transition time** — silent, track-local,
17
+ and invisible on any timeline without a dissolve. On a real turnover a single
18
+ 59-frame dissolve put 651 subsequent V1 events 59 frames past Resolve's native
19
+ import of the same AAF (probe 566/2586/2652 vs native 507/2527/2593); the fixed
20
+ probe reproduces the native positions exactly. The independent cross-check: each
21
+ layer's walked length used to overshoot its own DECLARED length by exactly that
22
+ layer's transition sum (V1 +59, V6 +77, dissolve-free layers +0) — with the
23
+ subtraction, walked equals declared on every layer. A leading transition clamps at
24
+ the sequence start rather than producing a negative record position.
25
+
26
+ ### Added
27
+
28
+ - **Sequence start timecode.** `list_sequences` and `parse_interchange` now report
29
+ `startTimecode`, `startFrame`, `startTimecodeFps` and `startTimecodeDrop` per AAF
30
+ sequence. A conform that places events without it builds at Resolve's default
31
+ 01:00:00:00 while the AAF starts at 00:59:50:00 — every clip ten seconds out, and
32
+ only visible against a linked picture reference. Avid writes one timecode slot *per
33
+ common rate* (a real turnover carried seven — 86160@24, 89750@25, 107592@30-drop,
34
+ 107700@30, 215400@60, all naming the same instant), so the slot matching the
35
+ editorial edit rate is chosen and the rate the frame number is expressed in is always
36
+ reported. An AAF with no timecode slot emits explicit nulls, never a guessed start.
37
+ - **Per-clip geometry** — the Avid transform parameters behind Resolve's "Use sizing
38
+ information" import option. Clips wrapped in `PaintResize_v2`, `SpatialAdapter` or
39
+ `FlipHoriz_2` carry a `geometry` list in application order (innermost first: 84 of
40
+ the fixture's clips nest a SpatialAdapter inside a PaintResize, so a single field
41
+ would have dropped a stage). Scale is emitted as a percent — proven, not assumed,
42
+ because 212 of 285 resize groups sit at exactly 100 — and the source/framing
43
+ rectangles yield the unit-free `reformatScaleX/Y` (0.744792 on the fixture: a 2.39:1
44
+ source letterboxed into a 16:9 framing). Parameters whose units the fixture does
45
+ *not* pin down — position, crop, and the absolute rectangle unit — pass through raw
46
+ under Avid's own names rather than being reinterpreted into a normalized field that
47
+ might ship an inversion, the lesson from `SpeedRatio` being stored as the inverse of
48
+ play rate. Animated parameters are named in `varying` and carry no single value.
49
+
50
+ ### Documentation
51
+
52
+ - `api_truth`: **`TimelineItem.GetSourceStartFrame` reads one frame off on some
53
+ items** while `GetLeftOffset` is exact on the same items — so a conform that
54
+ verifies placement with `GetSourceStartFrame` reports phantom off-by-one drift on
55
+ correctly placed clips, and would hide a real one-frame error just as easily.
56
+ `docs/reference/api-limitations.md` regenerated.
57
+ - The two timeline kernels no longer describe AAF as an honest refuse; it has parsed
58
+ via pyaaf2 since v2.73.x.
59
+
60
+ ### Validation
61
+
62
+ - 2391 Python unit tests, 505 Node advanced tests (40 in the AAF suite, up from 26).
63
+ - All release static checks and drift guards green.
64
+ - Verified offline against a real 878-event multi-layer Avid picture turnover.
65
+ - No Resolve scripting behavior changed — `aaf_probe.py` never touches the Resolve
66
+ API — so no live Resolve validation was required.
67
+
68
+ ## What's New in v2.75.0
69
+
70
+ The offline AAF reader now recovers retime ratios instead of only flagging them.
71
+
72
+ ### Added
73
+
74
+ - **Motion Control speed recovery.** OperationGroup parameters are read: a
75
+ constant `SpeedRatio` emits `speedRatio` (play rate) and corrects `speed`, so
76
+ consumers reading only `speed` are no longer told 100 for a 175% clip. Variable
77
+ timewarps (multi-point speed maps) report `speedVarying: true` rather than a
78
+ fabricated number — the reader's honest-refuse contract extends to speeds.
79
+ Note the stored AAF rational is RECORD/SOURCE (Edit Protocol output-over-input),
80
+ the inverse of play rate; the reader emits play rate, verified against the
81
+ length identity (sourceLen = recordLen / |ratio|) and Avid's own speed maps.
82
+
83
+ ### Fixed
84
+
85
+ - **Motion Control events inflated `recOut`.** Record advancement used the inner
86
+ source clip's length instead of the OperationGroup's declared record length, so
87
+ fast-motion clips claimed more record time than they occupy (and slow motion
88
+ claimed less). On a real 83-minute turnover this produced 40 spurious record
89
+ overlaps; with the declared length driving advancement, one remains — a
90
+ two-input blend genuinely sharing its record span.
91
+
5
92
  ## What's New in v2.74.0
6
93
 
7
94
  ### Added
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.74.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.76.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)
@@ -101,7 +101,8 @@ files and the project DB.
101
101
  grades across a re-conform (pairs with the color kernel's `drx grade_transfer`).
102
102
  - **`offline_ref`** — offline-reference clips have **no scripting API** but live
103
103
  inside `.drp`/`.drt` as `<OfflineClip>` entries; patch them here.
104
- - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML; **AAF = honest refuse**),
104
+ - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
105
+ multi-layer Avid turnovers included — honest refuse only when pyaaf2 is absent),
105
106
  `turnover_changelist` (moved/retimed/replaced/new/gone with timing guards),
106
107
  `conform_manifest`, `marker_roundtrip`.
107
108
  - **`drt` / `project_db`** — timeline file authoring and DB patching.
@@ -186,7 +186,8 @@ The kernel above restructures a *running* timeline. The companion advanced serve
186
186
  (`davinci-resolve-advanced`, see `resolve-advanced/README.md`) authors/diffs
187
187
  timelines and reasons over editorial interchange with **no Resolve running**:
188
188
 
189
- - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML; **AAF = honest refuse**),
189
+ - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
190
+ multi-layer Avid turnovers included — honest refuse only when pyaaf2 is absent),
190
191
  `turnover_changelist` (moved/retimed/replaced/new/gone between two cuts, with
191
192
  timing silent-lie guards), `conform_manifest`, `marker_roundtrip`.
192
193
  - **`drt`** — `.drt` timeline file authoring + structural diff.
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 25 missing capabilities, 24 bugs / unreliable behaviors.
15
+ **Totals:** 25 missing capabilities, 25 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -334,6 +334,14 @@ values, or automation-hostile modal prompts.
334
334
  - **Workaround / current handling:** CloseProject(current) to discard the untitled project without a prompt, then CreateProject; restore with LoadProject afterward.
335
335
  - **Tags:** project, modal, silent-failure
336
336
 
337
+ ### TimelineItem.GetSourceStartFrame
338
+
339
+ - **Object:** `TimelineItem`
340
+ - **Signature:** `() -> int`
341
+ - **Behavior:** Reads back one frame off on some items. Measured while verifying a constructed timeline against the clipInfos it was built from: for 4/4 items GetLeftOffset returned exactly the startFrame that was sent, while GetSourceStartFrame disagreed by 1 on some of the same items. The two are supposed to describe the same edit point, so a conform that verifies placement with GetSourceStartFrame reports phantom off-by-one drift on correctly placed clips — and would hide a real one-frame error just as easily.
342
+ - **Workaround / current handling:** Verify source-side placement with GetLeftOffset, which is exact. Treat GetSourceStartFrame as approximate, and never diff it against a sent startFrame to decide whether a clip landed right.
343
+ - **Tags:** off-by-one, unreliable-return, timeline, conform, verify
344
+
337
345
  ### hasattr() / getattr() on Resolve API objects (attribute fabrication)
338
346
 
339
347
  - **Object:** `(all Resolve scripting objects)`
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.74.0"
39
+ VERSION = "2.76.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (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.74.0",
3
+ "version": "2.76.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -78,18 +78,56 @@ export function resolveAafPath(contentOrPath) {
78
78
  return path.resolve(p);
79
79
  }
80
80
 
81
+ /**
82
+ * Per-sequence summary WITHOUT its events — the picker row, plus the sequence-level facts
83
+ * a conform needs before it places anything.
84
+ *
85
+ * `startTimecode`/`startFrame` are carried deliberately: an AAF that starts at 00:59:50:00
86
+ * built against Resolve's default 01:00:00:00 start puts every clip ten seconds out, and
87
+ * the misalignment only shows up against a linked picture reference. They are null (not
88
+ * absent) when the AAF has no timecode slot — an explicit "no start timecode here" rather
89
+ * than a guess. `startFrame` is expressed at `startTimecodeFps`.
90
+ */
91
+ function sequenceSummary(s) {
92
+ const out = {
93
+ id: String(s.id),
94
+ name: String(s.name),
95
+ eventCount: Number(s.eventCount || 0),
96
+ startTimecode: s.startTimecode ?? null,
97
+ startFrame: s.startFrame ?? null,
98
+ startTimecodeFps: s.startTimecodeFps ?? null,
99
+ startTimecodeDrop: s.startTimecodeDrop ?? null,
100
+ };
101
+ if (s.unhandled && Object.keys(s.unhandled).length) out.unhandled = s.unhandled;
102
+ return out;
103
+ }
104
+
81
105
  /**
82
106
  * Parse an AAF into a FLAT normalized-event list (mirrors parseEDL/parseOTIO/parseXMEML output),
83
- * concatenating every top-level sequence. Use listAafSequences() when you need per-sequence split.
107
+ * concatenating every top-level sequence. Use listAafSequences() when you need per-sequence split,
108
+ * or parseAafDocument() when you also need the sequence-level start timecode.
84
109
  * @param {string} contentOrPath absolute .aaf path
85
110
  * @returns {Promise<Array>} normalized events
86
111
  */
87
112
  export async function parseAAF(contentOrPath) {
113
+ return (await parseAafDocument(contentOrPath)).events;
114
+ }
115
+
116
+ /**
117
+ * Parse an AAF into flat events PLUS the per-sequence summaries they came from.
118
+ *
119
+ * parseAAF() flattens every sequence into one event list, which drops the sequence-level
120
+ * start timecode with it — so a caller that needs to know where the timeline starts had
121
+ * to re-run the probe. This returns both from a single probe run.
122
+ * @param {string} contentOrPath absolute .aaf path
123
+ * @returns {Promise<{events: Array, sequences: Array}>}
124
+ */
125
+ export async function parseAafDocument(contentOrPath) {
88
126
  const aafPath = resolveAafPath(contentOrPath);
89
127
  const { sequences } = await runProbe(aafPath);
90
128
  const events = [];
91
129
  for (const seq of sequences || []) for (const ev of seq.events || []) events.push(ev);
92
- return events;
130
+ return { events, sequences: (sequences || []).map(sequenceSummary) };
93
131
  }
94
132
 
95
133
  /**
@@ -101,14 +139,10 @@ export async function parseAAF(contentOrPath) {
101
139
  * nothing. A NestedScope — Avid's multi-layer video stack — used to land in
102
140
  * exactly that hole: eventCount 0 alongside ok:true. Absent means clean.
103
141
  * @param {string} contentOrPath absolute .aaf path
104
- * @returns {Promise<Array<{id:string,name:string,eventCount:number,unhandled?:Object}>>}
142
+ * @returns {Promise<Array<{id:string,name:string,eventCount:number,startTimecode:?string,startFrame:?number,unhandled?:Object}>>}
105
143
  */
106
144
  export async function listAafSequences(contentOrPath) {
107
145
  const aafPath = resolveAafPath(contentOrPath);
108
146
  const { sequences } = await runProbe(aafPath);
109
- return (sequences || []).map((s) => {
110
- const out = { id: String(s.id), name: String(s.name), eventCount: Number(s.eventCount || 0) };
111
- if (s.unhandled && Object.keys(s.unhandled).length) out.unhandled = s.unhandled;
112
- return out;
113
- });
147
+ return (sequences || []).map(sequenceSummary);
114
148
  }
@@ -11,6 +11,8 @@ trusting, so the Node server shells out to this helper, which uses the pure-Pyth
11
11
  "ok": true,
12
12
  "sequences": [
13
13
  { "id": <mob-id str>, "name": <str>, "eventCount": <int>,
14
+ "startTimecode": <"HH:MM:SS:FF"|null>, "startFrame": <int|null>,
15
+ "startTimecodeFps": <int|null>, "startTimecodeDrop": <bool|null>,
14
16
  "unhandled": { "<ComponentClass>": <int>, ... },
15
17
  "events": [ {normalized-event}, ... ] }
16
18
  ]
@@ -19,6 +21,16 @@ trusting, so the Node server shells out to this helper, which uses the pure-Pyth
19
21
  Normalized event shape mirrors resolve-advanced/server/editorial.mjs `evt()`:
20
22
  { index, track, source, srcIn, srcOut, recIn, recOut, speed, reverse, transition, fps }
21
23
 
24
+ Retime (motion-effect) events additionally carry `"effect"` and, when the ratio is
25
+ recoverable from the OperationGroup's parameters (see _retime_fields):
26
+ * constant ratio → `"speedRatio"`: play-rate float (1.75 = 175%), `"speed"`:
27
+ round(playRate*100), `"reverse"`: true for backwards play.
28
+ * variable speed → `"speedVarying": true` and speed stays 100 — a timewarp has
29
+ no single honest number, so none is fabricated.
30
+ * unrecoverable → the flag alone, speed stays 100 (unchanged old contract).
31
+ For retimes, srcIn/srcOut are the SOURCE-side range while recIn/recOut span the
32
+ OperationGroup's DECLARED (record) length — they differ by the ratio.
33
+
22
34
  Honest-refuse discipline (no fake parses):
23
35
  * exit 3 → pyaaf2 not installed (stderr: AAF_PROBE_NO_PYAAF2)
24
36
  * exit 4 → file unreadable / not an AAF (stderr: AAF_PROBE_UNREADABLE: <detail>)
@@ -40,9 +52,16 @@ Segment model (Avid Media Composer picture turnovers):
40
52
  * Sequence — ordered `.components`, laid end to end.
41
53
  * OperationGroup — effect wrapper. Its `.segments` are the effect INPUTS, and the
42
54
  primary input is usually a nested Sequence (not a bare SourceClip).
55
+ Its `.parameters` carry the retime ratio for motion effects, and
56
+ its own declared length is the RECORD duration of the effect.
43
57
  * Selector — an enabled/disabled layer variant; the live one is `Selected`.
44
58
  * ScopeReference — "show the NestedScope layer beneath me": real record time, no
45
59
  clip of this layer's own. Treated as a gap, like Filler.
60
+ * Transition — a dissolve/wipe that OVERLAPS its neighbours. It does not occupy
61
+ record time of its own; it CONSUMES it. See _walk_components.
62
+
63
+ Retimes additionally carry `"effect"`/`"speedRatio"`; clips wrapped in an Avid transform
64
+ effect carry `"geometry"` (see _geometry_fields).
46
65
  """
47
66
 
48
67
  import json
@@ -224,6 +243,265 @@ def _operation_name(comp):
224
243
  return ""
225
244
 
226
245
 
246
+ # ── Retime (Motion Control) parameter recovery ─────────────────────────────────
247
+ # Avid stores a retime's ratio on the OperationGroup's PARAMETERS. Verified against
248
+ # real Media Composer turnovers by cross-checking the inner SourceClip length vs the
249
+ # group's declared record length AND the PARAM_SPEED_MAP_U control-point values:
250
+ # * "SpeedRatio" (AAF Edit Protocol ParameterDef, a ConstantValue rational) is the
251
+ # RECORD/SOURCE length ratio — i.e. the INVERSE of the play rate. A 175% fast
252
+ # motion is stored as 4/7 (100 record frames consume 175 source frames); reverse
253
+ # play is a NEGATIVE rational (-1/1 = 100% backwards). Mixed-rate pulldown
254
+ # wrappers appear as 1000/1001.
255
+ # * "PARAM_SPEED_MAP_U" (Avid, a VaryingValue) has control points whose VALUES are
256
+ # play-rate scalars directly (1.75 = 175%, negative = reverse). More than one
257
+ # distinct value means the speed VARIES across the clip.
258
+ # * "PARAM_SPEED_RATIO_U" (Avid, a ConstantValue) belongs to the same *_U family
259
+ # as the speed map, so its value is a play-rate scalar, not a SpeedRatio.
260
+
261
+ # The AAF Edit Protocol parameter-definition id for SpeedRatio, so a file whose
262
+ # dictionary lost the human name still resolves.
263
+ _SPEED_RATIO_AUID = "72559a80-24d7-11d3-8a50-0050040ef7d2"
264
+
265
+
266
+ def _op_parameters(op_group):
267
+ """An OperationGroup's Parameter objects; [] when absent or unreadable."""
268
+ try:
269
+ prop = getattr(op_group, "parameters", None)
270
+ if prop is None:
271
+ return []
272
+ value = getattr(prop, "value", None)
273
+ return list(value if value is not None else prop)
274
+ except Exception:
275
+ return []
276
+
277
+
278
+ def _param_name(param):
279
+ try:
280
+ return str(param.name or "")
281
+ except Exception:
282
+ return ""
283
+
284
+
285
+ def _param_is(param, name, auid=None):
286
+ if _param_name(param) == name:
287
+ return True
288
+ if auid:
289
+ try:
290
+ return str(param.auid).lower() == auid
291
+ except Exception:
292
+ return False
293
+ return False
294
+
295
+
296
+ def _pointlist_values(varying):
297
+ """Control-point VALUES of a VaryingValue's point list, or None if unreadable."""
298
+ points = getattr(varying, "pointlist", None)
299
+ if points is None:
300
+ return None
301
+ inner = getattr(points, "value", None)
302
+ if inner is not None:
303
+ points = inner
304
+ try:
305
+ return [float(p.value) for p in points]
306
+ except Exception:
307
+ return None
308
+
309
+
310
+ def _retime_fields(op_group):
311
+ """Extra event fields recovered from a retime OperationGroup's parameters.
312
+
313
+ Returns one of:
314
+ {"speedRatio": <play-rate float>, "speed": <int %>, "reverse": <bool>}
315
+ {"speedVarying": True} — a variable-speed timewarp; no single honest number
316
+ {} — nothing recoverable (flag-only, speed stays 100)
317
+ """
318
+ play = None
319
+ speed_map = None
320
+ for param in _op_parameters(op_group):
321
+ cls = type(param).__name__
322
+ if cls == "VaryingValue":
323
+ if _param_name(param) == "PARAM_SPEED_MAP_U":
324
+ speed_map = param
325
+ continue
326
+ if cls != "ConstantValue":
327
+ continue
328
+ if _param_is(param, "SpeedRatio", _SPEED_RATIO_AUID):
329
+ try:
330
+ value = param.value
331
+ num = int(value.numerator)
332
+ den = int(value.denominator)
333
+ except Exception:
334
+ continue
335
+ if num:
336
+ play = den / num # stored record/source → play rate is the inverse
337
+ elif _param_name(param) == "PARAM_SPEED_RATIO_U" and play is None:
338
+ try:
339
+ value = float(param.value)
340
+ except Exception:
341
+ continue
342
+ if value:
343
+ play = value # *_U family stores the play rate directly
344
+ if speed_map is not None:
345
+ values = _pointlist_values(speed_map)
346
+ if values is None:
347
+ # A speed map we cannot read: we can neither call the speed constant
348
+ # nor prove it varies — recover nothing rather than guess.
349
+ return {}
350
+ if len(set(values)) > 1:
351
+ return {"speedVarying": True}
352
+ if play is None and values and values[0]:
353
+ play = values[0] # a flat map's single value IS the constant play rate
354
+ if not play:
355
+ return {}
356
+ return {
357
+ "speedRatio": round(abs(play), 6),
358
+ "speed": int(round(abs(play) * 100)),
359
+ "reverse": play < 0,
360
+ }
361
+
362
+
363
+ # ── Per-clip geometry (Avid transform OperationGroups) ────────────────────────
364
+ # The data behind Resolve's "Use sizing information" AAF import option. Census of a
365
+ # real 878-event Avid picture turnover — the parameters that are actually present,
366
+ # by operation:
367
+ #
368
+ # PaintResize_v2 (285) AFX_SCALE_X_U / AFX_SCALE_Y_U percent, 100 = identity
369
+ # AFX_POS_X_U / AFX_POS_Y_U Avid position units
370
+ # AFX_CROP_{LEFT,RIGHT,TOP,BOTTOM}_U Avid crop units
371
+ # AFX_FIXED_ASPECT_U bool (aspect locked)
372
+ # SpatialAdapter (90) AFX_SPATIAL_SOURCE_{WID,HEI}_{NUM,DEN} source rectangle
373
+ # AFX_SPATIAL_FRAMING_{WID,HEI}_{NUM,DEN} framing rectangle
374
+ # AFX_SPATIAL_REFORMAT, AFX_SCALE_{X,Y}_U
375
+ # FlipHoriz_2 (10) a horizontal flip; carries no geometry parameters of its own
376
+ #
377
+ # What the fixture PROVES, and is therefore emitted as a named field:
378
+ # * scale is a PERCENT — 212 of 285 PaintResize groups sit at exactly 100, i.e.
379
+ # identity, which pins the unit without needing a reference render.
380
+ # * the source and framing rectangles share ONE unit, so their RATIO is unit-free.
381
+ # `reformatScaleX/Y` is that ratio (a 5120/3 x 900 source into a 1200 x 900
382
+ # framing is the 2.39:1-into-16:9 letterbox the reference showed).
383
+ #
384
+ # What it does NOT prove, and is therefore passed through RAW under Avid's own
385
+ # parameter names in `params` rather than reinterpreted into a normalized field:
386
+ # the unit of POS_*/CROP_*, and the ABSOLUTE unit of the rectangles. This is the
387
+ # SpeedRatio lesson applied ahead of time — that one was stored as the INVERSE of
388
+ # play rate, and a normalized-looking field would have shipped the inversion. A
389
+ # stored Avid number keeps its own name until its semantics are measured.
390
+
391
+ _GEOMETRY_OPS = frozenset({"PaintResize_v2", "SpatialAdapter", "FlipHoriz_2"})
392
+
393
+ _GEOMETRY_SCALARS = frozenset(
394
+ {
395
+ "AFX_POS_X_U",
396
+ "AFX_POS_Y_U",
397
+ "AFX_CROP_LEFT_U",
398
+ "AFX_CROP_RIGHT_U",
399
+ "AFX_CROP_TOP_U",
400
+ "AFX_CROP_BOTTOM_U",
401
+ "AFX_SPATIAL_REFORMAT",
402
+ }
403
+ )
404
+ _GEOMETRY_BOOLS = frozenset({"AFX_FIXED_ASPECT_U", "AFX_SPATIAL_LOCK_ASPECT"})
405
+ # Rectangles arrive split across a <base>_NUM / <base>_DEN rational pair.
406
+ _GEOMETRY_RECTS = frozenset(
407
+ {
408
+ "AFX_SPATIAL_SOURCE_WID",
409
+ "AFX_SPATIAL_SOURCE_HEI",
410
+ "AFX_SPATIAL_FRAMING_WID",
411
+ "AFX_SPATIAL_FRAMING_HEI",
412
+ }
413
+ )
414
+
415
+
416
+ def _param_number(param):
417
+ """A Parameter's ConstantValue as a float (AAFRational, int, bool all convert)."""
418
+ try:
419
+ return round(float(param.value), 6)
420
+ except Exception:
421
+ return None
422
+
423
+
424
+ def _geometry_scalar(param):
425
+ """(value, is_varying) for one geometry parameter, or (None, False) if unreadable.
426
+
427
+ A VaryingValue whose control points all carry the SAME value is a constant — the
428
+ same rule the speed map uses. More than one distinct value is an animated
429
+ transform, and no single number for it would be honest.
430
+ """
431
+ if type(param).__name__ == "VaryingValue":
432
+ values = _pointlist_values(param)
433
+ if values is None:
434
+ return None, False
435
+ if len(set(values)) > 1:
436
+ return None, True
437
+ return (round(float(values[0]), 6) if values else None), False
438
+ return _param_number(param), False
439
+
440
+
441
+ def _geometry_fields(op_group, op_name):
442
+ """Recovered geometry for one transform OperationGroup. See the census above."""
443
+ scale = {}
444
+ scalars = {}
445
+ bools = {}
446
+ rects = {}
447
+ varying = set()
448
+ for param in _op_parameters(op_group):
449
+ name = _param_name(param)
450
+ base, _, suffix = name.rpartition("_")
451
+ if suffix in ("NUM", "DEN") and base in _GEOMETRY_RECTS:
452
+ value = _param_number(param)
453
+ if value is not None:
454
+ rects.setdefault(base, {})[suffix] = value
455
+ continue
456
+ if name in ("AFX_SCALE_X_U", "AFX_SCALE_Y_U"):
457
+ value, is_varying = _geometry_scalar(param)
458
+ if is_varying:
459
+ varying.add(name)
460
+ elif value is not None:
461
+ scale[name] = value
462
+ continue
463
+ if name in _GEOMETRY_BOOLS:
464
+ value = _param_number(param)
465
+ if value is not None:
466
+ bools[name] = bool(value)
467
+ continue
468
+ if name in _GEOMETRY_SCALARS:
469
+ value, is_varying = _geometry_scalar(param)
470
+ if is_varying:
471
+ varying.add(name)
472
+ elif value is not None:
473
+ scalars[name] = value
474
+ geometry = {"effect": op_name}
475
+ if op_name == "FlipHoriz_2":
476
+ geometry["flipHorizontal"] = True
477
+ if "AFX_SCALE_X_U" in scale:
478
+ geometry["scalePercentX"] = scale["AFX_SCALE_X_U"]
479
+ if "AFX_SCALE_Y_U" in scale:
480
+ geometry["scalePercentY"] = scale["AFX_SCALE_Y_U"]
481
+ sizes = {}
482
+ for base, parts in rects.items():
483
+ num, den = parts.get("NUM"), parts.get("DEN")
484
+ if num is None or not den:
485
+ continue
486
+ sizes[base] = round(num / den, 6)
487
+ if sizes:
488
+ geometry["rect"] = dict(sorted(sizes.items()))
489
+ # The one unit-free quantity the rectangles yield: framing over source.
490
+ for axis, src, framing in (
491
+ ("X", "AFX_SPATIAL_SOURCE_WID", "AFX_SPATIAL_FRAMING_WID"),
492
+ ("Y", "AFX_SPATIAL_SOURCE_HEI", "AFX_SPATIAL_FRAMING_HEI"),
493
+ ):
494
+ if sizes.get(src) and sizes.get(framing) is not None:
495
+ geometry[f"reformatScale{axis}"] = round(sizes[framing] / sizes[src], 6)
496
+ if varying:
497
+ # An animated transform. Named, never reduced to one number.
498
+ geometry["varying"] = sorted(varying)
499
+ params = dict(sorted({**scalars, **bools}.items()))
500
+ if params:
501
+ geometry["params"] = params
502
+ return geometry
503
+
504
+
227
505
  def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
228
506
  """
229
507
  Emit normalized events for ONE segment placed at record position `rec`.
@@ -288,11 +566,34 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
288
566
  for inp in getattr(segment, "segments", None) or []:
289
567
  _walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
290
568
  op_name = _operation_name(segment)
569
+ if op_name in _GEOMETRY_OPS:
570
+ # A transform effect. Clips are commonly wrapped in MORE than one (a
571
+ # SpatialAdapter reformat inside a PaintResize, say), so geometry is a
572
+ # LIST in application order — innermost first, because this walk annotates
573
+ # on the way back out. Collapsing them to one field would silently drop a
574
+ # stage of the transform stack.
575
+ geometry = _geometry_fields(segment, op_name)
576
+ for ev in state["events"][before:]:
577
+ ev.setdefault("geometry", []).append(geometry)
291
578
  if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
292
- # We can detect that a retime is present but not reliably its ratio
293
- # offline; flag it honestly rather than fake a speed number.
579
+ # A retime. Its ratio is recoverable from the group's PARAMETERS (see
580
+ # _retime_fields): a constant ratio updates speed/speedRatio so
581
+ # consumers reading only `speed` are no longer told 100; a variable
582
+ # timewarp is reported as speedVarying: true; an unreadable one keeps
583
+ # the old flag-only contract. A number is never fabricated.
584
+ extra = _retime_fields(segment)
294
585
  for ev in state["events"][before:]:
295
586
  ev["effect"] = op_name
587
+ ev.update(extra)
588
+ # The group's DECLARED length is the RECORD duration; the inner
589
+ # SourceClip's length is the SOURCE-side range — under a retime
590
+ # they differ by the ratio, so an event whose recOut was advanced
591
+ # by the source length inflates (fast motion) or undershoots
592
+ # (slow motion) its real record span. The container declared-
593
+ # length rule (this file's convention for rec advancement)
594
+ # applies to the events too.
595
+ if declared > 0:
596
+ ev["recOut"] = max(ev["recIn"], rec + declared)
296
597
  return declared
297
598
 
298
599
  # Unknown component — advance by its declared length, don't fake an event, and
@@ -302,7 +603,25 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
302
603
 
303
604
 
304
605
  def _walk_components(sequence, *, track, fps, rec, state, depth, transition=None):
305
- """Lay a Sequence's components end to end. Returns the record length consumed."""
606
+ """Lay a Sequence's components end to end. Returns the record length consumed.
607
+
608
+ Transitions SUBTRACT. This is the dual of the declared-length rule above, and it
609
+ is the AAF Edit Protocol's definition rather than a heuristic: a Transition is not
610
+ a component that occupies record time, it is an OVERLAP of the two components
611
+ around it, so
612
+
613
+ sequence length == sum(component lengths) - sum(transition lengths)
614
+
615
+ and the component after a transition starts `duration` frames EARLIER than the
616
+ previous one ended. Annotating the following clip (which this walker already did)
617
+ without rewinding `rec` left every later event on that track late by the CUMULATIVE
618
+ transition time — silent, track-local, and invisible on any timeline without a
619
+ dissolve. Measured on a real Avid turnover: V1's single 59-frame dissolve put every
620
+ subsequent V1 cut 59 frames past Resolve's own native import of the same file, and
621
+ each layer's walked length overshot its DECLARED length by exactly the sum of that
622
+ layer's transitions (V1 +59, V6 +77, every dissolve-free layer +0). The declared
623
+ length is the cross-check — with the subtraction, walked == declared on every layer.
624
+ """
306
625
  start = rec
307
626
  pending_transition = transition
308
627
  components = getattr(sequence, "components", None)
@@ -312,8 +631,13 @@ def _walk_components(sequence, *, track, fps, rec, state, depth, transition=None
312
631
  cls = type(comp).__name__
313
632
  try:
314
633
  if cls == "Transition":
315
- # A transition overlaps its neighbours; it does not advance rec itself.
316
- pending_transition = {"type": "dissolve", "duration": _length(comp)}
634
+ duration = _length(comp)
635
+ pending_transition = {"type": "dissolve", "duration": duration}
636
+ # Rewind: the next component overlaps the previous one by `duration`.
637
+ # Clamped at this sequence's own start — a leading transition has no
638
+ # preceding material to overlap, and a negative record position would
639
+ # be a worse lie than the malformed AAF that produced it.
640
+ rec = max(start, rec - duration)
317
641
  continue
318
642
  rec += _walk_segment(
319
643
  comp, track=track, fps=fps, rec=rec, state=state, depth=depth, transition=pending_transition
@@ -375,6 +699,147 @@ def _media_kind_to_track(slot):
375
699
  return "A" if _slot_media_kind(slot).startswith("sound") else "V"
376
700
 
377
701
 
702
+ # ── Sequence start timecode ───────────────────────────────────────────────────
703
+ # A composition does NOT have one timecode slot. Avid writes several — one per common
704
+ # timecode rate — all naming the same wall-clock start. A real turnover carried seven:
705
+ #
706
+ # start 86160 @24 start 89750 @25 start 107592 @30 drop
707
+ # start 107700 @30 start 215400 @60 (and duplicates)
708
+ #
709
+ # Every one of those is 00:59:50:00. They agree on the STRING and disagree on the
710
+ # FRAME NUMBER, so "read the first timecode slot" hands back a frame count in a rate
711
+ # the rest of the parse never uses — a 10%-off offset that looks plausible. Pick the
712
+ # slot whose rate matches the EDITORIAL edit rate, and always report the rate the
713
+ # frame number is expressed in so a mismatch can't hide.
714
+ #
715
+ # This mattered: a consumer built its conform timeline at Resolve's default
716
+ # 01:00:00:00 while the AAF started at 00:59:50:00, mis-aligning the linked picture
717
+ # reference by ten seconds until it was hand-fixed.
718
+
719
+ _TIMECODE_KIND = "timecode"
720
+
721
+
722
+ def _timecode_component(segment, depth=0):
723
+ """The Timecode component inside a timecode slot's segment, or None.
724
+
725
+ Avid wraps it in a Pulldown (the rate conversion that makes a 30-drop view of a
726
+ 23.976 sequence), sometimes inside a Sequence, so the slot segment's own class
727
+ name is not enough to find it.
728
+ """
729
+ if segment is None or depth > 8:
730
+ return None
731
+ cls = type(segment).__name__
732
+ if cls == "Timecode":
733
+ return segment
734
+ children = []
735
+ if cls == "Sequence":
736
+ children = list(getattr(segment, "components", None) or [])
737
+ elif cls == "Pulldown":
738
+ # pyaaf2 exposes the wrapped segment as the InputSegment PROPERTY, not an
739
+ # attribute — same access pattern as Selector's `Selected`.
740
+ for getter in (
741
+ lambda s: s["InputSegment"].value,
742
+ lambda s: s.getvalue("InputSegment"),
743
+ lambda s: getattr(s, "input_segment", None),
744
+ ):
745
+ try:
746
+ inner = getter(segment)
747
+ except Exception:
748
+ inner = None
749
+ if inner is not None:
750
+ children = [inner]
751
+ break
752
+ for child in children:
753
+ found = _timecode_component(child, depth + 1)
754
+ if found is not None:
755
+ return found
756
+ return None
757
+
758
+
759
+ def _frames_to_timecode(frames, rate, drop):
760
+ """SMPTE timecode string for a frame count at an INTEGER timecode rate.
761
+
762
+ Drop-frame (`;` separator) skips two frame NUMBERS — four at the 60-family rate —
763
+ at the top of every minute except every tenth. It renumbers; it never drops a
764
+ picture frame, which is why the conversion is a renumbering pass and not a
765
+ rescaling of `frames`.
766
+ """
767
+ try:
768
+ frames = int(frames)
769
+ rate = int(round(float(rate)))
770
+ except Exception:
771
+ return None
772
+ if rate <= 0 or frames < 0:
773
+ return None
774
+ sep = ":"
775
+ if drop and rate % 30 == 0:
776
+ dropped = 2 * (rate // 30)
777
+ per_10min = rate * 600 - dropped * 9
778
+ per_min = rate * 60 - dropped
779
+ tens, rem = divmod(frames, per_10min)
780
+ frames += dropped * 9 * tens
781
+ if rem > dropped:
782
+ frames += dropped * ((rem - dropped) // per_min)
783
+ sep = ";"
784
+ total_seconds, ff = divmod(frames, rate)
785
+ hh, rem_seconds = divmod(total_seconds, 3600)
786
+ mm, ss = divmod(rem_seconds, 60)
787
+ return f"{hh % 24:02d}:{mm:02d}:{ss:02d}{sep}{ff:02d}"
788
+
789
+
790
+ # Absent-timecode sequences report these as null rather than omitting them: an explicit
791
+ # null is a readable "this AAF carries no start timecode", where a missing key is
792
+ # indistinguishable from an older probe that never emitted one.
793
+ _NO_START_TIMECODE = {
794
+ "startTimecode": None,
795
+ "startFrame": None,
796
+ "startTimecodeFps": None,
797
+ "startTimecodeDrop": None,
798
+ }
799
+
800
+
801
+ def _sequence_start_timecode(mob, edit_fps):
802
+ """Start-timecode fields for a composition mob. Never guesses — see _NO_START_TIMECODE.
803
+
804
+ `startFrame` is expressed at `startTimecodeFps`, which is the rate of the slot we
805
+ picked; it equals the editorial edit rate whenever a matching slot exists.
806
+ """
807
+ candidates = []
808
+ for slot in getattr(mob, "slots", []) or []:
809
+ if _slot_media_kind(slot) != _TIMECODE_KIND:
810
+ continue
811
+ tc = _timecode_component(getattr(slot, "segment", None))
812
+ if tc is None:
813
+ continue
814
+ try:
815
+ start = int(getattr(tc, "start", None))
816
+ rate = int(round(float(getattr(tc, "fps", 0) or 0)))
817
+ except Exception:
818
+ continue
819
+ if rate <= 0 or start < 0:
820
+ continue
821
+ try:
822
+ drop = bool(getattr(tc, "drop", False))
823
+ except Exception:
824
+ drop = False
825
+ candidates.append((start, rate, drop))
826
+ if not candidates:
827
+ return dict(_NO_START_TIMECODE)
828
+ wanted = int(round(edit_fps)) if edit_fps else None
829
+ chosen = None
830
+ if wanted:
831
+ chosen = next((c for c in candidates if c[1] == wanted), None)
832
+ if chosen is None:
833
+ chosen = candidates[0]
834
+ start, rate, drop = chosen
835
+ return {
836
+ "startTimecode": _frames_to_timecode(start, rate, drop),
837
+ "startFrame": start,
838
+ "startTimecodeFps": rate,
839
+ "startTimecodeDrop": drop,
840
+ }
841
+
842
+
378
843
  def probe(path):
379
844
  import aaf2
380
845
 
@@ -399,6 +864,7 @@ def probe(path):
399
864
  name = None
400
865
  # `idx` is monotonic across the WHOLE mob, every slot and every nested layer.
401
866
  state = {"idx": 1, "events": [], "unhandled": {}}
867
+ edit_fps = None
402
868
  for slot in getattr(mob, "slots", []) or []:
403
869
  seg = getattr(slot, "segment", None)
404
870
  if seg is None:
@@ -407,18 +873,17 @@ def probe(path):
407
873
  # metadata/sound master) — see _NON_EDITORIAL_KINDS.
408
874
  if not _is_editorial_slot(slot):
409
875
  continue
410
- _walk_slot(
411
- seg,
412
- prefix=_media_kind_to_track(slot),
413
- fps=_fps_from_edit_rate(getattr(slot, "edit_rate", None)),
414
- state=state,
415
- )
876
+ fps = _fps_from_edit_rate(getattr(slot, "edit_rate", None))
877
+ if edit_fps is None:
878
+ edit_fps = fps # picks the timecode slot to trust — see above
879
+ _walk_slot(seg, prefix=_media_kind_to_track(slot), fps=fps, state=state)
416
880
  events = state["events"]
417
881
  sequences.append(
418
882
  {
419
883
  "id": mob_id or (str(name) if name else f"seq{len(sequences) + 1}"),
420
884
  "name": str(name) if name else f"Sequence {len(sequences) + 1}",
421
885
  "eventCount": len(events),
886
+ **_sequence_start_timecode(mob, edit_fps),
422
887
  # Component classes we could not model, by name+count. Empty {} means
423
888
  # a structurally complete read; non-empty means events are INCOMPLETE.
424
889
  "unhandled": dict(sorted(state["unhandled"].items())),
@@ -5,6 +5,7 @@
5
5
  * Actions:
6
6
  * parse_interchange — EDL / OTIO / XMEML / AAF (pyaaf2) / PRPROJ (gunzip+XML) → normalized events
7
7
  * list_sequences — ONE picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}]
8
+ * (AAF rows also carry startTimecode/startFrame — see aaf.mjs sequenceSummary)
8
9
  * convert_to_interchange— events (or a parsed source) → OTIO/EDL/DRT Resolve CAN import (the .prproj bridge)
9
10
  * turnover_changelist — diff old vs new events → moved/retimed/replaced/new/gone (+timing flags)
10
11
  * conform_manifest — per-event assert: source resolved, handles, retime, reverse, TC-base
@@ -13,7 +14,7 @@
13
14
  import fs from 'node:fs/promises';
14
15
  import { z } from 'zod';
15
16
  import { parseInterchange, diffChangelist, timingGuards, conformManifest, markerRoundtrip } from '../editorial.mjs';
16
- import { parseAAF } from '../aaf.mjs';
17
+ import { parseAAF, parseAafDocument } from '../aaf.mjs';
17
18
  import { parsePrproj, parsePrprojDoc } from '../prproj.mjs';
18
19
  import { listSequences, detectFormat } from '../sequences.mjs';
19
20
  import { authorInterchange } from '../author-interchange.mjs';
@@ -81,14 +82,17 @@ const markerSchema = z.object({
81
82
  export const editorialTool = {
82
83
  name: 'editorial',
83
84
  description:
84
- 'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}]), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions/speed survive, per-clip effects/color do not), turnover_changelist (diff old vs new → moved/retimed/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base), marker_roundtrip (markers with provenance tags). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
85
+ 'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions/speed survive, per-clip effects/color do not), turnover_changelist (diff old vs new → moved/retimed/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base), marker_roundtrip (markers with provenance tags). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
85
86
  async handler({ action, args }) {
86
87
  if (action === 'parse_interchange') {
87
88
  const p = parseSchema.parse(args);
88
89
  // Binary formats parse out-of-band from a PATH: AAF via pyaaf2, .prproj via gunzip+XML.
89
90
  if (p.format === 'aaf') {
90
- const events = await parseAAF(p.content);
91
- return { format: 'aaf', count: events.length, events };
91
+ // `sequences` carries the per-sequence start timecode, which the flattened event
92
+ // list cannot express a conform that places events without it lands the whole
93
+ // timeline at the wrong start.
94
+ const { events, sequences } = await parseAafDocument(p.content);
95
+ return { format: 'aaf', count: events.length, events, sequences };
92
96
  }
93
97
  if (p.format === 'prproj') {
94
98
  const doc = parsePrprojDoc(p.content);
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.74.0"
88
+ VERSION = "2.76.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  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.74.0"
14
+ VERSION = "2.76.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -516,6 +516,24 @@ API_TRUTH: List[Dict[str, Any]] = [
516
516
  "tags": ["missing-method", "timeline", "edit", "trim"],
517
517
  "submit": "missing",
518
518
  },
519
+ {
520
+ "symbol": "TimelineItem.GetSourceStartFrame",
521
+ "object": "TimelineItem",
522
+ "signature": "() -> int",
523
+ "reality": "Reads back one frame off on some items. Measured while verifying a "
524
+ "constructed timeline against the clipInfos it was built from: for "
525
+ "4/4 items GetLeftOffset returned exactly the startFrame that was "
526
+ "sent, while GetSourceStartFrame disagreed by 1 on some of the same "
527
+ "items. The two are supposed to describe the same edit point, so a "
528
+ "conform that verifies placement with GetSourceStartFrame reports "
529
+ "phantom off-by-one drift on correctly placed clips — and would hide "
530
+ "a real one-frame error just as easily.",
531
+ "recommended": "Verify source-side placement with GetLeftOffset, which is exact. "
532
+ "Treat GetSourceStartFrame as approximate, and never diff it "
533
+ "against a sent startFrame to decide whether a clip landed right.",
534
+ "tags": ["off-by-one", "unreliable-return", "timeline", "conform", "verify"],
535
+ "submit": "bug",
536
+ },
519
537
  {
520
538
  "symbol": "Razor / blade / split a timeline item",
521
539
  "object": "Timeline / TimelineItem",