davinci-resolve-mcp 2.75.0 → 2.77.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,111 @@
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.77.0
6
+
7
+ Folder addressing fails loud instead of quietly answering about whichever bin the
8
+ UI happens to have open. Contributed by [@billcarroll](https://github.com/billcarroll)
9
+ in [#116](https://github.com/samuelgursky/davinci-resolve-mcp/pull/116).
10
+
11
+ ### Fixed
12
+
13
+ - **An unresolvable folder address no longer falls back to the current bin.**
14
+ `media_pool add_subfolder` and `media_pool get_timeline_mattes` resolved their
15
+ folder argument with `_navigate_folder(...) or fallback`, so a typo'd path was
16
+ dropped and the action proceeded against the current bin (or root) with a
17
+ `success` envelope. For a read that is a wrong answer indistinguishable from a
18
+ right one; for `add_subfolder` it creates the folder wherever the UI happens to
19
+ be pointed. All three sites now share one resolver that returns
20
+ `FOLDER_NOT_FOUND` / `invalid_input` when a supplied address does not resolve,
21
+ with remediation naming `get_subfolders`.
22
+ - **A bad folder path came back marked retryable.** The `folder` tool did already
23
+ error on an unresolvable `path`, but with a bare message, so the envelope
24
+ defaulted to `resolve_api_failed` and told the caller to retry an address that
25
+ would never resolve. It is now `invalid_input`, non-retryable — the caller's to
26
+ fix.
27
+
28
+ ### Added
29
+
30
+ - **`folder_id` is accepted as a folder address**, alongside `path`, on the
31
+ `folder` tool, `media_pool add_subfolder`, and `media_pool get_timeline_mattes`.
32
+ `get_subfolders` hands out ids, and having no way to spend them is what invited
33
+ agents to guess a `folder_id` argument that no action read — which was silently
34
+ dropped, returned the current bin's contents with `success`, and made the tools
35
+ look like they ignored their arguments. Omitting every address still means what
36
+ it did before: the current folder for the `folder` tool, the root folder for the
37
+ two `media_pool` actions.
38
+
39
+ ### Known limitation
40
+
41
+ Only `path`/`folder_path`/`folderPath` and `folder_id`/`folderId` are recognised
42
+ as addresses. Any other invented key (`id`, `bin`, `folderName`) is still dropped,
43
+ and the action still answers about its default folder with `success`. This release
44
+ narrows the silent-wrong-folder class to a known key set rather than closing it;
45
+ closing it needs unknown-parameter rejection at the dispatch layer.
46
+
47
+ ## What's New in v2.76.0
48
+
49
+ Three AAF conform-fidelity fixes found by placing a full 83-minute Avid turnover and
50
+ cross-checking it against Resolve's own native import of the same file.
51
+
52
+ ### Fixed
53
+
54
+ - **AAF transitions did not subtract from record advancement.** In the AAF Edit
55
+ Protocol a Transition does not occupy record time, it *overlaps* its neighbours:
56
+ `sequence length == sum(components) − sum(transitions)`. The walker annotated the
57
+ clip after a dissolve but never rewound the record position, so **every later event
58
+ on that track was late by the cumulative transition time** — silent, track-local,
59
+ and invisible on any timeline without a dissolve. On a real turnover a single
60
+ 59-frame dissolve put 651 subsequent V1 events 59 frames past Resolve's native
61
+ import of the same AAF (probe 566/2586/2652 vs native 507/2527/2593); the fixed
62
+ probe reproduces the native positions exactly. The independent cross-check: each
63
+ layer's walked length used to overshoot its own DECLARED length by exactly that
64
+ layer's transition sum (V1 +59, V6 +77, dissolve-free layers +0) — with the
65
+ subtraction, walked equals declared on every layer. A leading transition clamps at
66
+ the sequence start rather than producing a negative record position.
67
+
68
+ ### Added
69
+
70
+ - **Sequence start timecode.** `list_sequences` and `parse_interchange` now report
71
+ `startTimecode`, `startFrame`, `startTimecodeFps` and `startTimecodeDrop` per AAF
72
+ sequence. A conform that places events without it builds at Resolve's default
73
+ 01:00:00:00 while the AAF starts at 00:59:50:00 — every clip ten seconds out, and
74
+ only visible against a linked picture reference. Avid writes one timecode slot *per
75
+ common rate* (a real turnover carried seven — 86160@24, 89750@25, 107592@30-drop,
76
+ 107700@30, 215400@60, all naming the same instant), so the slot matching the
77
+ editorial edit rate is chosen and the rate the frame number is expressed in is always
78
+ reported. An AAF with no timecode slot emits explicit nulls, never a guessed start.
79
+ - **Per-clip geometry** — the Avid transform parameters behind Resolve's "Use sizing
80
+ information" import option. Clips wrapped in `PaintResize_v2`, `SpatialAdapter` or
81
+ `FlipHoriz_2` carry a `geometry` list in application order (innermost first: 84 of
82
+ the fixture's clips nest a SpatialAdapter inside a PaintResize, so a single field
83
+ would have dropped a stage). Scale is emitted as a percent — proven, not assumed,
84
+ because 212 of 285 resize groups sit at exactly 100 — and the source/framing
85
+ rectangles yield the unit-free `reformatScaleX/Y` (0.744792 on the fixture: a 2.39:1
86
+ source letterboxed into a 16:9 framing). Parameters whose units the fixture does
87
+ *not* pin down — position, crop, and the absolute rectangle unit — pass through raw
88
+ under Avid's own names rather than being reinterpreted into a normalized field that
89
+ might ship an inversion, the lesson from `SpeedRatio` being stored as the inverse of
90
+ play rate. Animated parameters are named in `varying` and carry no single value.
91
+
92
+ ### Documentation
93
+
94
+ - `api_truth`: **`TimelineItem.GetSourceStartFrame` reads one frame off on some
95
+ items** while `GetLeftOffset` is exact on the same items — so a conform that
96
+ verifies placement with `GetSourceStartFrame` reports phantom off-by-one drift on
97
+ correctly placed clips, and would hide a real one-frame error just as easily.
98
+ `docs/reference/api-limitations.md` regenerated.
99
+ - The two timeline kernels no longer describe AAF as an honest refuse; it has parsed
100
+ via pyaaf2 since v2.73.x.
101
+
102
+ ### Validation
103
+
104
+ - 2391 Python unit tests, 505 Node advanced tests (40 in the AAF suite, up from 26).
105
+ - All release static checks and drift guards green.
106
+ - Verified offline against a real 878-event multi-layer Avid picture turnover.
107
+ - No Resolve scripting behavior changed — `aaf_probe.py` never touches the Resolve
108
+ API — so no live Resolve validation was required.
109
+
5
110
  ## What's New in v2.75.0
6
111
 
7
112
  The offline AAF reader now recovers retime ratios instead of only flagging them.
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.75.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.77.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -617,9 +617,25 @@ switching, and flattening remain Resolve UI workflows; see
617
617
  Note: `folder path` arguments use slash notation like `"Master/SubFolder"`.
618
618
  `"Master"` or `"/"` refers to the root folder.
619
619
 
620
+ Address a folder either by `path` or by `folder_id` — the id `get_subfolders`
621
+ returns for each entry (v2.77.0+; the same pair works for `media_pool
622
+ add_subfolder` via `parent_path`/`folder_id` and for `media_pool
623
+ get_timeline_mattes` via `folder_path`/`folder_id`). Omit both to get the
624
+ action's default: the current folder for the `folder` tool, the root folder for
625
+ those two `media_pool` actions. An address that is supplied but does not resolve
626
+ is a `FOLDER_NOT_FOUND` / `invalid_input` error — it never quietly falls back to
627
+ the current bin.
628
+
629
+ That fallback is what these tools used to do, so treat a pre-v2.77.0 server as
630
+ unable to tell you when it answered about the wrong folder. Note also that only
631
+ `path`/`folder_path`/`folderPath` and `folder_id`/`folderId` are recognised as
632
+ addresses: any other key you invent (`id`, `bin`, `folderName`) is still
633
+ silently dropped, and the action still answers about its default folder with
634
+ `success`. Use the documented names.
635
+
620
636
  **`folder`** — Operations on a specific Media Pool folder.
621
637
 
622
- Key actions: `get_clips(path?)`, `get_subfolders(path?)`, `export(path?, export_path)`,
638
+ Key actions: `get_clips(path?|folder_id?)`, `get_subfolders(path?|folder_id?)`, `export(path?, export_path)`,
623
639
  `transcribe_audio(path?, use_speaker_detection?)`, `clear_transcription(path?)`,
624
640
  `perform_audio_classification(path?)`, `analyze_for_intellisearch(path?, identify_faces?, is_better_mode?)`,
625
641
  `analyze_for_slate(path?, marker_color?)`, `remove_motion_blur(path?, deblur_option?)` (Resolve 21+;
@@ -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.75.0"
39
+ VERSION = "2.77.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.75.0",
3
+ "version": "2.77.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
  ]
@@ -55,6 +57,11 @@ Segment model (Avid Media Composer picture turnovers):
55
57
  * Selector — an enabled/disabled layer variant; the live one is `Selected`.
56
58
  * ScopeReference — "show the NestedScope layer beneath me": real record time, no
57
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).
58
65
  """
59
66
 
60
67
  import json
@@ -353,6 +360,148 @@ def _retime_fields(op_group):
353
360
  }
354
361
 
355
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
+
356
505
  def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
357
506
  """
358
507
  Emit normalized events for ONE segment placed at record position `rec`.
@@ -417,6 +566,15 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
417
566
  for inp in getattr(segment, "segments", None) or []:
418
567
  _walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
419
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)
420
578
  if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
