davinci-resolve-mcp 2.90.0 → 2.91.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,62 @@
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.91.0
6
+
7
+ A timeline item's source frames are counted in the **media's** frame rate, not
8
+ the timeline's — and a WAV carries no native rate, so Resolve reports **24** for
9
+ it. Read back at the timeline rate a WAV offset lands minutes from the real
10
+ position in the file, and nothing errors: `source_end` is derived as
11
+ `source_start + timeline_duration`, so the start/end pair stays internally
12
+ consistent whatever rate the caller assumed. Reported and measured by
13
+ @rusanivsky in #144.
14
+
15
+ ### Added
16
+
17
+ - **`source_fps` beside the frames.** Every timeline-item summary now carries
18
+ the rate its source frames are counted in, plus `source_start_seconds` /
19
+ `source_end_seconds`, so the frame number always arrives with its unit
20
+ attached. The rate is read from the media-pool item's `FPS` property, never
21
+ assumed; an unreadable rate reports `null` so callers see *unknown* rather
22
+ than a guess.
23
+ - **The reader that produced the value is tracked**, because the two do not
24
+ agree on units: on an audio item `GetLeftOffset` counts in **timeline** frames
25
+ while `GetSourceStartFrame` counts in **source** frames — 60687 vs 75784 for
26
+ the same edit point. On that fallback the summary reports `source_fps: null`
27
+ rather than pairing a timeline-frame number with the media rate.
28
+ - **`create_variant_from_ranges`' per-range `track_index`** — 1-based within
29
+ `track_type`, missing tracks added — was accepted but undocumented, so
30
+ multicam angles collapsed onto V1 for anyone who did not read the source. Now
31
+ in the action help, the action list, the example, and `docs/SKILL.md`.
32
+ - The trap is in the `api_truth` ledger and in `resolve-rough-cut`'s verified
33
+ traps table.
34
+
35
+ ### Fixed
36
+
37
+ - **`source_end_seconds` was the same unit lie the field was added to stop.**
38
+ `source_end` is `source_start + duration`, and that duration comes from
39
+ `GetDuration` — a **timeline** duration. Converting the sum at the media rate
40
+ compounds the very mix-up being guarded. The seconds now come from
41
+ `GetSourceStartTime` / `GetSourceEndTime`, which answer in seconds with no
42
+ rate inference at all, then from `GetSourceEndFrame / source_fps`, and read
43
+ `null` rather than convert the derived value. `source_end` itself is
44
+ unchanged — no consumer moves — but it is now annotated as unit-mixed where
45
+ it is assigned, in the probe action help, and in the ledger.
46
+
47
+ ### Validation
48
+
49
+ - Suite: 2613 passed, 1 skipped. `gen_api_limitations.py --check` clean.
50
+ - **Live on Studio 19.1.3.7** with synthetic media, which also shows the trap is
51
+ not a 21.x regression. A 300 s 48 kHz WAV reports `FPS 24`; appending its
52
+ source frames 4800–5235 to a 29.97 fps timeline yields a timeline duration of
53
+ **543** (= 435 × 29.97/24), so `source_end` came back **5343** where the true
54
+ source end is 5235 — `source_end / 24` reports **222.625 s** against a real
55
+ **218.133 s**, 4.49 s out on a clip 18.1 s long. The patched code reports
56
+ 218.133 s. The matching 29.97 video item was unaffected either way (24.524 s
57
+ read vs 24.525 s derived). Both second-readers exist on 19.1.3.7.
58
+ - Not tested here: the 21.0.3.7 measurements in the ledger entry, which are
59
+ @rusanivsky's and are labelled as such.
60
+
5
61
  ## What's New in v2.90.0
6
62
 
7
63
  AAF turnovers parsed by `editorial.parse_interchange` on the advanced server now
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.90.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.91.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.90.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.91.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.90.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.91.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1381,7 +1381,9 @@ Key actions:
1381
1381
  into an editor-facing beat report
1382
1382
  - `create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?)` — create
1383
1383
  a guarded timeline variant from declarative source ranges, optional markers,
1384
- transforms, and CDL
1384
+ transforms, and CDL. Each range takes `track_type?` and a 1-based
1385
+ `track_index?` (default 1), so multicam angles can be rebuilt onto V2/V3
1386
+ rather than collapsing onto V1; missing tracks are added
1385
1387
  - `bulk_set_item_properties(ops, dry_run?, readback?)` — apply transforms,
