davinci-resolve-mcp 2.97.2 → 2.97.3

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,73 @@
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.97.3
6
+
7
+ **`source_end` no longer has the start timecode baked into it.** Reported by
8
+ [@TheUnlockr](https://github.com/TheUnlockr) in
9
+ [#147](https://github.com/samuelgursky/davinci-resolve-mcp/issues/147) against
10
+ Studio 21.0.4.5, and confirmed live here on 19.1.3.7.
11
+
12
+ ### Fixed
13
+
14
+ - **The two source frame fields did not share an origin.** `source_start` comes
15
+ from `GetSourceStartFrame`, which is file-relative. `source_end` came from
16
+ `round(GetSourceEndTime x fps)` — and both second-readers answer in the media's
17
+ TIMECODE space, so on any clip with a non-zero start TC the whole start
18
+ timecode landed in `source_end`. On the reporter's Canon MP4 starting at
19
+ 04:18:37;25 that produced `source_end` 468800 on a clip 10650 frames long. The
20
+ two fields could not be differenced, and anything sizing a pull from them —
21
+ `extract_source_frame_ranges`, `create_variant_from_ranges`, conform and
22
+ consolidation — was working from a number ~44x the clip's length.
23
+ - `source_end` is now the SPAN between the two second-readers, anchored on
24
+ `source_start`. The offset cancels in the difference, so the result is
25
+ file-relative under either convention and needs no timecode parsing, no `Start
26
+ TC` property read, and no drop-frame arithmetic. On media starting at
27
+ 00:00:00:00 the correction is exactly zero, so nothing moves there.
28
+ - **The `_seconds` pair is rebased to match.** `source_start_seconds` used to
29
+ disagree with `source_start / source_fps` on any camera clip, despite being
30
+ documented as seconds into the source file. All four `source_*` fields are now
31
+ file-relative and say so.
32
+
33
+ ### Why the existing suite could not catch it
34
+
35
+ The v2.93.0 work that introduced this was live-validated — but with synthetic
36
+ media, which starts at 00:00:00:00. That makes the offset exactly zero, so a
37
+ timecode-absolute reader is indistinguishable from a file-relative one in
38
+ precisely that setup. The new harness closes the gap by generating synthetic
39
+ media that *carries* a timecode.
40
+
41
+ ### Added
42
+
43
+ - `tests/live_source_timecode_validation.py` — a controlled pair: two clips from
44
+ the same generator, identical except that one carries `-timecode 04:18:37;25`,
45
+ both cut into a timeline at a different rate. Any difference in the reported
46
+ fields is attributable to the timecode and nothing else. 17/17 checks pass on
47
+ Studio 19.1.3.7.
48
+ - Six unit tests in `tests/test_source_frame_rate.py` pinning the reporter's
49
+ measured numbers, including that zero-TC media is untouched.
50
+
51
+ ### Documentation
52
+
53
+ - The `GetSourceStartFrame` api_truth entry claimed the v2.93.0 product was "a
54
+ SOURCE frame by construction". That holds only for media starting at
55
+ 00:00:00:00; the entry now says so, records the live confirmation, and carries
56
+ the rate-conversion caveat below.
57
+
58
+ ### Validation
59
+
60
+ - Live on **DaVinci Resolve Studio 19.1.3.7** (not the 21.0.4.5 of the report):
61
+ on the timecoded copy `GetSourceStartTime` read 15527.812 s where the
62
+ file-relative answer is 10.010 s while `GetSourceStartFrame` read 300 — the two
63
+ readers in different spaces on the same edit point. The pre-fix formula gave
64
+ 465461 on a 1799-frame clip. After the fix the timecoded copy reports exactly
65
+ what the zero-TC control reports, for every range tried.
66
+ - One caveat the harness deliberately does *not* assert: `source_end` is not the
67
+ `endFrame` you sent when the rates differ. The record duration quantizes to
68
+ whole timeline frames, so a 93-source-frame request at 29.97 into a 24 fps
69
+ timeline consumes ~92.4 and *both* copies report 392. That is rate conversion,
70
+ not a timecode error.
71
+
5
72
  ## What's New in v2.97.2
6
73
 
7
74
  **The panel's inventory snapshot survives a large project.** Follow-up to the
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.97.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.3-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-35%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.97.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.3-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-35%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.97.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.97.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -402,8 +402,8 @@ values, or automation-hostile modal prompts.
402
402
 
403
403
  - **Object:** `TimelineItem`
404
404
  - **Signature:** `() -> int # source frame, counted in the MEDIA's frame rate`
405
- - **Behavior:** The value is counted in the source MEDIA's own frame rate, not the timeline's. CORRECTION (2026-08-10, Studio 19.1.3.7): an earlier version of this entry said a WAV 'carries no frame rate, so Resolve falls back to 24 fps'. That is WRONG, and 24 is not a constant to rely on. A WAV takes the PROJECT's timelineFrameRate AT IMPORT and freezes it. Measured with one 400.000 s 48 kHz WAV imported into three project states: project at 24 -> clip FPS 24.0, Duration 00:06:40:00 (9600 frames = 400 s); project at 29.97 -> clip FPS 29.97, Duration 00:06:39:18 (11988 frames = 400 s); and changing the project rate to 29.97 AFTER import left the clip reading 24.0 (SetSetting returned True and the project did move). So the mismatch is not 'audio is always 24' but 'the clip kept the rate the project had when it was imported, and the project moved afterwards' — which also means a WAV imported into a 29.97 project behaves exactly like video, with no trap at all. ALWAYS read the clip's FPS property; never assume 24. The original 21.0.3.7 report below is consistent with this: that project was at 24 when the WAV was imported. Reading the frames 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. GetSourceEndFrame ITSELF changes convention between the two regimes and cannot be used raw: measured over 12 items on 19.1.3.7, it is EXCLUSIVE (equals the endFrame sent) when the source rate equals the timeline rate, and INCLUSIVE (one less) when they differ — off by one in exactly the case a caller reaches for it. It is not a media-type split: the same WAV imported at 29.97 into a 29.97 timeline read exclusive, like video, and only the rate MISMATCH flipped it. What IS stable across both regimes is GetSourceEndTime x media_fps, which was exact on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; 24.524 s x 29.97 = 734.98 -> 735) — seconds carry no frame-rate assumption, so the product is in source space by construction.
406
- - **Workaround / current handling:** Convert an audio item's source frames with the MEDIA's rate, never the timeline's: seconds = source_start / media_fps. READ media_fps from the media-pool item's 'FPS' clip property (or ffprobe) every time — do not infer it from the timeline, and do NOT hard-code 24 for a WAV: that number is whatever the project rate was when the clip was imported, so it is 24 only for a project that was at 24, and a WAV imported at 29.97 has no mismatch at all. 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. source_end is no longer source_start + TIMELINE duration: as of v2.93.0 it is round(GetSourceEndTime x media_fps), which is a SOURCE frame by construction and stays EXCLUSIVE as every caller already assumed. Measured live on 19.1.3.7 over 8 items in both regimes, it equals the endFrame actually sent every time, and it reproduces the old value exactly wherever the old value was already right — so matched-rate media does not move. The old arithmetic overshot by +24/+26/+108/+149 frames on the mismatched WAV, and timeline extract_source_frame_ranges built pull ranges out of it: widths of 543 and 749 for clips that consume 435 and 600 source frames. It falls back to the old sum only when GetSourceEndTime or the rate is unreadable.
405
+ - **Behavior:** The value is counted in the source MEDIA's own frame rate, not the timeline's. CORRECTION (2026-08-10, Studio 19.1.3.7): an earlier version of this entry said a WAV 'carries no frame rate, so Resolve falls back to 24 fps'. That is WRONG, and 24 is not a constant to rely on. A WAV takes the PROJECT's timelineFrameRate AT IMPORT and freezes it. Measured with one 400.000 s 48 kHz WAV imported into three project states: project at 24 -> clip FPS 24.0, Duration 00:06:40:00 (9600 frames = 400 s); project at 29.97 -> clip FPS 29.97, Duration 00:06:39:18 (11988 frames = 400 s); and changing the project rate to 29.97 AFTER import left the clip reading 24.0 (SetSetting returned True and the project did move). So the mismatch is not 'audio is always 24' but 'the clip kept the rate the project had when it was imported, and the project moved afterwards' — which also means a WAV imported into a 29.97 project behaves exactly like video, with no trap at all. ALWAYS read the clip's FPS property; never assume 24. The original 21.0.3.7 report below is consistent with this: that project was at 24 when the WAV was imported. Reading the frames 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. GetSourceEndFrame ITSELF changes convention between the two regimes and cannot be used raw: measured over 12 items on 19.1.3.7, it is EXCLUSIVE (equals the endFrame sent) when the source rate equals the timeline rate, and INCLUSIVE (one less) when they differ — off by one in exactly the case a caller reaches for it. It is not a media-type split: the same WAV imported at 29.97 into a 29.97 timeline read exclusive, like video, and only the rate MISMATCH flipped it. What IS stable across both regimes is GetSourceEndTime x media_fps, which was exact on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; 24.524 s x 29.97 = 734.98 -> 735) — seconds carry no frame-rate assumption, so the product is in source space. But NOT necessarily at the source's ORIGIN: all 12 of those items were synthetic media starting at 00:00:00:00. The second-readers are TIMECODE-absolute, so on media with a non-zero start TC the product is a source-RATE frame counted from the camera's timecode zero, not from the head of the file (issue #147, corrected in v2.97.3 — see the handling note below). Use the SPAN between the two second-readers, never either one alone: the offset cancels in the difference. CONFIRMED LIVE here on Studio 19.1.3.7 (2026-08-17), independently of the 21.0.4.5 report, with a CONTROLLED PAIR: two ffmpeg-generated 29.97 clips identical except that one carries -timecode 04:18:37;25, both cut into a 24 fps timeline. On the timecoded copy GetSourceStartTime read 15527.812 s where the file-relative answer is 10.010 s, while GetSourceStartFrame read 300 — the two readers in different spaces on the same edit point. round(GetSourceEndTime x fps) gave 465461 on a 1799-frame clip. After the fix the timecoded copy reports exactly what the zero-TC control reports for every range tried (300..393, 0..100, 1000..1500). Harness: tests/live_source_timecode_validation.py. NOTE the harness does NOT assert source_end == the endFrame sent: when the rates differ the record duration quantizes to whole TIMELINE frames, so a 93-source-frame request consumes ~92.4 and BOTH copies report 392. That is rate conversion, not a timecode error.
406
+ - **Workaround / current handling:** Convert an audio item's source frames with the MEDIA's rate, never the timeline's: seconds = source_start / media_fps. READ media_fps from the media-pool item's 'FPS' clip property (or ffprobe) every time — do not infer it from the timeline, and do NOT hard-code 24 for a WAV: that number is whatever the project rate was when the clip was imported, so it is 24 only for a project that was at 24, and a WAV imported at 29.97 has no mismatch at all. 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. source_end is no longer source_start + TIMELINE duration: as of v2.93.0 it is read from GetSourceEndTime and stays EXCLUSIVE as every caller already assumed. CORRECTION (v2.97.3, issue #147): that release computed it as round(GetSourceEndTime x media_fps) and this entry called the result 'a SOURCE frame by construction'. That holds ONLY for media starting at 00:00:00:00. Both second-readers answer in the media's TIMECODE space, so on a camera clip carrying time-of-day or continuous TC the product has the whole start timecode baked into it: reported on a Canon MP4 starting at 04:18:37;25, source_end came back 468800 on a clip only 10650 frames long, ~44x its length, while source_start (GetSourceStartFrame) stayed file-relative — so the two fields no longer shared an origin and could not be differenced. The v2.93.0 live validation used synthetic media, which starts at 00:00:00:00, so the offset was zero and the error was invisible in exactly that setup. It is now the seconds SPAN between the two readers, anchored on source_start: the offset cancels in the difference, so the result is file-relative under either convention and needs no timecode parsing. All four source_* fields are file-relative, the _seconds pair included. Measured live on 19.1.3.7 over 8 items in both regimes, it equals the endFrame actually sent every time, and it reproduces the old value exactly wherever the old value was already right — so matched-rate media does not move. The old arithmetic overshot by +24/+26/+108/+149 frames on the mismatched WAV, and timeline extract_source_frame_ranges built pull ranges out of it: widths of 543 and 749 for clips that consume 435 and 600 source frames. It falls back to the old sum only when GetSourceEndTime or the rate is unreadable.
407
407
  - **Tags:** timeline, audio, wav, frame-rate, mixed-fps, silent-failure, readback
408
408
 
409
409
  ### Studio-gated calls on the free edition raise a modal that blocks LATER calls
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.97.2"
40
+ VERSION = "2.97.3"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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.97.2",
3
+ "version": "2.97.3",
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.97.2"
90
+ VERSION = "2.97.3"
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.97.2"
14
+ VERSION = "2.97.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -3138,7 +3138,36 @@ def _timeline_item_source_end_frame(item):
3138
3138
  return None
3139
3139
 
3140
3140
 
3141
- def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps):
3141
+ def _timeline_item_source_time_offset(item, source_start, source_fps, source_start_origin):
3142
+ """Seconds between the second-readers' origin and the start of the file.
3143
+
3144
+ `GetSourceStartTime`/`GetSourceEndTime` answer in the media's TIMECODE space,
3145
+ so on any clip with a non-zero start TC they carry that offset — a Canon MP4
3146
+ starting at 04:18:37;25 reads 15639.187 s at source frame 3637, not the
3147
+ 121.355 s that frame is into the file. `GetSourceStartFrame`, meanwhile, is
3148
+ file-relative. Mixing the two spaces is what made `source_end` exceed the
3149
+ clip's own length by the whole start timecode (#147).
3150
+
3151
+ The offset is recoverable from the item alone, with no timecode parsing and
3152
+ no drop-frame arithmetic: the same edit point read both ways differs by
3153
+ exactly the offset. Returns None when it cannot be established, and 0.0 on
3154
+ media starting at 00:00:00:00 — where every caller below is then a no-op,
3155
+ which is why zero-TC behavior does not move.
3156
+ """
3157
+ if source_start is None or not source_fps:
3158
+ return None
3159
+ # GetLeftOffset counts an audio item in TIMELINE frames, so it is not the
3160
+ # same space as the second-readers and cannot calibrate them.
3161
+ if source_start_origin != "GetSourceStartFrame":
3162
+ return None
3163
+ start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
3164
+ if start_seconds is None:
3165
+ return None
3166
+ return start_seconds - (source_start / source_fps)
3167
+
3168
+
3169
+ def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps,
3170
+ source_start_origin=None):
3142
3171
  """The EXCLUSIVE source-space end frame — the contract every caller assumes.
3143
3172
 
3144
3173
  `source_end` has always been exclusive (`src_end_excl` at the append site,
@@ -3161,23 +3190,47 @@ def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps
3161
3190
  the two regimes (see above), so building on it would mean branching on a
3162
3191
  rate comparison this function would first have to reconstruct.
3163
3192
 
3193
+ That product is only a FILE-relative frame when the media starts at
3194
+ 00:00:00:00, though. `GetSourceEndTime` answers in the media's timecode
3195
+ space, so on camera footage carrying time-of-day or continuous TC it bakes
3196
+ the start timecode into the number: measured on a Canon MP4 starting at
3197
+ 04:18:37;25, it produced source_end 468800 on a clip only 10650 frames long
3198
+ (#147). `source_start` is file-relative, so the two fields stopped sharing an
3199
+ origin and could no longer be differenced.
3200
+
3201
+ The span between the two second-readers is offset-free whichever space they
3202
+ are in, so anchoring that span on the file-relative `source_start` is correct
3203
+ under both conventions and needs no timecode parsing. On zero-TC media it
3204
+ reproduces the plain product exactly, so nothing moves there.
3205
+
3164
3206
  Falls back to the historical `source_start + duration` when the second-reader
3165
3207
  or the rate is unreadable — same value as before, so an older build loses the
3166
3208
  correction rather than the field.
3167
3209
  """