421
579
  # A retime. Its ratio is recoverable from the group's PARAMETERS (see
422
580
  # _retime_fields): a constant ratio updates speed/speedRatio so
@@ -445,7 +603,25 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
445
603
 
446
604
 
447
605
  def _walk_components(sequence, *, track, fps, rec, state, depth, transition=None):
448
- """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
+ """
449
625
  start = rec
450
626
  pending_transition = transition
451
627
  components = getattr(sequence, "components", None)
@@ -455,8 +631,13 @@ def _walk_components(sequence, *, track, fps, rec, state, depth, transition=None
455
631
  cls = type(comp).__name__
456
632
  try:
457
633
  if cls == "Transition":
458
- # A transition overlaps its neighbours; it does not advance rec itself.
459
- 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)
460
641
  continue
461
642
  rec += _walk_segment(
462
643
  comp, track=track, fps=fps, rec=rec, state=state, depth=depth, transition=pending_transition
@@ -518,6 +699,147 @@ def _media_kind_to_track(slot):
518
699
  return "A" if _slot_media_kind(slot).startswith("sound") else "V"
519
700
 
520
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
+
521
843
  def probe(path):
522
844
  import aaf2
523
845
 
@@ -542,6 +864,7 @@ def probe(path):
542
864
  name = None
543
865
  # `idx` is monotonic across the WHOLE mob, every slot and every nested layer.
544
866
  state = {"idx": 1, "events": [], "unhandled": {}}
867
+ edit_fps = None
545
868
  for slot in getattr(mob, "slots", []) or []:
546
869
  seg = getattr(slot, "segment", None)
547
870
  if seg is None:
@@ -550,18 +873,17 @@ def probe(path):
550
873
  # metadata/sound master) — see _NON_EDITORIAL_KINDS.
551
874
  if not _is_editorial_slot(slot):
552
875
  continue
553
- _walk_slot(
554
- seg,
555
- prefix=_media_kind_to_track(slot),
556
- fps=_fps_from_edit_rate(getattr(slot, "edit_rate", None)),
557
- state=state,
558
- )
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)
559
880
  events = state["events"]