1386
1388
  crop/composite/audio/property groups to many timeline items in one call
1387
1389
  - `apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?)` — apply a
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 27 missing capabilities, 32 bugs / unreliable behaviors.
15
+ **Totals:** 27 missing capabilities, 33 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
@@ -382,6 +382,14 @@ values, or automation-hostile modal prompts.
382
382
  - **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. Scope: placement at 100% speed. On a retimed clip the two read DIFFERENT domains — GetLeftOffset is warped (position / speed), GetSourceStartFrame is true source — see the retime entry's witness calibration before comparing them.
383
383
  - **Tags:** off-by-one, unreliable-return, timeline, conform, verify
384
384
 
385
+ ### TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; 24 fps for WAV)
386
+
387
+ - **Object:** `TimelineItem`
388
+ - **Signature:** `() -> int # source frame, counted in the MEDIA's frame rate`
389
+ - **Behavior:** The value is counted in the source MEDIA's own frame rate, not the timeline's — and a WAV carries no frame rate, so Resolve falls back to 24 fps. Reading it at the timeline rate lands minutes away from the real position in the file. Verified live on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a ZOOM0028.WAV item reported source_start 56871, which is 56871 / 24 = 2369.6 s into the file, NOT the 1897.6 s a 29.97 fps reading gives — a 471.9 s (7 min 52 s) error. Nothing looks wrong, because timeline probe_timeline_structure derives source_end as source_start + timeline_duration: the start/end pair stays internally consistent whatever rate you assume. VIDEO items are NOT affected — two 29.97 fps items (KR020007.MOV, IMG_0001.mov) on the same timeline reported source frames in their own, matching rate, confirmed against ffprobe durations and span arithmetic. This is the read-side twin of the AppendToTimeline mixed-fps entry below: that one is about writing source frames whose rate differs from the timeline's, this one about reading them back and not knowing which rate they are in. The rate was pinned by regression, not assumed: across 12 items of the same WAV, GetSourceStartFrame advances at 24.000 fps against the item's own GetSourceStartTime (24.0000/24.0007/23.9995 over spans up to 22 minutes). The same measurement exposed a second unit trap: on an AUDIO item GetLeftOffset advances at 29.970 — the TIMELINE rate — so the two readers describe the same edit point in DIFFERENT frame spaces (60687 vs 75784 for one item). On video they share the source space. Caveat on the absolute zero: Resolve's model of this file is 133003 frames (Duration 01:32:21:19 at 24 fps = 5541.79 s) while its true PCM length is 266264768 samples / 48 kHz = 5547.18 s, a 0.097% difference we have not explained — so frames/24 is exact in Resolve's source-time space, which is the space every other Resolve call uses, but may sit ~2 s off the byte position in a 40-minute-deep offset. Re-confirmed on Studio 19.1.3.7 (2026-08-10) with synthetic media, so this is not a 21.x regression: a 300 s 48 kHz WAV reports FPS 24, and appending source frames 4800-5235 of it to a 29.97 fps timeline yields a timeline duration of 543 (= 435 x 29.97/24), which is the conversion happening in the open. The same run measured the cost of the derived end: source_end came back 5343 (4800 + 543) where the true source end is 5235, so source_end / 24 reports 222.625 s against a real 218.133 s from GetSourceEndTime — 4.49 s out, on a clip only 18.1 s long. GetSourceStartTime read exactly 200.0 s (= 4800/24) on the same item. The matching VIDEO item (29.97 source in a 29.97 timeline) was unaffected in both: 24.524 s read against 24.525 s derived. Both second-readers exist on 19.1.3.7, so the GetSourceEndFrame fallback below is for builds older still.
390
+ - **Workaround / current handling:** Convert an audio item's source frames with the MEDIA's rate, never the timeline's: seconds = source_start / media_fps, treating a WAV (or any container with no native rate) as 24 fps. Take media_fps from the media-pool item's 'FPS' clip property or from ffprobe — do not infer it from the timeline. Feed the frames back to timeline create_variant_from_ranges in the same media-rate space you read them in; it converts on placement and reports the conversion in items[].duration_delta. The separate GetSourceStartFrame entry above (off-by-one vs GetLeftOffset) applies on top of this — the rate question is which unit the number is in, not whether it is exact. Mitigated in-process: _timeline_item_summary now emits source_fps and source_start_seconds/source_end_seconds beside the frames, so the number always arrives with its unit; on the GetLeftOffset fallback for an audio item it reports the rate as unknown rather than converting a timeline-frame value at the media rate. Note which number may be converted: source_end in the same summary is derived as source_start + TIMELINE duration, so on this WAV it adds 435 frames of 29.97 record time to a 24 fps source frame and dividing THAT by 24 reports an 18.125 s span for a 14.515 s clip. The seconds therefore come from GetSourceStartTime/GetSourceEndTime — which answer in seconds with no rate inference at all — then from GetSourceEndFrame / media_fps, and read null rather than convert the derived source_end.
391
+ - **Tags:** timeline, audio, wav, frame-rate, mixed-fps, silent-failure, readback
392
+
385
393
  ### Studio-gated calls on the free edition raise a modal that blocks LATER calls