3168
3210
  end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3169
3211
  if end_seconds is not None and source_fps:
3170
- end_frame = int(round(end_seconds * source_fps))
3171
- # A source end at or before the start means the readers disagree about
3172
- # this item; the derived value is the safer answer than a negative span.
3173
- if source_start is None or end_frame > source_start:
3174
- return end_frame
3212
+ offset = _timeline_item_source_time_offset(
3213
+ item, source_start, source_fps, source_start_origin)
3214
+ if offset is not None:
3215
+ span = int(round((end_seconds - offset) * source_fps)) - source_start
3216
+ # A non-positive span means the readers disagree about this item;
3217
+ # the derived value is safer than an inverted range.
3218
+ if span > 0:
3219
+ return source_start + span
3220
+ else:
3221
+ # No way to calibrate the origin: pre-#147 behavior, which is exact
3222
+ # on media starting at 00:00:00:00 and on every build old enough to
3223
+ # lack GetSourceStartTime that we have measured.
3224
+ end_frame = int(round(end_seconds * source_fps))
3225
+ if source_start is None or end_frame > source_start:
3226
+ return end_frame
3175
3227
  if source_start is not None and duration is not None:
3176
3228
  return source_start + duration
3177
3229
  return None
3178
3230
 
3179
3231
 
3180
- def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3232
+ def _timeline_item_source_seconds(item, source_start, source_end, source_fps,
3233
+ source_start_origin=None):
3181
3234
  """(start_seconds, end_seconds) into the source file, or None each.
3182
3235
 
3183
3236
  Prefers Resolve's second-readers, then a source-space frame divided by the
@@ -3185,14 +3238,26 @@ def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3185
3238
  is ``source_start + timeline_duration``, so on a 24 fps WAV in a 29.97 fps
3186
3239
  timeline it overstates the clip's span by 25% (18.1 s reported for a
3187
3240
  14.5 s clip). An unknown end reads as unknown.
3241
+
3242
+ Both readers answer in the media's TIMECODE space, so they are rebased onto
3243
+ the file-relative origin the frame fields use (#147) — otherwise
3244
+ ``source_start_seconds`` disagrees with ``source_start / source_fps`` on any
3245
+ camera clip, and "seconds into the source file" is not what the field holds.
3246
+ The correction is exactly zero on media starting at 00:00:00:00.
3188
3247
  """
