davinci-resolve-mcp 2.111.0 → 2.112.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/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +25 -12
- package/resolve-advanced/server/editorial.mjs +7 -3
- package/resolve-advanced/server/tools/drt.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/cut-media.js +36 -15
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
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.112.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
|
@@ -238,6 +238,8 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
const toTl = (frames, fps) => Math.round((frames * 24) / Math.round(fps || 24));
|
|
241
|
+
// 'V'/'V1' → 1, 'V2' → 2, … (parsers number video tracks; EDL is single-V).
|
|
242
|
+
const trackNum = (t) => { const m = /^V(\d+)?$/.exec(String(t || 'V')); return m ? (m[1] ? parseInt(m[1], 10) : 1) : 1; };
|
|
241
243
|
const flattenedRetimes = [];
|
|
242
244
|
const droppedTransitions = [];
|
|
243
245
|
const transitionCandidates = [];
|
|
@@ -253,7 +255,8 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
253
255
|
if ((e.speed ?? 100) !== 100 || e.reverse) {
|
|
254
256
|
flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
|
|
255
257
|
}
|
|
256
|
-
const
|
|
258
|
+
const vTrack = trackNum(e.track);
|
|
259
|
+
const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), ...(vTrack > 1 ? { track: vTrack } : {}) };
|
|
257
260
|
if (e.transition) {
|
|
258
261
|
// A dissolve INTO this event, at its record-in boundary. Whether it can
|
|
259
262
|
// be authored (abutting predecessor + handles both sides) is decided
|
|
@@ -261,24 +264,33 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
261
264
|
let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
|
|
262
265
|
d += d % 2; // placeTransition centers on the cut; keep it even
|
|
263
266
|
transitionCandidates.push({
|
|
264
|
-
atFrame: recIn, durationFrames: d,
|
|
267
|
+
atFrame: recIn, durationFrames: d, track: vTrack,
|
|
265
268
|
index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
|
|
266
269
|
source: e.source, srcIn: cut.srcIn,
|
|
267
270
|
});
|
|
268
271
|
}
|
|
269
|
-
placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames });
|
|
272
|
+
placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames, track: vTrack });
|
|
270
273
|
if (!perSource.has(e.source)) perSource.set(e.source, []);
|
|
271
274
|
perSource.get(e.source).push(cut);
|
|
272
275
|
}
|
|
273
276
|
|
|
277
|
+
// Overlap is judged PER VIDEO TRACK — V2 stacking over V1 is legitimate
|
|
278
|
+
// conform geometry (render-verified: an upper-track clip covers the lower).
|
|
274
279
|
placements.sort((a, b) => a.start - b.start);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
280
|
+
const byTrack = new Map();
|
|
281
|
+
for (const pl of placements) {
|
|
282
|
+
if (!byTrack.has(pl.track)) byTrack.set(pl.track, []);
|
|
283
|
+
byTrack.get(pl.track).push(pl);
|
|
284
|
+
}
|
|
285
|
+
for (const [trk, pls] of byTrack) {
|
|
286
|
+
for (let i = 1; i < pls.length; i += 1) {
|
|
287
|
+
if (pls[i].start < pls[i - 1].end) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`eventsToAssembleSpec: events ${pls[i - 1].index} and ${pls[i].index} ` +
|
|
290
|
+
`overlap on video track ${trk} after frame conversion — one track cannot hold both. ` +
|
|
291
|
+
'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
282
294
|
}
|
|
283
295
|
}
|
|
284
296
|
|
|
@@ -297,7 +309,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
297
309
|
// with the reason.
|
|
298
310
|
const transitions = [];
|
|
299
311
|
for (const c of transitionCandidates) {
|
|
300
|
-
const prev = placements.find((pl) => pl.end === c.atFrame);
|
|
312
|
+
const prev = placements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
|
|
301
313
|
if (!prev) {
|
|
302
314
|
droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, reason: 'no abutting predecessor at the cut' });
|
|
303
315
|
continue;
|
|
@@ -315,7 +327,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
315
327
|
});
|
|
316
328
|
continue;
|
|
317
329
|
}
|
|
318
|
-
transitions.push({ track:
|
|
330
|
+
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
|
|
319
331
|
}
|
|
320
332
|
|
|
321
333
|
return {
|
|
@@ -324,6 +336,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
324
336
|
videoEvents: vids.length,
|
|
325
337
|
sources: media.length,
|
|
326
338
|
audioEventsSkipped: audioSkipped,
|
|
339
|
+
upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
|
|
327
340
|
flattenedRetimes,
|
|
328
341
|
authoredTransitions: transitions,
|
|
329
342
|
droppedTransitions,
|
|
@@ -116,8 +116,12 @@ export function parseOTIO(otio, opts = {}) {
|
|
|
116
116
|
const tracks = (doc.tracks && doc.tracks.children) || [];
|
|
117
117
|
const events = [];
|
|
118
118
|
let idx = 1;
|
|
119
|
+
let vNum = 0;
|
|
119
120
|
for (const track of tracks) {
|
|
120
|
-
const
|
|
121
|
+
const isAudio = track.kind === 'Audio';
|
|
122
|
+
if (!isAudio) vNum += 1;
|
|
123
|
+
// First video track stays 'V' (compat); higher tracks are 'V2', 'V3', …
|
|
124
|
+
const kind = isAudio ? 'A' : (vNum === 1 ? 'V' : `V${vNum}`);
|
|
121
125
|
let rec = 0;
|
|
122
126
|
for (const child of track.children || []) {
|
|
123
127
|
const schema = child.OTIO_SCHEMA || '';
|
|
@@ -199,7 +203,7 @@ export function parseXMEMLEvents(xml, opts = {}) {
|
|
|
199
203
|
const media = seq && seq.media;
|
|
200
204
|
if (media) {
|
|
201
205
|
const vtracks = media.video && media.video.track ? (Array.isArray(media.video.track) ? media.video.track : [media.video.track]) : [];
|
|
202
|
-
|
|
206
|
+
vtracks.forEach((t, vi) => { if (t.clipitem) walk(t.clipitem, vi === 0 ? 'V' : `V${vi + 1}`); });
|
|
203
207
|
const atracks = media.audio && media.audio.track ? (Array.isArray(media.audio.track) ? media.audio.track : [media.audio.track]) : [];
|
|
204
208
|
for (const t of atracks) if (t.clipitem) walk(t.clipitem, 'A');
|
|
205
209
|
}
|
|
@@ -320,7 +324,7 @@ export function timingGuards(oldEvents, newEvents) {
|
|
|
320
324
|
const ne = matches[0];
|
|
321
325
|
if (!ne) {
|
|
322
326
|
// A dropped audio event where its video sibling survives → dropped J/L-cut audio.
|
|
323
|
-
if (oe.track === 'A' && newEvents.some((x) => x.track
|
|
327
|
+
if (oe.track === 'A' && newEvents.some((x) => x.track !== 'A' && x.source === oe.source))
|
|
324
328
|
flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
|
|
325
329
|
continue;
|
|
326
330
|
}
|
|
@@ -46,7 +46,7 @@ const assembleSchema = z.object({
|
|
|
46
46
|
spec: z
|
|
47
47
|
.object({})
|
|
48
48
|
.passthrough()
|
|
49
|
-
.describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
|
|
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)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
|
|
50
50
|
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
|
|
51
51
|
targetAppVersion: z
|
|
52
52
|
.union([z.string(), z.number()])
|
|
@@ -24,16 +24,20 @@ const {
|
|
|
24
24
|
freshDbIds,
|
|
25
25
|
getTrackVec,
|
|
26
26
|
replaceTrackVec,
|
|
27
|
+
emptyTrackClone,
|
|
27
28
|
} = require('./seq-surgery');
|
|
28
29
|
const { clipDbId, setClipStart, setClipDuration, setClipIn } = require('./splice-clips');
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* @param {Buffer|string} drpInput
|
|
32
33
|
* @param {object} opts
|
|
33
|
-
* @param {Array<{startFrame:number, durationFrames:number, srcIn?:number}>} opts.cuts
|
|
34
|
+
* @param {Array<{startFrame:number, durationFrames:number, srcIn?:number, track?:number}>} opts.cuts
|
|
34
35
|
* Timeline placements. startFrame is timeline-absolute (origin 86400 on the
|
|
35
36
|
* bundled templates — clips before the origin are dropped by Resolve on
|
|
36
37
|
* import, silently). srcIn is the source in-point in TIMELINE frames.
|
|
38
|
+
* track is the 1-based VIDEO track (default 1); missing tracks are grown as
|
|
39
|
+
* empty clones. Cuts on track > 1 are placed VIDEO-ONLY — their audio would
|
|
40
|
+
* overlap the track-1 cuts' audio on A1 (the template has a single A1).
|
|
37
41
|
* @param {string} [opts.timelineUuid]
|
|
38
42
|
* @returns {Promise<{buffer: Buffer, cutCount: number, clipDbIds: string[]}>}
|
|
39
43
|
*/
|
|
@@ -52,6 +56,9 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
52
56
|
if (cut.mediaRef !== undefined && !/^[0-9a-f-]{36}$/.test(cut.mediaRef)) {
|
|
53
57
|
throw new TypeError(`cutSourceIntoClips: cuts[${i}].mediaRef must be a uuid`);
|
|
54
58
|
}
|
|
59
|
+
if (cut.track !== undefined && (!Number.isInteger(cut.track) || cut.track < 1)) {
|
|
60
|
+
throw new TypeError(`cutSourceIntoClips: cuts[${i}].track must be a positive integer`);
|
|
61
|
+
}
|
|
55
62
|
});
|
|
56
63
|
|
|
57
64
|
const zip = await loadDrpZip(drpInput);
|
|
@@ -59,6 +66,19 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
59
66
|
let xml = seqXml;
|
|
60
67
|
const clipDbIds = [];
|
|
61
68
|
|
|
69
|
+
const cloneCut = (donor, cut) => {
|
|
70
|
+
let c = freshDbIds(donor);
|
|
71
|
+
c = setClipStart(c, cut.startFrame);
|
|
72
|
+
c = setClipDuration(c, cut.durationFrames);
|
|
73
|
+
c = setClipIn(c, cut.srcIn ?? 0);
|
|
74
|
+
if (cut.mediaRef) {
|
|
75
|
+
// Multi-source: point this cut at ITS source's transplanted pool
|
|
76
|
+
// element instead of the donor's.
|
|
77
|
+
c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
|
|
78
|
+
}
|
|
79
|
+
return c;
|
|
80
|
+
};
|
|
81
|
+
|
|
62
82
|
for (const trackType of ['video', 'audio']) {
|
|
63
83
|
const { match, tracks } = getTrackVec(xml, trackType);
|
|
64
84
|
if (!tracks.length) continue;
|
|
@@ -66,22 +86,23 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
66
86
|
const clips = splitClipElements(items);
|
|
67
87
|
if (!clips.length) continue; // audio-less media: nothing to cut on A1
|
|
68
88
|
const donor = clips[0];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
if (trackType === 'audio') {
|
|
90
|
+
// A1 mirrors track-1 video cuts only; higher video tracks stay video-only.
|
|
91
|
+
const a1 = cuts.filter((cut) => (cut.track ?? 1) === 1).map((cut) => cloneCut(donor, cut));
|
|
92
|
+
tracks[0] = setItemsInner(tracks[0], a1.join(''));
|
|
93
|
+
xml = replaceTrackVec(xml, trackType, match, tracks);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const maxTrack = Math.max(1, ...cuts.map((cut) => cut.track ?? 1));
|
|
97
|
+
const cloneSource = tracks[0];
|
|
98
|
+
while (tracks.length < maxTrack) tracks.push(emptyTrackClone(cloneSource));
|
|
99
|
+
for (let t = 1; t <= tracks.length; t += 1) {
|
|
100
|
+
const mine = cuts.filter((cut) => (cut.track ?? 1) === t);
|
|
101
|
+
if (t > 1 && !mine.length) continue; // grown-empty or untouched track keeps its items
|
|
102
|
+
const clones = mine.map((cut) => cloneCut(donor, cut));
|
|
82
103
|
for (const c of clones) clipDbIds.push(clipDbId(c));
|
|
104
|
+
tracks[t - 1] = setItemsInner(tracks[t - 1], clones.join(''));
|
|
83
105
|
}
|
|
84
|
-
tracks[0] = setItemsInner(tracks[0], clones.join(''));
|
|
85
106
|
xml = replaceTrackVec(xml, trackType, match, tracks);
|
|
86
107
|
}
|
|
87
108
|
|
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.112.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()}")
|