davinci-resolve-mcp 2.116.1 → 2.117.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/docs/guides/native-drt-authoring.md +1 -0
- package/docs/reference/api-limitations.md +8 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +3 -2
- package/resolve-advanced/server/author-interchange.mjs +13 -5
- package/resolve-advanced/server/tools/drt.mjs +4 -1
- package/resolve-advanced/server/tools/editorial.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +32 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +22 -0
|
@@ -47,6 +47,7 @@ window. `render.verify_output` covers the container-level checks.
|
|
|
47
47
|
| Constant retimes, reverse | `cuts[].reverse` | v2.114 |
|
|
48
48
|
| Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
|
|
49
49
|
| Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
|
|
50
|
+
| Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
|
|
50
51
|
| Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
|
|
51
52
|
|
|
52
53
|
`assemble_from_interchange` drives the same engine from an EDL / OTIO /
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 34 missing capabilities, 44 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
|
|
@@ -214,6 +214,13 @@ equivalent, blocking full automation.
|
|
|
214
214
|
- **Workaround / current handling:** Treat as irreversible within a session. server returns _ok() unconditionally because there is nothing to check.
|
|
215
215
|
- **Tags:** resolve-21, unreliable-return, irreversible, session-wide
|
|
216
216
|
|
|
217
|
+
### A timeline's start timecode lives in the pool clip's MediaExtents blob (patchable offline)
|
|
218
|
+
|
|
219
|
+
- **Object:** `Sm2MpTimelineClip.MediaExtents`
|
|
220
|
+
- **Behavior:** The start timecode of a timeline is stored in exactly one non-cosmetic place in a .drp/.drt: the media pool timeline clip's MediaExtents blob, a 16-byte pair of LE doubles [startSeconds, durationSeconds] (measured: 02:03:04:05 @24 appears only as 7384.2083 there and in a UI-state blob). Patching startSeconds offline and importing yields a timeline at the new start timecode with clips at their absolute frames, and it renders.
|
|
221
|
+
- **Workaround / current handling:** To author a non-default start TC offline, patch MediaExtents (drt.assemble spec.startFrame does this) and keep clip Start frames >= the new origin - clips before it are silently dropped on import. For conform, assemble_from_interchange preserveStartTimecode=true anchors at the turnover's real first record frame instead of 01:00:00:00.
|
|
222
|
+
- **Tags:** timecode, drt, import
|
|
223
|
+
|
|
217
224
|
### MediaPool.ImportMedia (current-folder destination only)
|
|
218
225
|
|
|
219
226
|
- **Object:** `MediaPool`
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.117.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -102,8 +102,9 @@ Each dispatches on an `action`. Highlights:
|
|
|
102
102
|
point across xml/edl/otio/drt/drp/aaf/prproj), `convert_to_interchange` (author OTIO/EDL/DRT that Resolve
|
|
103
103
|
imports, from events or a parsed source — **the .prproj→Resolve conform bridge**, no Premiere needed;
|
|
104
104
|
editorial timing/cuts/transitions carry, per-clip effects/color do not; speed/reverse carry on
|
|
105
|
-
the `otio` and `edl` targets only — `drt` has no per-clip speed field and reports
|
|
106
|
-
flattens in `flattened`
|
|
105
|
+
the `otio` and `edl` targets only — this flat `drt` target has no per-clip speed field and reports
|
|
106
|
+
every retime it flattens in `flattened`; for a `.drt` that AUTHORS retimes (plus dissolves,
|
|
107
|
+
multi-track, audio) use `drt.assemble_from_interchange` instead),
|
|
107
108
|
`turnover_changelist` (moved/retimed/replaced/new/gone + timing silent-lie guards), `conform_manifest`,
|
|
108
109
|
`marker_roundtrip`.
|
|
109
110
|
- **`provenance`** — provenance / audit: `gallery_lineage`, `grade_provenance` ("why is this graded this
|
|
@@ -220,14 +220,14 @@ export function eventsToOTIO(events, opts = {}) {
|
|
|
220
220
|
* @returns {{spec: object, report: object}}
|
|
221
221
|
*/
|
|
222
222
|
export function eventsToAssembleSpec(events, opts = {}) {
|
|
223
|
-
const { sourceMap, timelineName } = opts;
|
|
223
|
+
const { sourceMap, timelineName, preserveStartTimecode = false } = opts;
|
|
224
224
|
if (!Array.isArray(events) || !events.length) {
|
|
225
225
|
throw new TypeError('eventsToAssembleSpec: events must be a non-empty array');
|
|
226
226
|
}
|
|
227
227
|
if (!sourceMap || typeof sourceMap !== 'object') {
|
|
228
228
|
throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
|
|
229
229
|
}
|
|
230
|
-
const
|
|
230
|
+
const DEFAULT_ORIGIN = 86400;
|
|
231
231
|
const isAudio = (t) => /^A\d*$/.test(String(t || ''));
|
|
232
232
|
const vids = events.filter((e) => !isAudio(e.track) && e.recIn != null && e.recOut != null);
|
|
233
233
|
const auds = events.filter((e) => isAudio(e.track) && e.recIn != null && e.recOut != null);
|
|
@@ -252,7 +252,15 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
252
252
|
const perSource = new Map();
|
|
253
253
|
const placements = [];
|
|
254
254
|
|
|
255
|
-
|
|
255
|
+
// Default: anchor the earliest event at the template origin (86400).
|
|
256
|
+
// preserveStartTimecode keeps the interchange's ABSOLUTE record positions —
|
|
257
|
+
// the assembled timeline starts at the turnover's real first record frame
|
|
258
|
+
// (MediaExtents start-TC patch, measured: imports with the new start TC and
|
|
259
|
+
// renders). AAF conforms need this: build at THAT start, not 01:00:00:00.
|
|
260
|
+
const minRecRaw = Math.min(...vids.map((e) => toTl(e.recIn, e.fps)));
|
|
261
|
+
const minRec = preserveStartTimecode ? 0 : minRecRaw;
|
|
262
|
+
const ORIGIN = preserveStartTimecode ? 0 : DEFAULT_ORIGIN;
|
|
263
|
+
const startFrame = preserveStartTimecode ? minRecRaw : DEFAULT_ORIGIN;
|
|
256
264
|
for (const e of vids) {
|
|
257
265
|
const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
|
|
258
266
|
const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
|
|
@@ -419,7 +427,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
419
427
|
}
|
|
420
428
|
|
|
421
429
|
return {
|
|
422
|
-
spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },
|
|
430
|
+
spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(transitions.length ? { transitions } : {}) },
|
|
423
431
|
report: {
|
|
424
432
|
videoEvents: vids.length,
|
|
425
433
|
sources: media.length,
|
|
@@ -431,7 +439,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
431
439
|
authoredRetimes,
|
|
432
440
|
authoredTransitions: transitions,
|
|
433
441
|
droppedTransitions,
|
|
434
|
-
origin:
|
|
442
|
+
origin: startFrame,
|
|
435
443
|
},
|
|
436
444
|
};
|
|
437
445
|
}
|
|
@@ -41,12 +41,14 @@ const assembleFromInterchangeSchema = z.object({
|
|
|
41
41
|
outputPath: z.string().describe('Where the importable .drt is written'),
|
|
42
42
|
targetAppVersion: z.union([z.string(), z.number()]).optional()
|
|
43
43
|
.describe("Host Resolve version, e.g. '19.1' for pre-21"),
|
|
44
|
+
preserveStartTimecode: z.boolean().optional()
|
|
45
|
+
.describe('Keep the interchange\'s ABSOLUTE record start: the assembled timeline starts at the turnover\'s real first record frame instead of 01:00:00:00 (start-TC patch render-verified on 19). AAF conforms should pass true.'),
|
|
44
46
|
});
|
|
45
47
|
const assembleSchema = z.object({
|
|
46
48
|
spec: z
|
|
47
49
|
.object({})
|
|
48
50
|
.passthrough()
|
|
49
|
-
.describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
|
|
51
|
+
.describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
|
|
50
52
|
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
|
|
51
53
|
targetAppVersion: z
|
|
52
54
|
.union([z.string(), z.number()])
|
|
@@ -220,6 +222,7 @@ export const drtTool = {
|
|
|
220
222
|
if (!events || !events.length) return { error: 'no events parsed from the interchange input' };
|
|
221
223
|
const { spec, report } = eventsToAssembleSpec(events, {
|
|
222
224
|
sourceMap: p.sourceMap, timelineName: p.timelineName,
|
|
225
|
+
preserveStartTimecode: p.preserveStartTimecode,
|
|
223
226
|
});
|
|
224
227
|
if (p.targetAppVersion !== undefined) {
|
|
225
228
|
spec.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
|
|
@@ -86,7 +86,7 @@ const markerSchema = z.object({
|
|
|
86
86
|
export const editorialTool = {
|
|
87
87
|
name: 'editorial',
|
|
88
88
|
description:
|
|
89
|
-
'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 survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY —
|
|
89
|
+
'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 survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY — this FLAT drt target flattens every retime to 100% forward and returns `flattened`/`flattenedCount` naming each event that lost one (`flattened` is always present on `drt`, empty when there were none); for a .drt that AUTHORS retimes/dissolves/multi-track/audio, use drt.assemble_from_interchange), 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).',
|
|
90
90
|
async handler({ action, args }) {
|
|
91
91
|
if (action === 'parse_interchange') {
|
|
92
92
|
const p = parseSchema.parse(args);
|
|
@@ -33,6 +33,13 @@ const { placeTransition } = require('./place-transition');
|
|
|
33
33
|
|
|
34
34
|
async function assembleTimeline(spec = {}) {
|
|
35
35
|
const { timelineName, elements = [], transitions = [], media, templateVersion } = spec;
|
|
36
|
+
// Timeline start (frames @24). The start timecode lives in ONE place — the
|
|
37
|
+
// pool timeline clip's MediaExtents [startSeconds, durationSeconds]
|
|
38
|
+
// double-LE pair (measured: patching it imports with the new start TC and
|
|
39
|
+
// renders; no other non-cosmetic copy exists). Clips are absolute frames,
|
|
40
|
+
// so a custom origin just moves the guard.
|
|
41
|
+
const originFrame = spec.startFrame ?? DEFAULT_START_FRAME;
|
|
42
|
+
if (!Number.isInteger(originFrame) || originFrame < 0) throw new TypeError('assembleTimeline: spec.startFrame must be a non-negative integer');
|
|
36
43
|
if (!Array.isArray(elements)) throw new TypeError('assembleTimeline: elements must be an array');
|
|
37
44
|
if (!Array.isArray(transitions)) throw new TypeError('assembleTimeline: transitions must be an array');
|
|
38
45
|
|
|
@@ -59,9 +66,9 @@ async function assembleTimeline(spec = {}) {
|
|
|
59
66
|
const validateCuts = (src, label) => {
|
|
60
67
|
const mediaSpec = src.spec;
|
|
61
68
|
(src.cuts || []).forEach((cut, i) => {
|
|
62
|
-
if (cut.startFrame <
|
|
69
|
+
if (cut.startFrame < originFrame) {
|
|
63
70
|
throw new RangeError(
|
|
64
|
-
`assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${
|
|
71
|
+
`assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${originFrame} — Resolve silently drops it on import`,
|
|
65
72
|
);
|
|
66
73
|
}
|
|
67
74
|
if (mediaSpec && Number.isFinite(mediaSpec.frameCount) && Number.isFinite(mediaSpec.fps)) {
|
|
@@ -81,7 +88,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
81
88
|
base = await addMediaClip({
|
|
82
89
|
mediaFile: sources[0].mediaFilePath, spec: sources[0].spec, timelineName, templateVersion,
|
|
83
90
|
});
|
|
84
|
-
base.startFrame =
|
|
91
|
+
base.startFrame = originFrame;
|
|
85
92
|
|
|
86
93
|
// Transplant/insert native pool elements BEFORE cutting, so each cut can
|
|
87
94
|
// reference its source's MediaRef.
|
|
@@ -185,7 +192,28 @@ async function assembleTimeline(spec = {}) {
|
|
|
185
192
|
}));
|
|
186
193
|
}
|
|
187
194
|
|
|
188
|
-
|
|
195
|
+
if (originFrame !== DEFAULT_START_FRAME) {
|
|
196
|
+
const zipF = await JSZip.loadAsync(buffer);
|
|
197
|
+
const mpP = 'MediaPool/Master/MpFolder.xml';
|
|
198
|
+
let mpF = await zipF.file(mpP).async('string');
|
|
199
|
+
const tlBlocks = mpF.match(/<Sm2MpTimelineClip[\s\S]*?<\/Sm2MpTimelineClip>/g) || [];
|
|
200
|
+
const tlBlock = tlBlocks.find((b) => b.includes(`<Name>${tlName}</Name>`)) || tlBlocks[0];
|
|
201
|
+
if (!tlBlock) throw new Error('assembleTimeline: timeline pool clip not found for startFrame patch');
|
|
202
|
+
const meM = tlBlock.match(/<MediaExtents>([0-9a-fA-F]*)<\/MediaExtents>/);
|
|
203
|
+
if (!meM) throw new Error('assembleTimeline: timeline pool clip has no MediaExtents to patch');
|
|
204
|
+
let maxEnd = originFrame;
|
|
205
|
+
const collect = (arr) => (arr || []).forEach((x) => { const e = (x.startFrame ?? 0) + (x.durationFrames ?? 0); if (e > maxEnd) maxEnd = e; });
|
|
206
|
+
(Array.isArray(media) ? media : media ? [media] : []).forEach((src) => collect(src.cuts));
|
|
207
|
+
collect(elements);
|
|
208
|
+
const me = Buffer.alloc(16);
|
|
209
|
+
me.writeDoubleLE(originFrame / 24, 0);
|
|
210
|
+
me.writeDoubleLE((maxEnd - originFrame) / 24, 8);
|
|
211
|
+
mpF = mpF.replace(tlBlock, tlBlock.replace(meM[0], `<MediaExtents>${me.toString('hex')}</MediaExtents>`));
|
|
212
|
+
zipF.file(mpP, mpF);
|
|
213
|
+
buffer = await zipF.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { buffer, timelineName: tlName, startFrame: originFrame, mediaDescriptor: mediaDescriptorState };
|
|
189
217
|
}
|
|
190
218
|
|
|
191
219
|
module.exports = { assembleTimeline };
|
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.117.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
package/src/utils/api_truth.py
CHANGED
|
@@ -1914,6 +1914,28 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1914
1914
|
"structural readback cannot see this class.",
|
|
1915
1915
|
"tags": ["media-pool", "import", "render", "silent-failure", "readback"],
|
|
1916
1916
|
},
|
|
1917
|
+
{
|
|
1918
|
+
"symbol": "A timeline's start timecode lives in the pool clip's MediaExtents blob (patchable offline)",
|
|
1919
|
+
"object": "Sm2MpTimelineClip.MediaExtents",
|
|
1920
|
+
"reality": "The start timecode of a timeline is stored in exactly "
|
|
1921
|
+
"one non-cosmetic place in a .drp/.drt: the media pool "
|
|
1922
|
+
"timeline clip's MediaExtents blob, a 16-byte pair of "
|
|
1923
|
+
"LE doubles [startSeconds, durationSeconds] (measured: "
|
|
1924
|
+
"02:03:04:05 @24 appears only as 7384.2083 there and in "
|
|
1925
|
+
"a UI-state blob). Patching startSeconds offline and "
|
|
1926
|
+
"importing yields a timeline at the new start timecode "
|
|
1927
|
+
"with clips at their absolute frames, and it renders.",
|
|
1928
|
+
"recommended": "To author a non-default start TC offline, patch "
|
|
1929
|
+
"MediaExtents (drt.assemble spec.startFrame does "
|
|
1930
|
+
"this) and keep clip Start frames >= the new origin "
|
|
1931
|
+
"- clips before it are silently dropped on import. "
|
|
1932
|
+
"For conform, assemble_from_interchange "
|
|
1933
|
+
"preserveStartTimecode=true anchors at the "
|
|
1934
|
+
"turnover's real first record frame instead of "
|
|
1935
|
+
"01:00:00:00.",
|
|
1936
|
+
"tags": ["timecode", "drt", "import"],
|
|
1937
|
+
"submit": "missing",
|
|
1938
|
+
},
|
|
1917
1939
|
{
|
|
1918
1940
|
"symbol": "Audio tracks cannot be grown in an imported timeline; Fairlight strips live in the pool Sm2Sequence.FieldsBlob",
|
|
1919
1941
|
"object": "Sm2TiTrack (audio) / FLStudioModelBA",
|