3248
+ offset = _timeline_item_source_time_offset(
3249
+ item, source_start, source_fps, source_start_origin)
3189
3250
  start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
3190
3251
  if start_seconds is None:
3191
3252
  start_seconds = _source_frames_to_seconds(source_start, source_fps)
3253
+ elif offset is not None:
3254
+ start_seconds = round(start_seconds - offset, 3)
3192
3255
  end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3193
3256
  if end_seconds is None:
3194
3257
  end_seconds = _source_frames_to_seconds(
3195
3258
  _timeline_item_source_end_frame(item), source_fps)
3259
+ elif offset is not None:
3260
+ end_seconds = round(end_seconds - offset, 3)
3196
3261
  return start_seconds, end_seconds
3197
3262
 
3198
3263
 
@@ -3221,7 +3286,7 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3221
3286
  # of whichever reader produced source_start, so the audio caveat that blanks
3222
3287
  # source_fps must not blank the end as well.
3223
3288
  source_end = _timeline_item_source_end_exclusive(
3224
- item, source_start, duration, media_fps)
3289
+ item, source_start, duration, media_fps, source_start_origin)
3225
3290
  source_fps = media_fps
3226
3291
  if source_start_origin == "GetLeftOffset" and (track_info or (None,))[0] == "audio":
3227
3292
  # GetLeftOffset counts an audio item in TIMELINE frames, so pairing it
