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