560
881
  sequences.append(
561
882
  {
562
883
  "id": mob_id or (str(name) if name else f"seq{len(sequences) + 1}"),
563
884
  "name": str(name) if name else f"Sequence {len(sequences) + 1}",
564
885
  "eventCount": len(events),
886
+ **_sequence_start_timecode(mob, edit_fps),
565
887
  # Component classes we could not model, by name+count. Empty {} means
566
888
  # a structurally complete read; non-empty means events are INCOMPLETE.
567
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.75.0"
88
+ VERSION = "2.77.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.75.0"
14
+ VERSION = "2.77.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -2579,6 +2579,54 @@ def _find_clip_with_parent(folder, clip_id, _parent=None):
2579
2579
  return found_clip, found_parent
2580
2580
  return None, None
2581
2581
 
2582
+ def _find_folder_by_id(folder, folder_id):
2583
+ if folder.GetUniqueId() == folder_id:
2584
+ return folder
2585
+ for sub in (folder.GetSubFolderList() or []):
2586
+ found = _find_folder_by_id(sub, folder_id)
2587
+ if found:
2588
+ return found
2589
+ return None
2590
+
2591
+
2592
+ def _folder_from_params(mp, p, *path_keys, no_address="current"):
2593
+ """Resolve the folder an action was aimed at. Returns (folder, error).
2594
+
2595
+ Naming no folder falls back to the action's documented default (see
2596
+ `no_address`). What must never happen is the middle case: an addressing argument was
2597
+ supplied, did not resolve, and the action answered about the current bin
2598
+ anyway. That reports success for a different question than the caller asked,
2599
+ and it is indistinguishable from the tool working. It cost one session an
2600
+ afternoon: `folder_id` is not a key any action read, so it was dropped, the
2601
+ current bin's clips came back, and the tools were written off as broken.
2602
+
2603
+ So: unresolvable-but-supplied is an error, and the id that `get_subfolders`
2604
+ hands out is accepted as an address, since being given an id and having no
2605
+ way to use it is what invited the guess.
2606
+
2607
+ `no_address` preserves each action's historical no-argument default: "current"
2608
+ for the folder tool, "root" for the media_pool actions (whose old code hit
2609
+ `_navigate_folder(mp, "")`, which returns root). This fix must not also
2610
+ change what omitting the address means.
2611
+ """
2612
+ remediation = ("List folders with folder get_subfolders (walking down from "
2613
+ "path=\"Master\") and address by exact path or folder_id.")
2614
+ path = _first_param(p, *path_keys)
2615
+ if path:
2616
+ f = _navigate_folder(mp, path)
2617
+ return (f, None) if f else (None, _err(
2618
+ f"Folder not found: {path}", code="FOLDER_NOT_FOUND",
2619
+ category="invalid_input", remediation=remediation))
2620
+ folder_id = _first_param(p, "folder_id", "folderId")
2621
+ if folder_id:
2622
+ f = _find_folder_by_id(mp.GetRootFolder(), str(folder_id))
2623
+ return (f, None) if f else (None, _err(
2624
+ f"Folder not found: {folder_id}", code="FOLDER_NOT_FOUND",
2625
+ category="invalid_input", remediation=remediation))
2626
+ f = mp.GetRootFolder() if no_address == "root" else mp.GetCurrentFolder()
2627
+ return (f, None) if f else (None, _err("No current Media Pool folder"))
2628
+
2629
+
2582
2630
  def _navigate_folder(mp, path):
2583
2631
  root = mp.GetRootFolder()
2584
2632
  if not path or path in ("Master", "/", ""):
@@ -16666,7 +16714,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16666
16714
  return _err(f"Folder not found: {p.get('path')}")
16667
16715
  return {"success": bool(mp.SetCurrentFolder(f))}
16668
16716
  elif action == "add_subfolder":
16669
- parent = _navigate_folder(mp, p.get("parent_path", "")) or mp.GetCurrentFolder()
16717
+ parent, folder_err = _folder_from_params(mp, p, "parent_path", "parentPath", no_address="root")
16718
+ if folder_err:
16719
+ return folder_err
16670
16720
  f = mp.AddSubFolder(parent, p["name"])
16671
16721
  return _ok(name=f.GetName(), id=f.GetUniqueId()) if f else _err("Failed to create subfolder")
16672
16722
  elif action == "delete_folders":
@@ -16973,7 +17023,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16973
17023
  clip = _find_clip(root, p["clip_id"])
16974
17024
  return {"mattes": mp.GetClipMatteList(clip)} if clip else _err("Clip not found")
16975
17025
  elif action == "get_timeline_mattes":
16976
- folder = _navigate_folder(mp, p.get("folder_path", "")) or mp.GetCurrentFolder()
17026
+ folder, folder_err = _folder_from_params(mp, p, "folder_path", "folderPath", no_address="root")
17027
+ if folder_err:
17028
+ return folder_err
16977
17029
  result = mp.GetTimelineMatteList(folder)
16978
17030
  return {"mattes": len(result) if result else 0}
16979
17031
  elif action == "delete_clip_mattes":
@@ -17037,6 +17089,11 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
17037
17089
  def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
17038
17090
  """Operations on Media Pool folders.
17039
17091
 
17092
+ Address a folder with `path` ("Master/SubFolder") or with `folder_id` (the id
17093
+ get_subfolders returns). Omit both for the current folder. An address that is
17094
+ supplied but does not resolve is an error — it never falls back to the current
17095
+ folder.
17096
+
17040
17097
  Actions:
17041
17098
  get_clips(path?) -> {clips} — path like "Master/SubFolder", omit for current
17042
17099
  get_name(path?) -> {name}
@@ -17057,10 +17114,9 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
17057
17114
  if err:
17058
17115
  return err
17059
17116
 
17060
- folder_path = p.get("path", "")
17061
- f = _navigate_folder(mp, folder_path) if folder_path else mp.GetCurrentFolder()
17062
- if not f:
17063
- return _err(f"Folder not found: {folder_path}")
17117
+ f, folder_err = _folder_from_params(mp, p, "path", "folder_path", "folderPath")
17118
+ if folder_err:
17119
+ return folder_err
17064
17120
 
17065
17121
  if action == "get_clips":
17066
17122
  clips = f.GetClipList() or []
@@ -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",