@@ -3229,7 +3294,7 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3229
3294
  # the frame and leave the rate unknown rather than convert it wrong.
3230
3295
  source_fps = None
3231
3296
  source_start_seconds, source_end_seconds = _timeline_item_source_seconds(
3232
- item, source_start, source_end, source_fps)
3297
+ item, source_start, source_end, source_fps, source_start_origin)
3233
3298
  summary = {
3234
3299
  "timeline_item_id": _safe_timeline_item_id(item),
3235
3300
  "name": _safe_timeline_item_name(item),
@@ -3238,10 +3303,13 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3238
3303
  "start": start,
3239
3304
  "end": end,
3240
3305
  "duration": duration,
3306
+ # All four source_* fields below are FILE-relative: frame 0 / 0.0 s is the
3307
+ # first frame of the media, whatever timecode the camera stamped on it.
3241
3308
  "source_start": source_start,
3242
- # EXCLUSIVE source frame. Read from source space via GetSourceEndTime;
3243
- # falls back to source_start + TIMELINE duration only when that reader or
3244
- # the media rate is unavailable, which is the old unit-mixed value.
3309
+ # EXCLUSIVE source frame. The span between Resolve's second-readers,
3310
+ # anchored on source_start so a non-zero start timecode cannot leak in;
3311
+ # falls back to source_start + TIMELINE duration only when those readers
3312
+ # or the media rate are unavailable, which is the old unit-mixed value.
3245
3313
  "source_end": source_end,
3246
3314
  "source_fps": source_fps,
3247
3315
  "source_start_seconds": source_start_seconds,
@@ -22275,10 +22343,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22275
22343
  freezes the PROJECT's rate at import, so it differs from the timeline whenever
22276
22344
  the project moved afterwards, and dividing by the timeline rate is then wrong by
22277
22345
  minutes. source_fps is null when the rate could not be read; treat the frames as
22278
- unitless then, do not assume the timeline's. source_end comes from
22279
- GetSourceEndTime x source_fps, so it is a source frame even when the rates
22280
- differ; it falls back to source_start + TIMELINE duration (unit-mixed) only when
22281
- that reader or the rate is unavailable.
22346
+ unitless then, do not assume the timeline's. All four source_* fields are
22347
+ FILE-relative frame 0 is the head of the media, whatever timecode the camera
22348
+ stamped on it. source_end is the span between Resolve's source second-readers
22349
+ anchored on source_start, so it is a source frame even when the rates differ and
22350
+ even on media with a non-zero start TC (those readers are timecode-absolute; the
22351
+ offset cancels in the span). It falls back to source_start + TIMELINE duration
22352
+ (unit-mixed) only when those readers or the rate are unavailable.
22282
22353
  detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
22283
22354
  source_range_report(handles?, merge?) -> {ranges, occurrences}
22284
22355
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
@@ -913,8 +913,33 @@ API_TRUTH: List[Dict[str, Any]] = [
913
913
  "regimes is GetSourceEndTime x media_fps, which was exact "
914
914
  "on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; "
915
915
  "24.524 s x 29.97 = 734.98 -> 735) — seconds carry no "
916
- "frame-rate assumption, so the product is in source space "
917
- "by construction.",
916
+ "frame-rate assumption, so the product is in source space. "
917
+ "But NOT necessarily at the source's ORIGIN: all 12 of those "
918
+ "items were synthetic media starting at 00:00:00:00. The "
919
+ "second-readers are TIMECODE-absolute, so on media with a "
920
+ "non-zero start TC the product is a source-RATE frame "
921
+ "counted from the camera's timecode zero, not from the head "
922
+ "of the file (issue #147, corrected in v2.97.3 — see the "
923
+ "handling note below). Use the SPAN between the two "
924
+ "second-readers, never either one alone: the offset cancels "
925
+ "in the difference. CONFIRMED LIVE here on Studio 19.1.3.7 "
926
+ "(2026-08-17), independently of the 21.0.4.5 report, with a "
927
+ "CONTROLLED PAIR: two ffmpeg-generated 29.97 clips identical "
928
+ "except that one carries -timecode 04:18:37;25, both cut "
929
+ "into a 24 fps timeline. On the timecoded copy "
930
+ "GetSourceStartTime read 15527.812 s where the file-relative "
931
+ "answer is 10.010 s, while GetSourceStartFrame read 300 — "
932
+ "the two readers in different spaces on the same edit point. "
933
+ "round(GetSourceEndTime x fps) gave 465461 on a 1799-frame "
934
+ "clip. After the fix the timecoded copy reports exactly what "
935
+ "the zero-TC control reports for every range tried "
936
+ "(300..393, 0..100, 1000..1500). Harness: "
937
+ "tests/live_source_timecode_validation.py. NOTE the harness "
938
+ "does NOT assert source_end == the endFrame sent: when the "
939
+ "rates differ the record duration quantizes to whole "
940
+ "TIMELINE frames, so a 93-source-frame request consumes "
941
+ "~92.4 and BOTH copies report 392. That is rate conversion, "
942
+ "not a timecode error.",
918
943
  "recommended": "Convert an audio item's source frames with the MEDIA's rate, "
919
944
  "never the timeline's: seconds = source_start / media_fps. "
920
945
  "READ media_fps from the media-pool item's 'FPS' clip property "
@@ -936,9 +961,29 @@ API_TRUTH: List[Dict[str, Any]] = [
936
961
  "reports the rate as unknown rather than converting a "
937
962
  "timeline-frame value at the media rate. source_end is "
938
963
  "no longer source_start + TIMELINE duration: as of "
939
- "v2.93.0 it is round(GetSourceEndTime x media_fps), "
940
- "which is a SOURCE frame by construction and stays "
941
- "EXCLUSIVE as every caller already assumed. Measured "
964
+ "v2.93.0 it is read from GetSourceEndTime and stays "
965
+ "EXCLUSIVE as every caller already assumed. CORRECTION "
966
+ "(v2.97.3, issue #147): that release computed it as "
967
+ "round(GetSourceEndTime x media_fps) and this entry "
968
+ "called the result 'a SOURCE frame by construction'. "
969
+ "That holds ONLY for media starting at 00:00:00:00. "
970
+ "Both second-readers answer in the media's TIMECODE "
971
+ "space, so on a camera clip carrying time-of-day or "
972
+ "continuous TC the product has the whole start "
973
+ "timecode baked into it: reported on a Canon MP4 "
974
+ "starting at 04:18:37;25, source_end came back 468800 "
975
+ "on a clip only 10650 frames long, ~44x its length, "
976
+ "while source_start (GetSourceStartFrame) stayed "
977
+ "file-relative — so the two fields no longer shared an "
978
+ "origin and could not be differenced. The v2.93.0 live "
979
+ "validation used synthetic media, which starts at "
980
+ "00:00:00:00, so the offset was zero and the error was "
981
+ "invisible in exactly that setup. It is now the seconds "
982
+ "SPAN between the two readers, anchored on source_start: "
983
+ "the offset cancels in the difference, so the result is "
984
+ "file-relative under either convention and needs no "
985
+ "timecode parsing. All four source_* fields are "
986
+ "file-relative, the _seconds pair included. Measured "
942
987
  "live on 19.1.3.7 over 8 items in both regimes, it "
943
988
  "equals the endFrame actually sent every time, and it "
944
989
  "reproduces the old value exactly wherever the old value "
@@ -954,6 +999,7 @@ API_TRUTH: List[Dict[str, Any]] = [
954
999
  "submit": "bug",
955
1000
  "mitigation": ["_media_item_source_fps", "_source_frames_to_seconds",
956
1001
  "_timeline_item_source_seconds",
1002
+ "_timeline_item_source_time_offset",
957
1003
  "_timeline_item_source_end_exclusive"],
958
1004
  },
959
1005
  {