davinci-resolve-mcp 2.89.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 +127 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +3 -1
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/aaf_probe.py +327 -11
- package/src/granular/common.py +1 -1
- package/src/server.py +183 -22
- package/src/utils/api_truth.py +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,133 @@
|
|
|
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
|
+
|
|
61
|
+
## What's New in v2.90.0
|
|
62
|
+
|
|
63
|
+
AAF turnovers parsed by `editorial.parse_interchange` on the advanced server now
|
|
64
|
+
carry their animation curves, the transforms nobody had interpreted, and the
|
|
65
|
+
effects that occupy record time while emitting nothing. All three were losses a
|
|
66
|
+
consumer could not see, because the parse reported itself complete.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **Keyframe curves ship instead of the word "varying".** A `VaryingValue` was
|
|
71
|
+
read for its values alone, and only to answer "one number or more than one" —
|
|
72
|
+
`ControlPoint.time` was never asked for, so every animated reframe reached a
|
|
73
|
+
consumer as `"varying"`: enough to refuse the clip, never enough to rebuild
|
|
74
|
+
it. Transform stages now carry `keyframes[<AvidParamName>]` and retimes carry
|
|
75
|
+
`speedCurve.playRate` / `speedCurve.sourceOffset`, each with its interpolation
|
|
76
|
+
and its points as `{t, v, frame}`.
|
|
77
|
+
- **`domain` names which rule produced `frame`,** because the two curve families
|
|
78
|
+
do not share a time domain and a single conversion rule would have been wrong
|
|
79
|
+
by an effect's whole length on one of them. Measured over all 2047 control
|
|
80
|
+
points of an 878-event Avid picture turnover: transform params are normalized
|
|
81
|
+
over the effect span with the endpoint inclusive (`frame = t x (length - 1)`,
|
|
82
|
+
which lands 1243 of 1254 points on an integer frame against 278 under
|
|
83
|
+
`length`), while speed maps are already in frames. Keys outside `0..1` are
|
|
84
|
+
kept rather than clamped — 107 of 1254 sit before the first frame or past the
|
|
85
|
+
last, which is what Avid leaves when an animated clip is trimmed. A curve with
|
|
86
|
+
one unreadable point is refused whole; interpolating through a missing key
|
|
87
|
+
produces a confident wrong animation.
|
|
88
|
+
- **`passthrough` stages carry the four uninterpreted transform operations**
|
|
89
|
+
(SBlend_v2, Stabilize_2, MaskImage_2, 2DMatteKey_2) that were previously
|
|
90
|
+
discarded whole — 22 events on the fixture. Their numbers travel in
|
|
91
|
+
`rawParams`, deliberately not `params`: the same parameter *name* carries
|
|
92
|
+
different units on different operations (SBlend's `DVE_POS_X_U` runs to -315
|
|
93
|
+
where Stabilize's runs to -0.92 on the same show), so there is nothing to
|
|
94
|
+
normalize, and a 2DMatteKey `AFX_POS_X_U` of 500 promoted into `params` would
|
|
95
|
+
become a ~960px shift of a clip nobody repositioned.
|
|
96
|
+
- **`effectsWithoutEvents` closes a hole `unhandled` structurally cannot see.**
|
|
97
|
+
An effect can be modelled perfectly and still emit nothing — the group is
|
|
98
|
+
walked, its inputs are walked, and they contain no `SourceClip`. On the
|
|
99
|
+
fixture `unhandled` reads `{}` (a complete parse) while 29 SubCap titles
|
|
100
|
+
occupy real record time and reach the consumer as nothing whatsoever. Charged
|
|
101
|
+
once, to the innermost cause.
|
|
102
|
+
- **`speedRatioFromCurve`** — the rate the offset curve itself implies, emitted
|
|
103
|
+
only when that curve is straight, so no variable timewarp is ever averaged
|
|
104
|
+
into a single number.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **The declared AAF `SpeedRatio` rational is the source span truncated to whole
|
|
109
|
+
frames, and 7 of 18 constant retimes on the fixture disagree with their own
|
|
110
|
+
curve** — `201/112 = 1.794643` where the curve says `201.6/112 = 1.80`, and at
|
|
111
|
+
worst `31/19 = 1.631579` against a curve reading `1.70`, a 4% speed error in a
|
|
112
|
+
number an operator is handed to type in by hand. Every curve slope lands on a
|
|
113
|
+
rate an editor would actually dial (1.7, 1.8, 2.0, 0.75); every declared value
|
|
114
|
+
is that rate spoiled by rounding. Both ship under their own names and neither
|
|
115
|
+
is substituted for the other.
|
|
116
|
+
- **Variable timewarps are reconstructible.** The offset curve is dense (up to
|
|
117
|
+
387 points at half-frame steps) and describes where every record frame reads
|
|
118
|
+
from, taking the 4 variable timewarps on the fixture from "flagged, rebuild
|
|
119
|
+
from nothing" to fully described — including one reverse ramp whose offset
|
|
120
|
+
runs 301.0 to -0.78. Reverse read off the curve agreed with the declared flag
|
|
121
|
+
9 of 9, in both directions.
|
|
122
|
+
|
|
123
|
+
### Validation
|
|
124
|
+
|
|
125
|
+
- Every pre-existing field is byte-identical on the fixture, verified by a
|
|
126
|
+
structural diff of the full 878-event parse against the previous output; event
|
|
127
|
+
count, `unhandled`, and all existing counters are unchanged.
|
|
128
|
+
- 10 new tests in `resolve-advanced/test/aaf-sequences.test.mjs`.
|
|
129
|
+
- No DaVinci Resolve scripting behavior changed; this is offline interchange
|
|
130
|
+
parsing, so no live Resolve validation was required.
|
|
131
|
+
|
|
5
132
|
## What's New in v2.89.0
|
|
6
133
|
|
|
7
134
|
The build gates this server already enforced are now gates an agent can ask
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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,
|
|
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.
|
|
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
|
@@ -14,6 +14,7 @@ trusting, so the Node server shells out to this helper, which uses the pure-Pyth
|
|
|
14
14
|
"startTimecode": <"HH:MM:SS:FF"|null>, "startFrame": <int|null>,
|
|
15
15
|
"startTimecodeFps": <int|null>, "startTimecodeDrop": <bool|null>,
|
|
16
16
|
"unhandled": { "<ComponentClass>": <int>, ... },
|
|
17
|
+
"effectsWithoutEvents": { "<OperationName>": <int>, ... },
|
|
17
18
|
"events": [ {normalized-event}, ... ] }
|
|
18
19
|
]
|
|
19
20
|
}
|
|
@@ -31,6 +32,25 @@ recoverable from the OperationGroup's parameters (see _retime_fields):
|
|
|
31
32
|
For retimes, srcIn/srcOut are the SOURCE-side range while recIn/recOut span the
|
|
32
33
|
OperationGroup's DECLARED (record) length — they differ by the ratio.
|
|
33
34
|
|
|
35
|
+
KEYFRAMES (U21). A VaryingValue is a curve, and it used to be read for its values
|
|
36
|
+
alone — enough to say "this animates", never enough to rebuild it. Curves now ship:
|
|
37
|
+
* transform stages → `geometry[i].keyframes[<AvidParamName>]`
|
|
38
|
+
* retimes → `speedCurve.playRate` / `speedCurve.sourceOffset`
|
|
39
|
+
each `{ interpolation, domain, effectLength, points: [{t, v, frame}] }`. 🚨 The two
|
|
40
|
+
families do NOT share a time domain — `domain` names which one applies:
|
|
41
|
+
* "effectSpan" → frame = t x (effectLength - 1) (transform parameters)
|
|
42
|
+
* "effectFrames" → frame = t (speed maps, already frames)
|
|
43
|
+
Keys outside 0..1 are real (a clip trimmed after it was animated) and are kept, and
|
|
44
|
+
a frame may be fractional. A curve with ONE unreadable point is refused whole.
|
|
45
|
+
Retimes also carry `"speedRatioFromCurve"` when the offset curve is straight — the
|
|
46
|
+
declared SpeedRatio rational is the source span truncated to WHOLE FRAMES and is
|
|
47
|
+
wrong by up to 4% on real material, so both numbers ship under their own names.
|
|
48
|
+
|
|
49
|
+
TRANSFORM OPS WE DO NOT INTERPRET (U22) get a stage marked `"passthrough": true`
|
|
50
|
+
whose numbers sit in `"rawParams"`, never `"params"` — the same parameter NAME
|
|
51
|
+
carries different units on different operations, so there is nothing to normalize
|
|
52
|
+
and nothing a consumer may safely compose. See _PASSTHROUGH_GEOMETRY_OPS.
|
|
53
|
+
|
|
34
54
|
Honest-refuse discipline (no fake parses):
|
|
35
55
|
* exit 3 → pyaaf2 not installed (stderr: AAF_PROBE_NO_PYAAF2)
|
|
36
56
|
* exit 4 → file unreadable / not an AAF (stderr: AAF_PROBE_UNREADABLE: <detail>)
|
|
@@ -45,6 +65,13 @@ an genuinely empty sequence, and worse than an honest refusal because downstream
|
|
|
45
65
|
consumers gate on `ok`. Every component we skip is now counted by class name and
|
|
46
66
|
reported per sequence, so a miss is VISIBLE without changing the exit-code contract.
|
|
47
67
|
|
|
68
|
+
`effectsWithoutEvents` (U23) closes the hole `unhandled` structurally cannot see.
|
|
69
|
+
An effect can be modelled PERFECTLY and still produce nothing: an OperationGroup is
|
|
70
|
+
walked, its inputs are walked, and they contain no SourceClip. On the reference
|
|
71
|
+
turnover `unhandled` reads `{}` — a complete parse — while 29 SubCap title effects
|
|
72
|
+
occupy real record time and reach the consumer as nothing at all. Each such group is
|
|
73
|
+
counted once, against the innermost operation that caused it.
|
|
74
|
+
|
|
48
75
|
Segment model (Avid Media Composer picture turnovers):
|
|
49
76
|
* NestedScope — a multi-layer video track. Its `.slots` are the layers (V1..Vn);
|
|
50
77
|
it has NO `.components`. Layers are PARALLEL, so each layer's
|
|
@@ -66,6 +93,7 @@ effect carry `"geometry"` (see _geometry_fields).
|
|
|
66
93
|
|
|
67
94
|
import json
|
|
68
95
|
import os
|
|
96
|
+
import re
|
|
69
97
|
import sys
|
|
70
98
|
|
|
71
99
|
|
|
@@ -421,20 +449,154 @@ def _param_is(param, name, auid=None):
|
|
|
421
449
|
return False
|
|
422
450
|
|
|
423
451
|
|
|
424
|
-
def
|
|
425
|
-
"""
|
|
452
|
+
def _pointlist(varying):
|
|
453
|
+
"""A VaryingValue's ControlPoint objects, or None if unreadable."""
|
|
426
454
|
points = getattr(varying, "pointlist", None)
|
|
427
455
|
if points is None:
|
|
428
456
|
return None
|
|
429
457
|
inner = getattr(points, "value", None)
|
|
430
458
|
if inner is not None:
|
|
431
459
|
points = inner
|
|
460
|
+
try:
|
|
461
|
+
return list(points)
|
|
462
|
+
except Exception:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _pointlist_values(varying):
|
|
467
|
+
"""Control-point VALUES of a VaryingValue's point list, or None if unreadable."""
|
|
468
|
+
points = _pointlist(varying)
|
|
469
|
+
if points is None:
|
|
470
|
+
return None
|
|
432
471
|
try:
|
|
433
472
|
return [float(p.value) for p in points]
|
|
434
473
|
except Exception:
|
|
435
474
|
return None
|
|
436
475
|
|
|
437
476
|
|
|
477
|
+
# ── Keyframes (VaryingValue control points) ───────────────────────────────────
|
|
478
|
+
# A VaryingValue is a CURVE, and until now the probe read only its values, and only
|
|
479
|
+
# to answer "is this one number or more than one". The times were never asked for,
|
|
480
|
+
# so an animated reframe reached a consumer as the bare word "varying" — enough to
|
|
481
|
+
# refuse the clip, never enough to rebuild it.
|
|
482
|
+
#
|
|
483
|
+
# `ControlPoint.time` is readable. It is NOT one domain, and that is the whole trap
|
|
484
|
+
# here — measured over all 2047 control points of a real 878-event Avid turnover:
|
|
485
|
+
#
|
|
486
|
+
# * TRANSFORM parameters (AFX_*, DVE_*) are normalized over the effect span, with
|
|
487
|
+
# the endpoint INCLUSIVE: frame = time x (length - 1).
|
|
488
|
+
# 1243 of 1254 points land exactly on an integer frame under (length - 1); only
|
|
489
|
+
# 278 also do under (length), so the two are distinguishable and (length - 1)
|
|
490
|
+
# is the rule. The 11 that land on neither are ONE keyframe of one clip at
|
|
491
|
+
# frame 125.5 — a legitimate half-frame key, which (length - 1) renders exactly.
|
|
492
|
+
# * SPEED maps are already in effect FRAMES: PARAM_SPEED_OFFSET_MAP_U spans
|
|
493
|
+
# exactly 0..length on every one of the 11 retimes carrying it.
|
|
494
|
+
#
|
|
495
|
+
# So a single "convert to frames" rule would be wrong by the effect's whole length
|
|
496
|
+
# on one of the two families. The domain is therefore NAMED in the output and the
|
|
497
|
+
# raw stored `t` travels next to the derived `frame`, so a consumer that disagrees
|
|
498
|
+
# with our arithmetic can redo it. Same discipline as the raw AFX_* passthrough:
|
|
499
|
+
# a stored number keeps its own name until its semantics are measured.
|
|
500
|
+
#
|
|
501
|
+
# Keyframes outside 0..1 are REAL and are kept: 107 of 1254 sit before the effect's
|
|
502
|
+
# first frame or after its last, which is what Avid leaves behind when a clip is
|
|
503
|
+
# trimmed after it was animated. Clamping them would silently move the animation.
|
|
504
|
+
|
|
505
|
+
_DOMAIN_EFFECT_SPAN = "effectSpan" # frame = t x (length - 1)
|
|
506
|
+
_DOMAIN_EFFECT_FRAMES = "effectFrames" # frame = t
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _interpolation_name(varying):
|
|
510
|
+
try:
|
|
511
|
+
return str(varying.interpolationdef.name or "") or None
|
|
512
|
+
except Exception:
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _keyframes(varying, *, length, domain):
|
|
517
|
+
"""One VaryingValue as an explicit curve, or None if its points are unreadable.
|
|
518
|
+
|
|
519
|
+
Shape: {"interpolation", "domain", "effectLength", "points": [{"t","frame","v"}]}
|
|
520
|
+
`frame` is omitted when the effect length is unknown, because there is nothing
|
|
521
|
+
to normalize against and a fabricated frame number is worse than none.
|
|
522
|
+
"""
|
|
523
|
+
points = _pointlist(varying)
|
|
524
|
+
if not points:
|
|
525
|
+
return None
|
|
526
|
+
span = None
|
|
527
|
+
if isinstance(length, int) and length > 1 and domain == _DOMAIN_EFFECT_SPAN:
|
|
528
|
+
span = length - 1
|
|
529
|
+
out = []
|
|
530
|
+
for p in points:
|
|
531
|
+
try:
|
|
532
|
+
t = float(p.time)
|
|
533
|
+
v = float(p.value)
|
|
534
|
+
except Exception:
|
|
535
|
+
# A point we cannot read makes the CURVE untrustworthy, not just the
|
|
536
|
+
# point — an animation with a hole in it would interpolate straight
|
|
537
|
+
# through the missing key. Refuse the whole parameter.
|
|
538
|
+
return None
|
|
539
|
+
point = {"t": round(t, 9), "v": round(v, 6)}
|
|
540
|
+
if domain == _DOMAIN_EFFECT_FRAMES:
|
|
541
|
+
point["frame"] = round(t, 6)
|
|
542
|
+
elif span:
|
|
543
|
+
point["frame"] = round(t * span, 6)
|
|
544
|
+
out.append(point)
|
|
545
|
+
curve = {"domain": domain, "points": out}
|
|
546
|
+
interp = _interpolation_name(varying)
|
|
547
|
+
if interp:
|
|
548
|
+
curve["interpolation"] = interp
|
|
549
|
+
if isinstance(length, int) and length > 0:
|
|
550
|
+
curve["effectLength"] = length
|
|
551
|
+
return curve
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _rate_from_offset_curve(curve):
|
|
555
|
+
"""The play rate the source-offset curve ITSELF implies, or None.
|
|
556
|
+
|
|
557
|
+
Only returned when that curve is straight — every interior point on the line
|
|
558
|
+
through its endpoints, to within a tenth of a frame. A dense VARYING map is not
|
|
559
|
+
one rate and must not be averaged into one, so colinearity is the test that
|
|
560
|
+
separates the two rather than a flag we would have to trust.
|
|
561
|
+
|
|
562
|
+
🚨 Why this exists: the declared `SpeedRatio` rational is the source span
|
|
563
|
+
TRUNCATED TO WHOLE FRAMES over the record length, and the curve is exact.
|
|
564
|
+
Measured on the fixture, 6 of 18 constant retimes disagree —
|
|
565
|
+
|
|
566
|
+
L=112 curve span 201.600 rate 1.80 declared 201/112 = 1.794643
|
|
567
|
+
L= 71 curve span 142.000 rate 2.00 declared 141/71 = 1.985915
|
|
568
|
+
L= 19 curve span 32.300 rate 1.70 declared 31/19 = 1.631579 ← 4%
|
|
569
|
+
|
|
570
|
+
— and every curve slope lands on a number an editor would actually dial (1.7,
|
|
571
|
+
1.8, 2.0, 0.75) while every declared value is that number spoiled by a rounding.
|
|
572
|
+
A consumer handing 1.631579 to an operator to type in has handed them a visibly
|
|
573
|
+
wrong speed. Both numbers are reported under their own names; neither is
|
|
574
|
+
silently substituted for the other.
|
|
575
|
+
"""
|
|
576
|
+
if not curve:
|
|
577
|
+
return None
|
|
578
|
+
points = curve.get("points") or []
|
|
579
|
+
if len(points) < 2:
|
|
580
|
+
return None
|
|
581
|
+
first, last = points[0], points[-1]
|
|
582
|
+
try:
|
|
583
|
+
run = float(last["frame"]) - float(first["frame"])
|
|
584
|
+
rise = float(last["v"]) - float(first["v"])
|
|
585
|
+
except (KeyError, TypeError, ValueError):
|
|
586
|
+
return None
|
|
587
|
+
if abs(run) < 1e-9:
|
|
588
|
+
return None
|
|
589
|
+
slope = rise / run
|
|
590
|
+
for p in points[1:-1]:
|
|
591
|
+
try:
|
|
592
|
+
expected = float(first["v"]) + (float(p["frame"]) - float(first["frame"])) * slope
|
|
593
|
+
except (KeyError, TypeError, ValueError):
|
|
594
|
+
return None
|
|
595
|
+
if abs(float(p["v"]) - expected) > 0.1:
|
|
596
|
+
return None # a real timewarp — no single rate describes it
|
|
597
|
+
return slope
|
|
598
|
+
|
|
599
|
+
|
|
438
600
|
def _retime_fields(op_group):
|
|
439
601
|
"""Extra event fields recovered from a retime OperationGroup's parameters.
|
|
440
602
|
|
|
@@ -442,14 +604,34 @@ def _retime_fields(op_group):
|
|
|
442
604
|
{"speedRatio": <play-rate float>, "speed": <int %>, "reverse": <bool>}
|
|
443
605
|
{"speedVarying": True} — a variable-speed timewarp; no single honest number
|
|
444
606
|
{} — nothing recoverable (flag-only, speed stays 100)
|
|
607
|
+
|
|
608
|
+
Any of those may additionally carry "speedCurve" — the retime's own keyframes.
|
|
609
|
+
A variable timewarp used to reach a consumer as the single word `speedVarying`,
|
|
610
|
+
which is enough to REFUSE the clip and never enough to rebuild it. Two curves
|
|
611
|
+
are emitted when present, under Avid's own parameter names:
|
|
612
|
+
|
|
613
|
+
playRate PARAM_SPEED_MAP_U — sparse play-rate keys (AvidCubicInterpolator
|
|
614
|
+
on every one measured), keys freely outside the trimmed range.
|
|
615
|
+
sourceOffset PARAM_SPEED_OFFSET_MAP_U — a DENSE source-position curve, linear,
|
|
616
|
+
spanning exactly 0..length (up to 387 points at half-frame steps
|
|
617
|
+
on the fixture). Its slope reproduces the constant ratios exactly
|
|
618
|
+
— 0.75 → 175.75 over 100 frames is the 1.75 the SpeedRatio
|
|
619
|
+
declares — so it is the same quantity measured a second way, and
|
|
620
|
+
for a VARYING timewarp it is the only complete description of
|
|
621
|
+
where each record frame reads from.
|
|
445
622
|
"""
|
|
446
623
|
play = None
|
|
447
624
|
speed_map = None
|
|
625
|
+
offset_map = None
|
|
626
|
+
length = _length(op_group)
|
|
448
627
|
for param in _op_parameters(op_group):
|
|
449
628
|
cls = type(param).__name__
|
|
450
629
|
if cls == "VaryingValue":
|
|
451
|
-
|
|
630
|
+
name = _param_name(param)
|
|
631
|
+
if name == "PARAM_SPEED_MAP_U":
|
|
452
632
|
speed_map = param
|
|
633
|
+
elif name == "PARAM_SPEED_OFFSET_MAP_U":
|
|
634
|
+
offset_map = param
|
|
453
635
|
continue
|
|
454
636
|
if cls != "ConstantValue":
|
|
455
637
|
continue
|
|
@@ -469,22 +651,41 @@ def _retime_fields(op_group):
|
|
|
469
651
|
continue
|
|
470
652
|
if value:
|
|
471
653
|
play = value # *_U family stores the play rate directly
|
|
654
|
+
curve = {}
|
|
655
|
+
for key, param in (("playRate", speed_map), ("sourceOffset", offset_map)):
|
|
656
|
+
if param is None:
|
|
657
|
+
continue
|
|
658
|
+
# Both live in the effect's own FRAME domain — measured, see _keyframes.
|
|
659
|
+
got = _keyframes(param, length=length, domain=_DOMAIN_EFFECT_FRAMES)
|
|
660
|
+
if got:
|
|
661
|
+
got["parameter"] = _param_name(param)
|
|
662
|
+
curve[key] = got
|
|
663
|
+
extra = {"speedCurve": curve} if curve else {}
|
|
664
|
+
measured = _rate_from_offset_curve(curve.get("sourceOffset"))
|
|
665
|
+
if measured is not None:
|
|
666
|
+
extra["speedRatioFromCurve"] = round(abs(measured), 6)
|
|
667
|
+
if measured < 0:
|
|
668
|
+
extra["reverseFromCurve"] = True
|
|
669
|
+
|
|
472
670
|
if speed_map is not None:
|
|
473
671
|
values = _pointlist_values(speed_map)
|
|
474
672
|
if values is None:
|
|
475
673
|
# A speed map we cannot read: we can neither call the speed constant
|
|
476
|
-
# nor prove it varies — recover nothing rather than guess.
|
|
477
|
-
|
|
674
|
+
# nor prove it varies — recover nothing rather than guess. The offset
|
|
675
|
+
# curve, if it read cleanly, still ships: it is an independent
|
|
676
|
+
# parameter and its unreadable sibling says nothing about it.
|
|
677
|
+
return extra
|
|
478
678
|
if len(set(values)) > 1:
|
|
479
|
-
return {"speedVarying": True}
|
|
679
|
+
return {"speedVarying": True, **extra}
|
|
480
680
|
if play is None and values and values[0]:
|
|
481
681
|
play = values[0] # a flat map's single value IS the constant play rate
|
|
482
682
|
if not play:
|
|
483
|
-
return
|
|
683
|
+
return extra
|
|
484
684
|
return {
|
|
485
685
|
"speedRatio": round(abs(play), 6),
|
|
486
686
|
"speed": int(round(abs(play) * 100)),
|
|
487
687
|
"reverse": play < 0,
|
|
688
|
+
**extra,
|
|
488
689
|
}
|
|
489
690
|
|
|
490
691
|
|
|
@@ -518,6 +719,31 @@ def _retime_fields(op_group):
|
|
|
518
719
|
|
|
519
720
|
_GEOMETRY_OPS = frozenset({"PaintResize_v2", "SpatialAdapter", "FlipHoriz_2"})
|
|
520
721
|
|
|
722
|
+
# ── Transform-bearing operations we do NOT interpret (U22) ────────────────────
|
|
723
|
+
# Widening _GEOMETRY_OPS is the wrong fix for these. Census of the same turnover:
|
|
724
|
+
#
|
|
725
|
+
# SBlend_v2 (18 groups over 12 clips) DVE_SCALE_X/Y_U 103, 123, 110→120 ·
|
|
726
|
+
# DVE_POS_X_U -60, -315 · DVE_ROT_Z_U 3, 1
|
|
727
|
+
# Stabilize_2 (5) DVE_SCALE_X/Y_U 102.52, 101.96 (a
|
|
728
|
+
# stabiliser's zoom-in) · DVE_POS_X_U 1.54
|
|
729
|
+
# MaskImage_2 (1), 2DMatteKey_2 (1) AFX_POS/AFX_SCALE
|
|
730
|
+
#
|
|
731
|
+
# 🚨 The same parameter NAME does not mean the same unit: SBlend's DVE_POS_X_U runs
|
|
732
|
+
# to -315 while Stabilize's runs to -0.92 on the same show. Two ops, one name, two
|
|
733
|
+
# units — so there is nothing here to normalize, and `scalePercent*` on these would
|
|
734
|
+
# be composed into the applied zoom by a consumer that has no way to know better.
|
|
735
|
+
#
|
|
736
|
+
# 🚨 And they cannot ride in `params` either: 2DMatteKey_2 carries AFX_POS_X_U 500.0,
|
|
737
|
+
# which is a name an existing consumer already has a CALIBRATED rule for — it would
|
|
738
|
+
# be silently applied as a ~960px reposition of a clip nobody repositioned. So the
|
|
739
|
+
# numbers travel under their own key, `rawParams`, which no consumer reads yet, next
|
|
740
|
+
# to `passthrough: true`. Reported, never silently dropped; never silently applied.
|
|
741
|
+
_PASSTHROUGH_GEOMETRY_OPS = frozenset({"SBlend_v2", "Stabilize_2", "MaskImage_2", "2DMatteKey_2"})
|
|
742
|
+
|
|
743
|
+
_PASSTHROUGH_PARAM_RE = re.compile(
|
|
744
|
+
r"(?:^|_)(?:POS|SCALE|CROP|ROT|SKEW|SIZE|OPACITY|ASPECT)(?:_|$)|CORNER_PIN|ENABLED"
|
|
745
|
+
)
|
|
746
|
+
|
|
521
747
|
_GEOMETRY_SCALARS = frozenset(
|
|
522
748
|
{
|
|
523
749
|
"AFX_POS_X_U",
|
|
@@ -566,6 +792,52 @@ def _geometry_scalar(param):
|
|
|
566
792
|
return _param_number(param), False
|
|
567
793
|
|
|
568
794
|
|
|
795
|
+
def _geometry_keyframes(param, length):
|
|
796
|
+
"""The curve behind an ANIMATED geometry parameter, in the effect-span domain."""
|
|
797
|
+
return _keyframes(param, length=length, domain=_DOMAIN_EFFECT_SPAN)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _passthrough_geometry(op_group, op_name):
|
|
801
|
+
"""A transform op we do not interpret, reported rather than dropped (U22).
|
|
802
|
+
|
|
803
|
+
Everything geometry-shaped, under Avid's own names, in `rawParams` — plus the
|
|
804
|
+
curve for any of them that animates. No normalized field is emitted and no
|
|
805
|
+
`params` key is used, so this can be composed by nobody and misread by nobody.
|
|
806
|
+
Returns None when the group carries nothing geometry-shaped at all.
|
|
807
|
+
"""
|
|
808
|
+
length = _length(op_group)
|
|
809
|
+
raw = {}
|
|
810
|
+
keyframes = {}
|
|
811
|
+
for param in _op_parameters(op_group):
|
|
812
|
+
name = _param_name(param)
|
|
813
|
+
if not name or not _PASSTHROUGH_PARAM_RE.search(name):
|
|
814
|
+
continue
|
|
815
|
+
if type(param).__name__ == "VaryingValue":
|
|
816
|
+
values = _pointlist_values(param)
|
|
817
|
+
if values is None:
|
|
818
|
+
continue
|
|
819
|
+
if len(set(values)) > 1:
|
|
820
|
+
curve = _keyframes(param, length=length, domain=_DOMAIN_EFFECT_SPAN)
|
|
821
|
+
if curve:
|
|
822
|
+
keyframes[name] = curve
|
|
823
|
+
continue
|
|
824
|
+
if values:
|
|
825
|
+
raw[name] = round(float(values[0]), 6)
|
|
826
|
+
continue
|
|
827
|
+
value = _param_number(param)
|
|
828
|
+
if value is not None:
|
|
829
|
+
raw[name] = value
|
|
830
|
+
if not raw and not keyframes:
|
|
831
|
+
return None
|
|
832
|
+
out = {"effect": op_name, "passthrough": True}
|
|
833
|
+
if raw:
|
|
834
|
+
out["rawParams"] = dict(sorted(raw.items()))
|
|
835
|
+
if keyframes:
|
|
836
|
+
out["keyframes"] = dict(sorted(keyframes.items()))
|
|
837
|
+
out["varying"] = sorted(keyframes)
|
|
838
|
+
return out
|
|
839
|
+
|
|
840
|
+
|
|
569
841
|
def _geometry_fields(op_group, op_name):
|
|
570
842
|
"""Recovered geometry for one transform OperationGroup. See the census above."""
|
|
571
843
|
scale = {}
|
|
@@ -573,6 +845,16 @@ def _geometry_fields(op_group, op_name):
|
|
|
573
845
|
bools = {}
|
|
574
846
|
rects = {}
|
|
575
847
|
varying = set()
|
|
848
|
+
keyframes = {}
|
|
849
|
+
length = _length(op_group)
|
|
850
|
+
|
|
851
|
+
def note_varying(param, name):
|
|
852
|
+
"""Name the animated parameter AND keep its curve."""
|
|
853
|
+
varying.add(name)
|
|
854
|
+
curve = _geometry_keyframes(param, length)
|
|
855
|
+
if curve:
|
|
856
|
+
keyframes[name] = curve
|
|
857
|
+
|
|
576
858
|
for param in _op_parameters(op_group):
|
|
577
859
|
name = _param_name(param)
|
|
578
860
|
base, _, suffix = name.rpartition("_")
|
|
@@ -584,7 +866,7 @@ def _geometry_fields(op_group, op_name):
|
|
|
584
866
|
if name in ("AFX_SCALE_X_U", "AFX_SCALE_Y_U"):
|
|
585
867
|
value, is_varying = _geometry_scalar(param)
|
|
586
868
|
if is_varying:
|
|
587
|
-
|
|
869
|
+
note_varying(param, name)
|
|
588
870
|
elif value is not None:
|
|
589
871
|
scale[name] = value
|
|
590
872
|
continue
|
|
@@ -596,7 +878,7 @@ def _geometry_fields(op_group, op_name):
|
|
|
596
878
|
if name in _GEOMETRY_SCALARS:
|
|
597
879
|
value, is_varying = _geometry_scalar(param)
|
|
598
880
|
if is_varying:
|
|
599
|
-
|
|
881
|
+
note_varying(param, name)
|
|
600
882
|
elif value is not None:
|
|
601
883
|
scalars[name] = value
|
|
602
884
|
geometry = {"effect": op_name}
|
|
@@ -622,8 +904,12 @@ def _geometry_fields(op_group, op_name):
|
|
|
622
904
|
if sizes.get(src) and sizes.get(framing) is not None:
|
|
623
905
|
geometry[f"reformatScale{axis}"] = round(sizes[framing] / sizes[src], 6)
|
|
624
906
|
if varying:
|
|
625
|
-
# An animated transform. Named, never reduced to one number
|
|
907
|
+
# An animated transform. Named, never reduced to one number — and, since
|
|
908
|
+
# U21, never reduced to the NAME either: `keyframes` carries the curve.
|
|
909
|
+
# `varying` stays exactly as it was, because consumers gate on it.
|
|
626
910
|
geometry["varying"] = sorted(varying)
|
|
911
|
+
if keyframes:
|
|
912
|
+
geometry["keyframes"] = dict(sorted(keyframes.items()))
|
|
627
913
|
params = dict(sorted({**scalars, **bools}.items()))
|
|
628
914
|
if params:
|
|
629
915
|
geometry["params"] = params
|
|
@@ -691,9 +977,21 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
|
|
|
691
977
|
# overlap it on the same track label. Callers already cannot assume events are
|
|
692
978
|
# non-overlapping — parallel NestedScope layers overlap by construction.
|
|
693
979
|
before = len(state["events"])
|
|
980
|
+
charges_before = state.get("_emptyEffectCharges", 0)
|
|
694
981
|
for inp in getattr(segment, "segments", None) or []:
|
|
695
982
|
_walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
696
983
|
op_name = _operation_name(segment)
|
|
984
|
+
|
|
985
|
+
if op_name and declared > 0 and len(state["events"]) == before:
|
|
986
|
+
# Walked cleanly, emitted nothing, and still consumed record time. See the
|
|
987
|
+
# "effectsWithoutEvents" note in probe(). Only the INNERMOST such group is
|
|
988
|
+
# charged — an outer wrapper around an empty effect is empty for the same
|
|
989
|
+
# one reason, and charging both would report one title as two.
|
|
990
|
+
if state.get("_emptyEffectCharges", 0) == charges_before:
|
|
991
|
+
bucket = state.setdefault("effectsWithoutEvents", {})
|
|
992
|
+
bucket[op_name] = bucket.get(op_name, 0) + 1
|
|
993
|
+
state["_emptyEffectCharges"] = charges_before + 1
|
|
994
|
+
|
|
697
995
|
if op_name in _GEOMETRY_OPS:
|
|
698
996
|
# A transform effect. Clips are commonly wrapped in MORE than one (a
|
|
699
997
|
# SpatialAdapter reformat inside a PaintResize, say), so geometry is a
|
|
@@ -703,6 +1001,14 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
|
|
|
703
1001
|
geometry = _geometry_fields(segment, op_name)
|
|
704
1002
|
for ev in state["events"][before:]:
|
|
705
1003
|
ev.setdefault("geometry", []).append(geometry)
|
|
1004
|
+
elif op_name in _PASSTHROUGH_GEOMETRY_OPS:
|
|
1005
|
+
# Carries a transform whose units we have not measured. It joins the same
|
|
1006
|
+
# ordered stack so its PLACE in the chain survives, marked passthrough so
|
|
1007
|
+
# no consumer composes it. See _PASSTHROUGH_GEOMETRY_OPS.
|
|
1008
|
+
geometry = _passthrough_geometry(segment, op_name)
|
|
1009
|
+
if geometry:
|
|
1010
|
+
for ev in state["events"][before:]:
|
|
1011
|
+
ev.setdefault("geometry", []).append(geometry)
|
|
706
1012
|
if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
|
|
707
1013
|
# A retime. Its ratio is recoverable from the group's PARAMETERS (see
|
|
708
1014
|
# _retime_fields): a constant ratio updates speed/speedRatio so
|
|
@@ -991,7 +1297,7 @@ def probe(path):
|
|
|
991
1297
|
except Exception:
|
|
992
1298
|
name = None
|
|
993
1299
|
# `idx` is monotonic across the WHOLE mob, every slot and every nested layer.
|
|
994
|
-
state = {"idx": 1, "events": [], "unhandled": {}}
|
|
1300
|
+
state = {"idx": 1, "events": [], "unhandled": {}, "effectsWithoutEvents": {}}
|
|
995
1301
|
edit_fps = None
|
|
996
1302
|
for slot in getattr(mob, "slots", []) or []:
|
|
997
1303
|
seg = getattr(slot, "segment", None)
|
|
@@ -1024,6 +1330,16 @@ def probe(path):
|
|
|
1024
1330
|
# Component classes we could not model, by name+count. Empty {} means
|
|
1025
1331
|
# a structurally complete read; non-empty means events are INCOMPLETE.
|
|
1026
1332
|
"unhandled": dict(sorted(state["unhandled"].items())),
|
|
1333
|
+
# 🚨 Effects that OCCUPY RECORD TIME and produce no event (U23).
|
|
1334
|
+
# `unhandled` cannot see these: it counts component classes the
|
|
1335
|
+
# walker does not model, and these are modeled fine — an
|
|
1336
|
+
# OperationGroup is walked, its inputs are walked, and they simply
|
|
1337
|
+
# contain no SourceClip. On the reference turnover `unhandled` reads
|
|
1338
|
+
# {} — a structurally complete parse — while 29 SubCap title effects
|
|
1339
|
+
# occupy real record time and reach the consumer as nothing at all.
|
|
1340
|
+
# A conform that silently loses 29 titles looks exactly like a
|
|
1341
|
+
# conform that had none. Counted by operation name so it cannot.
|
|
1342
|
+
"effectsWithoutEvents": dict(sorted(state.get("effectsWithoutEvents", {}).items())),
|
|
1027
1343
|
"events": events,
|
|
1028
1344
|
}
|
|
1029
1345
|
)
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
5012
|
-
summary
|
|
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
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
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":
|
|
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": "<
|
|
23348
|
-
' {"clip_id": "<
|
|
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
|
'})'
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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",
|