386
394
 
387
395
  - **Object:** `Resolve (all 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.90.0"
39
+ VERSION = "2.91.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.90.0",
3
+ "version": "2.91.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.90.0"
90
+ VERSION = "2.91.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.90.0"
14
+ VERSION = "2.91.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -2992,18 +2992,31 @@ def _safe_media_pool_item_name(mpi):
2992
2992
  return None
2993
2993
 
2994
2994
 
2995
- def _timeline_item_source_start(item):
2995
+ def _timeline_item_source_start_with_origin(item):
2996
+ """(source_start, origin) — which reader the frame number came from.
2997
+
2998
+ The origin matters because the two readers do not agree on units. On an
2999
+ AUDIO item, measured on Studio 21.0.3.7 across 12 items of one WAV,
3000
+ GetSourceStartFrame advances at exactly 24.000 fps against the item's own
3001
+ GetSourceStartTime (the media's rate) while GetLeftOffset advances at 29.970
3002
+ — the TIMELINE rate. Same edit point, different frame spaces. Callers that
3003
+ attach a rate to the number must know which reader produced it.
3004
+ """
2996
3005
  if _has_method(item, "GetSourceStartFrame"):
2997
3006
  try:
2998
3007
  source_start = _frame_int(item.GetSourceStartFrame())
2999
3008
  if source_start is not None:
3000
- return source_start
3009
+ return source_start, "GetSourceStartFrame"
3001
3010
  except Exception:
3002
3011
  pass
3003
3012
  try:
3004
- return _frame_int(item.GetLeftOffset())
3013
+ return _frame_int(item.GetLeftOffset()), "GetLeftOffset"
3005
3014
  except Exception:
3006
- return None
3015
+ return None, None
3016
+
3017
+
3018
+ def _timeline_item_source_start(item):
3019
+ return _timeline_item_source_start_with_origin(item)[0]
3007
3020
 
3008
3021
 
3009
3022
  def _timeline_item_media_pool_item(item):
@@ -3039,7 +3052,103 @@ def _timeline_item_track_info(item):
3039
3052
  return None, _err("invalid source track index")
3040
3053
 
3041
3054
 
3042
- def _timeline_item_summary(item, track_info=None):
3055
+ def _media_item_source_fps(media_pool_item, clip_properties=None):
3056
+ """The frame rate a media-pool item's SOURCE frames are counted in.
3057
+
3058
+ Source frames (GetSourceStartFrame / GetLeftOffset) are expressed in the
3059
+ MEDIA's own rate, never the timeline's — and a WAV carries no native rate,
3060
+ so Resolve reports 24 for it. Read the rate instead of assuming the
3061
+ timeline's: at 29.97 a WAV offset lands minutes away from the real position
3062
+ in the file, and nothing errors (see the api_truth entry "GetSourceStartFrame
3063
+ on an AUDIO item"). Returns None when the rate cannot be read, so callers
3064
+ surface "unknown" rather than a guess.
3065
+
3066
+ Pass ``clip_properties`` when the caller already holds the item's property
3067
+ dict — the probe path does, so this costs it no extra bridge call.
3068
+ """
3069
+ value = None
3070
+ if isinstance(clip_properties, dict):
3071
+ value = clip_properties.get("FPS")
3072
+ if value in (None, "") and media_pool_item is not None:
3073
+ try:
3074
+ value = media_pool_item.GetClipProperty("FPS")
3075
+ except Exception:
3076
+ value = None
3077
+ if isinstance(value, dict): # GetClipProperty("") returns the whole map
3078
+ value = value.get("FPS")
3079
+ try:
3080
+ fps = float(value)
3081
+ except (TypeError, ValueError):
3082
+ return None
3083
+ return fps if fps > 0 else None
3084
+
3085
+
3086
+ def _source_frames_to_seconds(frames, fps):
3087
+ """Source frames -> seconds into the file, or None when either is unknown."""
3088
+ if frames is None or not fps:
3089
+ return None
3090
+ return round(frames / fps, 3)
3091
+
3092
+
3093
+ def _timeline_item_source_time(item, method):
3094
+ """Resolve's own source-time reader (GetSourceStartTime/GetSourceEndTime).
3095
+
3096
+ Seconds into the source file, read directly — no rate inference, so it is
3097
+ the authoritative answer whenever the build exposes it. None when the
3098
+ method is absent or unreadable, leaving the caller to fall back to
3099
+ frames / source_fps.
3100
+ """
3101
+ if not _has_method(item, method):
3102
+ return None
3103
+ try:
3104
+ value = getattr(item, method)()
3105
+ except Exception:
3106
+ return None
3107
+ try:
3108
+ seconds = float(value)
3109
+ except (TypeError, ValueError):
3110
+ return None
3111
+ return round(seconds, 3)
3112
+
3113
+
3114
+ def _timeline_item_source_end_frame(item):
3115
+ """The item's SOURCE-space end frame, from Resolve's own reader.
3116
+
3117
+ Distinct from the ``source_end`` this module derives as
3118
+ ``source_start + duration``: that duration is a TIMELINE duration
3119
+ (GetDuration), so the sum mixes units the moment the media rate differs
3120
+ from the timeline's — always, for a WAV. Only a genuine source-space frame
3121
+ may be divided by source_fps.
3122
+ """
3123
+ if not _has_method(item, "GetSourceEndFrame"):
3124
+ return None
3125
+ try:
3126
+ return _frame_int(item.GetSourceEndFrame())
3127
+ except Exception:
3128
+ return None
3129
+
3130
+
3131
+ def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3132
+ """(start_seconds, end_seconds) into the source file, or None each.
3133
+
3134
+ Prefers Resolve's second-readers, then a source-space frame divided by the
3135
+ media rate. The derived ``source_end`` is deliberately NOT a fallback: it
3136
+ is ``source_start + timeline_duration``, so on a 24 fps WAV in a 29.97 fps
3137
+ timeline it overstates the clip's span by 25% (18.1 s reported for a
3138
+ 14.5 s clip). An unknown end reads as unknown.
3139
+ """
3140
+ start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
3141
+ if start_seconds is None:
3142
+ start_seconds = _source_frames_to_seconds(source_start, source_fps)
3143
+ end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3144
+ if end_seconds is None:
3145
+ end_seconds = _source_frames_to_seconds(
3146
+ _timeline_item_source_end_frame(item), source_fps)
3147
+ return start_seconds, end_seconds
3148
+
3149
+
3150
+ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3151
+ clip_properties=None):
3043
3152
  if not item:
3044
3153
  return None
3045
3154
  start = end = duration = source_start = source_end = None
@@ -3049,12 +3158,23 @@ def _timeline_item_summary(item, track_info=None):
3049
3158
  except Exception:
3050
3159
  pass
3051
3160
  duration = _timeline_item_duration(item, start, end)
3052
- source_start = _timeline_item_source_start(item)
3161
+ source_start, source_start_origin = _timeline_item_source_start_with_origin(item)
3053
3162
  if source_start is not None and duration is not None:
3054
3163
  source_end = source_start + duration
3055
3164
  if track_info is None:
3056
3165
  track_info, _ = _timeline_item_track_info(item)
3057
- media_pool_item = _timeline_item_media_pool_item(item)
3166
+ if media_pool_item is None:
3167
+ media_pool_item = _timeline_item_media_pool_item(item)
3168
+ # source_* are in the MEDIA's frame rate; report it and the derived seconds
3169
+ # so a caller never has to guess which rate the frame numbers are in.
3170
+ source_fps = _media_item_source_fps(media_pool_item, clip_properties)
3171
+ if source_start_origin == "GetLeftOffset" and (track_info or (None,))[0] == "audio":
3172
+ # GetLeftOffset counts an audio item in TIMELINE frames, so pairing it
3173
+ # with the media rate would produce a confidently wrong number. Report
3174
+ # the frame and leave the rate unknown rather than convert it wrong.
3175
+ source_fps = None
3176
+ source_start_seconds, source_end_seconds = _timeline_item_source_seconds(
3177
+ item, source_start, source_end, source_fps)
3058
3178
  summary = {
3059
3179
  "timeline_item_id": _safe_timeline_item_id(item),
3060
3180
  "name": _safe_timeline_item_name(item),
@@ -3064,7 +3184,13 @@ def _timeline_item_summary(item, track_info=None):
3064
3184
  "end": end,
3065
3185
  "duration": duration,
3066
3186
  "source_start": source_start,
3187
+ # NOTE: derived as source_start + TIMELINE duration, so it is unit-mixed
3188
+ # whenever source_fps != the timeline rate. Kept for compatibility; use
3189
+ # source_end_seconds, which is read from source space, for real time.
3067
3190
  "source_end": source_end,
3191
+ "source_fps": source_fps,
3192
+ "source_start_seconds": source_start_seconds,
3193
+ "source_end_seconds": source_end_seconds,
3068
3194
  "media_pool_item_id": _safe_media_pool_item_id(media_pool_item),
3069
3195
  "media_pool_item_name": _safe_media_pool_item_name(media_pool_item),
3070
3196
  }
@@ -5008,8 +5134,9 @@ def _conform_capabilities():
5008
5134
 
5009
5135
 
5010
5136
  def _timeline_item_conform_summary(item, track_type: str, track_index: int, item_index: int):
5011
- summary = _timeline_item_summary(item, (track_type, track_index)) or {}
5012
- summary["item_index"] = item_index
5137
+ # Fetch the media-pool item and its properties FIRST, then hand both to the
5138
+ # summary: it needs the 'FPS' property for source_fps, and this way the probe
5139
+ # pays for one GetMediaPoolItem/GetClipProperty pair per item, not two.
5013
5140
  media_pool_item = _timeline_item_media_pool_item(item)
5014
5141
  file_path = None
5015
5142
  clip_properties = None
@@ -5019,12 +5146,17 @@ def _timeline_item_conform_summary(item, track_type: str, track_index: int, item
5019
5146
  clip_properties = _ser(media_pool_item.GetClipProperty(""))
5020
5147
  except Exception:
5021
5148
  clip_properties = None
5022
- if isinstance(clip_properties, dict):
5023
- file_path = clip_properties.get("File Path") or clip_properties.get("FilePath")
5024
- for key in ("Status", "Media Status", "Offline", "Online Status"):
5025
- if key in clip_properties:
5026
- media_status = clip_properties.get(key)
5027
- break
5149
+ summary = _timeline_item_summary(
5150
+ item, (track_type, track_index),
5151
+ media_pool_item=media_pool_item, clip_properties=clip_properties,
5152
+ ) or {}
5153
+ summary["item_index"] = item_index
5154
+ if isinstance(clip_properties, dict):
5155
+ file_path = clip_properties.get("File Path") or clip_properties.get("FilePath")
5156
+ for key in ("Status", "Media Status", "Offline", "Online Status"):
5157
+ if key in clip_properties:
5158
+ media_status = clip_properties.get(key)
5159
+ break
5028
5160
  summary["file_path"] = file_path
5029
5161
  summary["file_exists"] = bool(file_path and os.path.exists(str(file_path)))
5030
5162
  summary["media_status"] = media_status
@@ -5409,7 +5541,8 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
5409
5541
  def _variant_item_placement(item) -> Dict[str, Any]:
5410
5542
  """Report an appended item's placed frame positions in both frame spaces.
5411
5543
  record_* are TIMELINE frames (GetStart/GetEnd/GetDuration); source_start is
5412
- a SOURCE frame."""
5544
+ a SOURCE frame, counted in source_fps — the MEDIA's rate, which for a WAV is
5545
+ 24 and not the timeline's."""
5413
5546
  def _read(method):
5414
5547
  fn = getattr(item, method, None)
5415
5548
  if not callable(fn):
@@ -5423,11 +5556,16 @@ def _variant_item_placement(item) -> Dict[str, Any]:
5423
5556
  duration = _read("GetDuration")
5424
5557
  if duration is None and record_start is not None and record_end is not None:
5425
5558
  duration = record_end - record_start
5559
+ source_start = _timeline_item_source_start(item)
5560
+ source_fps = _media_item_source_fps(_timeline_item_media_pool_item(item))
5561
+ source_start_seconds, _ = _timeline_item_source_seconds(item, source_start, None, source_fps)
5426
5562
  return {
5427
5563
  "record_start": record_start,
5428
5564
  "record_end": record_end,
5429
5565
  "duration": duration,
5430
- "source_start": _timeline_item_source_start(item),
5566
+ "source_start": source_start,
5567
+ "source_fps": source_fps,
5568
+ "source_start_seconds": source_start_seconds,
5431
5569
  }
5432
5570
 
5433
5571
 
@@ -21393,7 +21531,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
21393
21531
  Frame numbers are TIMELINE/record frames (position on the timeline) unless an action
21394
21532
  says SOURCE. Source frames are positions within a media-pool clip's own media:
21395
21533
  create_variant_from_ranges takes SOURCE start_frame/end_frame; extract_source_frame_ranges
21396
- and source_range_report return SOURCE ranges.
21534
+ and source_range_report return SOURCE ranges. A SOURCE frame is counted in the MEDIA's own
21535
+ frame rate, not the timeline's: an AUDIO item's source_start/source_end read back in the
21536
+ file's rate, and a WAV (no native rate) defaults to 24 fps, so converting one at the timeline
21537
+ rate is silently wrong by minutes (resolve_control api_truth "GetSourceStartFrame").
21397
21538
 
21398
21539
  Actions:
21399
21540
  list() -> {timelines}
@@ -21449,6 +21590,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
21449
21590
  nothing moves. (frames here are TIMELINE/record frames.)
21450
21591
  story_spine_report() -> {beats, track_summaries, source_ranges, audio_spine}
21451
21592
  create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?) -> {success, id, items}
21593
+ ranges[] take track_type? (video|audio) and track_index? (1-based, within the
21594
+ track_type, default 1); missing tracks are added, so V2/V3 multicam angles survive.
21452
21595
  # example: action_help(name='<action_name>')
21453
21596
  bulk_set_item_properties(ops, dry_run?, readback?) -> {results, op_count}
21454
21597
  # example: action_help(name='<action_name>')
@@ -21511,6 +21654,14 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
21511
21654
  Default handles=24, gap_max=30. Use handles=0 for gap-only auto handles.
21512
21655
  conform_capabilities() -> {supported, partially_supported, unsupported, export_aliases}
21513
21656
  probe_timeline_structure(track_types?, include_markers?, include_clip_properties?) -> {tracks, markers}
21657
+ Each item reports source_start in the MEDIA's frame rate, the source_fps it is
21658
+ counted in, and source_start_seconds/source_end_seconds. Use those seconds — a
21659
+ WAV counts at 24 fps on any timeline, so dividing by the timeline rate is wrong
21660
+ by minutes. source_fps is null when the rate could not be read; treat the frames
21661
+ as unitless then, do not assume the timeline's. source_end is derived as
21662
+ source_start + TIMELINE duration, so it is unit-mixed when the rates differ —
21663
+ the seconds come from Resolve's own source-time readers instead, and read null
21664
+ rather than convert the derived value.
21514
21665
  detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
21515
21666
  source_range_report(handles?, merge?) -> {ranges, occurrences}
21516
21667
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
@@ -23334,9 +23485,16 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
23334
23485
  "summary": "Build a variant timeline from N source ranges. Video-only unless ranges include track_type='audio'. Source-safe; dry_run validates clip ids and frame ranges.",
23335
23486
  "params": (
23336
23487
  "name, ranges: [{clip_id|media_pool_item_id, start_frame, end_frame, "
23337
- "record_frame?, track_type?}], pack?, markers?, cdl?, dry_run? — clip_id is a "
23488
+ "record_frame?, track_type?, track_index?}], pack?, markers?, cdl?, dry_run? — clip_id is a "
23338
23489
  "media-pool item id (not a timeline-item id); start_frame/end_frame are SOURCE "
23339
23490
  "frames, end_frame exclusive (source duration = end_frame - start_frame). "
23491
+ "track_index is the 1-based destination track WITHIN track_type (default 1); the "
23492
+ "variant is created with enough video/audio tracks to cover the highest index used, "
23493
+ "so multicam angles can be rebuilt onto V2/V3 instead of collapsing onto V1. "
23494
+ "SOURCE frames are counted in the MEDIA's frame rate, not the timeline's — an audio "
23495
+ "item's read back as 24 fps for a WAV (api_truth \"GetSourceStartFrame on an AUDIO "
23496
+ "item\"); pass them in that space, placement converts and items[].duration_delta "
23497
+ "reports the conversion. "
23340
23498
  "pack=true butts clips together at the end of each track (gap-free, ignores record_frame)"
23341
23499
  ),
23342
23500
  "returns": "{success, id, items} — items[].placed = placed frames; items[].range = the requested range",
@@ -23344,8 +23502,11 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
23344
23502
  'timeline(action="create_variant_from_ranges", params={\n'
23345
23503
  ' "name": "v02_tighter_act1",\n'
23346
23504
  ' "ranges": [\n'
23347
- ' {"clip_id": "<media-pool-item-id>", "start_frame": 1200, "end_frame": 1320},\n'
23348
- ' {"clip_id": "<media-pool-item-id>", "start_frame": 1500, "end_frame": 1600}\n'
23505
+ ' {"clip_id": "<cam1-id>", "start_frame": 1200, "end_frame": 1320},\n'
23506
+ ' {"clip_id": "<cam3-id>", "start_frame": 1500, "end_frame": 1600,\n'
23507
+ ' "track_index": 2},\n'
23508
+ ' {"clip_id": "<wav-id>", "track_type": "audio", "track_index": 1,\n'
23509
+ ' "start_frame": 56871, "end_frame": 57591} # 24 fps source frames\n'
23349
23510
  ' ],\n'
23350
23511
  ' "dry_run": True\n'
23351
23512
  '})'
@@ -660,6 +660,92 @@ API_TRUTH: List[Dict[str, Any]] = [
660
660
  "tags": ["off-by-one", "unreliable-return", "timeline", "conform", "verify"],
661
661
  "submit": "bug",
662
662
  },
663
+ {
664
+ "symbol": "TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; 24 fps for WAV)",
665
+ "object": "TimelineItem",
666
+ "signature": "() -> int # source frame, counted in the MEDIA's frame rate",
667
+ "reality": "The value is counted in the source MEDIA's own frame rate, not "
668
+ "the timeline's — and a WAV carries no frame rate, so Resolve "
669
+ "falls back to 24 fps. Reading it at the timeline rate lands "
670
+ "minutes away from the real position in the file. Verified live "
671
+ "on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a "
672
+ "ZOOM0028.WAV item reported source_start 56871, which is "
673
+ "56871 / 24 = 2369.6 s into the file, NOT the 1897.6 s a 29.97 "
674
+ "fps reading gives — a 471.9 s (7 min 52 s) error. Nothing looks "
675
+ "wrong, because timeline probe_timeline_structure derives "
676
+ "source_end as source_start + timeline_duration: the start/end "
677
+ "pair stays internally consistent whatever rate you assume. "
678
+ "VIDEO items are NOT affected — two 29.97 fps items "
679
+ "(KR020007.MOV, IMG_0001.mov) on the same timeline reported "
680
+ "source frames in their own, matching rate, confirmed against "
681
+ "ffprobe durations and span arithmetic. This is the read-side "
682
+ "twin of the AppendToTimeline mixed-fps entry below: that one is "
683
+ "about writing source frames whose rate differs from the "
684
+ "timeline's, this one about reading them back and not knowing "
685
+ "which rate they are in. The rate was pinned by regression, "
686
+ "not assumed: across 12 items of the same WAV, "
687
+ "GetSourceStartFrame advances at 24.000 fps against the item's "
688
+ "own GetSourceStartTime (24.0000/24.0007/23.9995 over spans up "
689
+ "to 22 minutes). The same measurement exposed a second unit "
690
+ "trap: on an AUDIO item GetLeftOffset advances at 29.970 — the "
691
+ "TIMELINE rate — so the two readers describe the same edit "
692
+ "point in DIFFERENT frame spaces (60687 vs 75784 for one "
693
+ "item). On video they share the source space. Caveat on the "
694
+ "absolute zero: Resolve's model of this file is 133003 frames "
695
+ "(Duration 01:32:21:19 at 24 fps = 5541.79 s) while its true "
696
+ "PCM length is 266264768 samples / 48 kHz = 5547.18 s, a 0.097% "
697
+ "difference we have not explained — so frames/24 is exact in "
698
+ "Resolve's source-time space, which is the space every other "
699
+ "Resolve call uses, but may sit ~2 s off the byte position in "
700
+ "a 40-minute-deep offset. Re-confirmed on Studio 19.1.3.7 "
701
+ "(2026-08-10) with synthetic media, so this is not a 21.x "
702
+ "regression: a 300 s 48 kHz WAV reports FPS 24, and appending "
703
+ "source frames 4800-5235 of it to a 29.97 fps timeline yields "
704
+ "a timeline duration of 543 (= 435 x 29.97/24), which is the "
705
+ "conversion happening in the open. The same run measured the "
706
+ "cost of the derived end: source_end came back 5343 "
707
+ "(4800 + 543) where the true source end is 5235, so "
708
+ "source_end / 24 reports 222.625 s against a real 218.133 s "
709
+ "from GetSourceEndTime — 4.49 s out, on a clip only 18.1 s "
710
+ "long. GetSourceStartTime read exactly 200.0 s (= 4800/24) on "
711
+ "the same item. The matching VIDEO item (29.97 source in a "
712
+ "29.97 timeline) was unaffected in both: 24.524 s read against "
713
+ "24.525 s derived. Both second-readers exist on 19.1.3.7, so "
714
+ "the GetSourceEndFrame fallback below is for builds older "
715
+ "still.",
716
+ "recommended": "Convert an audio item's source frames with the MEDIA's rate, "
717
+ "never the timeline's: seconds = source_start / media_fps, "
718
+ "treating a WAV (or any container with no native rate) as 24 "
719
+ "fps. Take media_fps from the media-pool item's 'FPS' clip "
720
+ "property or from ffprobe — do not infer it from the "
721
+ "timeline. Feed the frames back to timeline "
722
+ "create_variant_from_ranges in the same media-rate space you "
723
+ "read them in; it converts on placement and reports the "
724
+ "conversion in items[].duration_delta. The separate "
725
+ "GetSourceStartFrame entry above (off-by-one vs "
726
+ "GetLeftOffset) applies on top of this — the rate question "
727
+ "is which unit the number is in, not whether it is exact. "
728
+ "Mitigated in-process: _timeline_item_summary now emits "
729
+ "source_fps and source_start_seconds/source_end_seconds "
730
+ "beside the frames, so the number always arrives with its "
731
+ "unit; on the GetLeftOffset fallback for an audio item it "
732
+ "reports the rate as unknown rather than converting a "
733
+ "timeline-frame value at the media rate. Note which "
734
+ "number may be converted: source_end in the same summary "
735
+ "is derived as source_start + TIMELINE duration, so on "
736
+ "this WAV it adds 435 frames of 29.97 record time to a 24 "
737
+ "fps source frame and dividing THAT by 24 reports an "
738
+ "18.125 s span for a 14.515 s clip. The seconds therefore "
739
+ "come from GetSourceStartTime/GetSourceEndTime — which "
740
+ "answer in seconds with no rate inference at all — then "
741
+ "from GetSourceEndFrame / media_fps, and read null rather "
742
+ "than convert the derived source_end.",
743
+ "tags": ["timeline", "audio", "wav", "frame-rate", "mixed-fps",
744
+ "silent-failure", "readback"],
745
+ "submit": "bug",
746
+ "mitigation": ["_media_item_source_fps", "_source_frames_to_seconds",
747
+ "_timeline_item_source_seconds"],
748
+ },
663
749
  {
664
750
  "symbol": "Razor / blade / split a timeline item",
665
751
  "object": "Timeline / TimelineItem",