davinci-resolve-mcp 2.127.0 → 2.128.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 +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +1 -1
- package/resolve-advanced/server/tools/drt.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +18 -0
- package/resolve-advanced/vendor/drp-format/place-subtitles.js +128 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +6 -1
|
@@ -51,6 +51,7 @@ window. `render.verify_output` covers the container-level checks.
|
|
|
51
51
|
| Timeline markers | `spec.markers` (16 colors, notes, durations, customData) | v2.118 |
|
|
52
52
|
| Turnover markers | EDL `* LOC:` locators + OTIO markers → authored | v2.119 |
|
|
53
53
|
| TC-bearing sources (AAF route) | native clip capture (`MediaStartTime`), channel-leg merge | v2.120–2.122 |
|
|
54
|
+
| Subtitles | `spec.subtitles` / `spec.subtitlesSrt` (raw SRT) | v2.128 |
|
|
54
55
|
| Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
|
|
55
56
|
|
|
56
57
|
`assemble_from_interchange` drives the same engine from an EDL / OTIO /
|
|
@@ -557,7 +557,7 @@ values, or automation-hostile modal prompts.
|
|
|
557
557
|
### MediaTimemapBA keyframes are generation-split; 19.x silently ignores the R21 protobuf form
|
|
558
558
|
|
|
559
559
|
- **Object:** `Sm2TimeMap (per-clip retime blob)`
|
|
560
|
-
- **Behavior:** Resolve 21 encodes a retimed clip's KeyframesBA as protobuf points; Resolve 19.1.3 encodes it as a keyed-dict of keyed-dict keyframes ({interp, YOut, YIn, Y, XOut, XIn, X}). On import, 19 SILENTLY IGNORES the protobuf form — the clip reads back and plays at 100% with no warning (measured: identical timelines, one per form; protobuf → source 0..96 over 96 frames, keyed → source 0..48 over 96 frames and a live 50% render). The map spans the WHOLE source stretched by 1/speed; the clip's <In>/<Duration> window into it in RECORD frames (srcIn converts by /speed). REVERSE is the same envelope with the Y endpoints swapped - kf0=(0,YMax), kf1=(XMax,0) - and In then measures from the source END: (frames - srcIn - dur*speed)/speed (measured: a reversed srcIn-24 dur-48 cut reads back source 71->23). A FLAT map (both keyframes at the same Y - a freeze) is the one shape where readback and render DIVERGE: the item reads back frozen (source 96..96) but renders MOVING (48/48 unique frames measured). Do not author freezes as flat timemaps.
|
|
560
|
+
- **Behavior:** Resolve 21 encodes a retimed clip's KeyframesBA as protobuf points; Resolve 19.1.3 encodes it as a keyed-dict of keyed-dict keyframes ({interp, YOut, YIn, Y, XOut, XIn, X}). On import, 19 SILENTLY IGNORES the protobuf form — the clip reads back and plays at 100% with no warning (measured: identical timelines, one per form; protobuf → source 0..96 over 96 frames, keyed → source 0..48 over 96 frames and a live 50% render). The map spans the WHOLE source stretched by 1/speed; the clip's <In>/<Duration> window into it in RECORD frames (srcIn converts by /speed). REVERSE is the same envelope with the Y endpoints swapped - kf0=(0,YMax), kf1=(XMax,0) - and In then measures from the source END: (frames - srcIn - dur*speed)/speed (measured: a reversed srcIn-24 dur-48 cut reads back source 71->23). A FLAT map (both keyframes at the same Y - a freeze) is the one shape where readback and render DIVERGE: the item reads back frozen (source 96..96) but renders MOVING (48/48 unique frames measured). Do not author freezes as flat timemaps. AUDIO clips ignore the timemap entirely: a 50% keyed map on an imported audio clip reads back retimed (source 0..48 over 96 record frames) but RENDERS at 100% - unchanged pitch and spectrum (highpass/lowpass split identical to the 1x reference).
|
|
561
561
|
- **Workaround / current handling:** Author retimes for pre-21 hosts with the keyed form (drt.assemble cuts[].speed does this; encoder byte-exact against a live 19.1.3.7 harvest). Treat any cross-generation timemap as unverified until a readback shows the retimed source range.
|
|
562
562
|
- **Tags:** retime, import, silent-failure, drt
|
|
563
563
|
|
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.128.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
|
@@ -329,7 +329,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
329
329
|
const durationFrames = recOut - recIn;
|
|
330
330
|
if (durationFrames <= 0) continue;
|
|
331
331
|
if ((e.speed ?? 100) !== 100 || e.reverse) {
|
|
332
|
-
audioRetimesSkipped.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'audio retime not
|
|
332
|
+
audioRetimesSkipped.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'audio retime not authorable — the audio engine ignores the clip timemap (measured: reads back retimed, renders 100%); played at 100%' });
|
|
333
333
|
}
|
|
334
334
|
const track = audioTrackNum(e.track);
|
|
335
335
|
const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), audioOnly: true, track };
|
|
@@ -50,7 +50,7 @@ const assembleSchema = z.object({
|
|
|
50
50
|
spec: z
|
|
51
51
|
.object({})
|
|
52
52
|
.passthrough()
|
|
53
|
-
.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)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-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)."),
|
|
53
|
+
.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)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), 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)."),
|
|
54
54
|
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
|
|
55
55
|
targetAppVersion: z
|
|
56
56
|
.union([z.string(), z.number()])
|
|
@@ -27,6 +27,7 @@ const JSZip = require('jszip');
|
|
|
27
27
|
const { cutSourceIntoClips } = require('./cut-media');
|
|
28
28
|
const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
|
|
29
29
|
const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
|
|
30
|
+
const { placeSubtitles, parseSrt } = require('./place-subtitles');
|
|
30
31
|
const { randomUUID } = require('node:crypto');
|
|
31
32
|
const { placeFusionTitle } = require('./place-fusion-title');
|
|
32
33
|
const { placeGenerator } = require('./place-generator');
|
|
@@ -198,6 +199,23 @@ async function assembleTimeline(spec = {}) {
|
|
|
198
199
|
}));
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
// Subtitles: plain Sm2TiGenerator items (text in <Name>, no blobs — the
|
|
203
|
+
// cache law does not apply; the payload is API-visible after import).
|
|
204
|
+
// spec.subtitles frames are timeline-ABSOLUTE; spec.subtitlesSrt is raw SRT
|
|
205
|
+
// text whose cues anchor at the origin.
|
|
206
|
+
let subtitles = Array.isArray(spec.subtitles) ? [...spec.subtitles] : [];
|
|
207
|
+
if (typeof spec.subtitlesSrt === 'string' && spec.subtitlesSrt.length) {
|
|
208
|
+
subtitles.push(...parseSrt(spec.subtitlesSrt, 24).map((c) => ({ ...c, startFrame: c.startFrame + originFrame })));
|
|
209
|
+
}
|
|
210
|
+
if (subtitles.length) {
|
|
211
|
+
for (const sub of subtitles) {
|
|
212
|
+
if (!Number.isInteger(sub.startFrame) || sub.startFrame < originFrame) {
|
|
213
|
+
throw new RangeError(`assembleTimeline: subtitle at frame ${sub.startFrame} is before the timeline origin ${originFrame}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
({ buffer } = await placeSubtitles(buffer, { subtitles }));
|
|
217
|
+
}
|
|
218
|
+
|
|
201
219
|
if (Array.isArray(spec.markers) && spec.markers.length) {
|
|
202
220
|
// Timeline markers ride in project.xml as a Sm2SequenceLockableBlob whose
|
|
203
221
|
// BlobOwner is the timeline's Sm2Sequence DbId (the uuid every track's
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* place-subtitles — author SUBTITLE items into a timeline inside a real .drp,
|
|
3
|
+
* offline.
|
|
4
|
+
*
|
|
5
|
+
* Ground truth (harvested live from Studio 19.1.3.7, an SRT appended via the
|
|
6
|
+
* scripting API and re-exported): a subtitle is the SIMPLEST timeline item in
|
|
7
|
+
* the schema — a plain `<Sm2TiGenerator>` with `<PrettyType>Subtitle` and the
|
|
8
|
+
* CUE TEXT in `<Name>`, all blob fields empty, sitting in a `<Sm2TiTrack>` of
|
|
9
|
+
* `<Type>2</Type>` inside `<SubtitleTrackVec>`. No Fusion comp, so the
|
|
10
|
+
* byte-keyed comp-cache law does not apply; the text is API-visible after
|
|
11
|
+
* import (readback IS meaningful here — the payload is the Name).
|
|
12
|
+
*
|
|
13
|
+
* The r19 template ships `<SubtitleTrackVec/>` self-closed; this module
|
|
14
|
+
* synthesizes the vec + track from the harvested shape (track FieldsBlob is
|
|
15
|
+
* the same keyed NumLayers=0 dict every track carries) and reuses the
|
|
16
|
+
* timeline's shared <Sequence> uuid.
|
|
17
|
+
*
|
|
18
|
+
* Caveat (measured): Resolve treats angle-bracket runs in cue text as SRT
|
|
19
|
+
* formatting markup — an unknown tag like <escaped> is STRIPPED from the
|
|
20
|
+
* displayed/readback name. That is standard subtitle semantics, not a loss
|
|
21
|
+
* in this writer (the XML itself carries the text escaped and intact).
|
|
22
|
+
*
|
|
23
|
+
* @module drp-format/place-subtitles
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('node:crypto');
|
|
27
|
+
const { escapeXml } = require('./xml-builder');
|
|
28
|
+
const { loadDrpZip, selectTargetSeq } = require('./seq-surgery');
|
|
29
|
+
|
|
30
|
+
// Harvested track FieldsBlob: keyed dict { NumLayers: int32 0 } — identical
|
|
31
|
+
// across video/audio/subtitle tracks.
|
|
32
|
+
const TRACK_FIELDS_BLOB = '000000010000000100000012004e0075006d004c00610079006500720073000000020000000000';
|
|
33
|
+
|
|
34
|
+
function subtitleElement({ startFrame, durationFrames, text }) {
|
|
35
|
+
return (
|
|
36
|
+
`<Element>\n <Sm2TiGenerator DbId="${crypto.randomUUID()}">\n` +
|
|
37
|
+
' <FieldsBlob/>\n' +
|
|
38
|
+
' <PrettyType>Subtitle</PrettyType>\n' +
|
|
39
|
+
` <Name>${escapeXml(text)}</Name>\n` +
|
|
40
|
+
` <Start>${startFrame}</Start>\n` +
|
|
41
|
+
` <Duration>${durationFrames}</Duration>\n` +
|
|
42
|
+
' <LinkedItemSync/>\n' +
|
|
43
|
+
' <WasDisbanded>false</WasDisbanded>\n' +
|
|
44
|
+
' <MarkersBA/>\n' +
|
|
45
|
+
' <UiMemento>0</UiMemento>\n' +
|
|
46
|
+
' <Flags>0</Flags>\n' +
|
|
47
|
+
' <PriorityIndex>0</PriorityIndex>\n' +
|
|
48
|
+
' <EffectFiltersBA/>\n' +
|
|
49
|
+
' <ImportExportMetadataBA/>\n' +
|
|
50
|
+
' <RenderTextEnabled>true</RenderTextEnabled>\n' +
|
|
51
|
+
' <RenderTextGanged>true</RenderTextGanged>\n' +
|
|
52
|
+
' <RenderTextPrefixed>true</RenderTextPrefixed>\n' +
|
|
53
|
+
' <In/>\n' +
|
|
54
|
+
' </Sm2TiGenerator>\n </Element>'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Place subtitle cues on the (single) subtitle track of a timeline in a .drp.
|
|
60
|
+
* Overlapping cues are refused — one track cannot hold both.
|
|
61
|
+
*
|
|
62
|
+
* @param {Buffer|string} drpInput
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {Array<{startFrame:number,durationFrames:number,text:string}>} opts.subtitles
|
|
65
|
+
* startFrame is timeline-ABSOLUTE (origin 86400 on the bundled templates).
|
|
66
|
+
* @param {string} [opts.timelineUuid]
|
|
67
|
+
* @returns {Promise<{buffer:Buffer, entry:string, timelineUuid:string, count:number}>}
|
|
68
|
+
*/
|
|
69
|
+
async function placeSubtitles(drpInput, opts = {}) {
|
|
70
|
+
const { subtitles, timelineUuid } = opts;
|
|
71
|
+
if (!Array.isArray(subtitles) || !subtitles.length) throw new TypeError('placeSubtitles: subtitles must be a non-empty array');
|
|
72
|
+
const sorted = [...subtitles].sort((a, b) => a.startFrame - b.startFrame);
|
|
73
|
+
sorted.forEach((sub, i) => {
|
|
74
|
+
if (!Number.isInteger(sub.startFrame) || !Number.isInteger(sub.durationFrames) || sub.durationFrames <= 0) {
|
|
75
|
+
throw new TypeError(`placeSubtitles: subtitles[${i}] needs integer startFrame and positive durationFrames`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof sub.text !== 'string' || !sub.text.length) throw new TypeError(`placeSubtitles: subtitles[${i}].text must be a non-empty string`);
|
|
78
|
+
if (i > 0 && sub.startFrame < sorted[i - 1].startFrame + sorted[i - 1].durationFrames) {
|
|
79
|
+
throw new RangeError(`placeSubtitles: cues ${i - 1} and ${i} overlap — one subtitle track cannot hold both`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const zip = await loadDrpZip(drpInput);
|
|
84
|
+
const { entry, xml: seqXml, seqId } = await selectTargetSeq(zip, timelineUuid);
|
|
85
|
+
const seqUuidM = seqXml.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/);
|
|
86
|
+
if (!seqUuidM) throw new Error('placeSubtitles: cannot find the shared <Sequence> uuid');
|
|
87
|
+
const items = sorted.map(subtitleElement).join('\n ');
|
|
88
|
+
const track =
|
|
89
|
+
`\n <Element>\n <Sm2TiTrack DbId="${crypto.randomUUID()}">\n` +
|
|
90
|
+
` <FieldsBlob>${TRACK_FIELDS_BLOB}</FieldsBlob>\n` +
|
|
91
|
+
' <Type>2</Type>\n <SubType>0</SubType>\n <Flags>0</Flags>\n' +
|
|
92
|
+
` <Sequence>${seqUuidM[1]}</Sequence>\n` +
|
|
93
|
+
` <Items>\n ${items}\n </Items>\n` +
|
|
94
|
+
' <UserDefinedName/>\n <LayersVec/>\n </Sm2TiTrack>\n </Element>\n ';
|
|
95
|
+
let xml;
|
|
96
|
+
if (/<SubtitleTrackVec\/>/.test(seqXml)) {
|
|
97
|
+
xml = seqXml.replace('<SubtitleTrackVec/>', `<SubtitleTrackVec>${track}</SubtitleTrackVec>`);
|
|
98
|
+
} else if (/<SubtitleTrackVec>/.test(seqXml)) {
|
|
99
|
+
xml = seqXml.replace('</SubtitleTrackVec>', `${track}</SubtitleTrackVec>`);
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error('placeSubtitles: timeline has no SubtitleTrackVec');
|
|
102
|
+
}
|
|
103
|
+
zip.file(entry, xml);
|
|
104
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
105
|
+
return { buffer, entry, timelineUuid: seqId, count: sorted.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parse an SRT string → cues in frames at fps (timeline-RELATIVE, add the origin). */
|
|
109
|
+
function parseSrt(srt, fps = 24) {
|
|
110
|
+
const cues = [];
|
|
111
|
+
const tc = (s) => {
|
|
112
|
+
const m = /(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/.exec(s);
|
|
113
|
+
return Math.round(((+m[1] * 3600 + +m[2] * 60 + +m[3]) + +m[4] / 1000) * fps);
|
|
114
|
+
};
|
|
115
|
+
for (const block of String(srt).replace(/\r/g, '').split(/\n\n+/)) {
|
|
116
|
+
const lines = block.split('\n').filter((l) => l.trim().length);
|
|
117
|
+
const ti = lines.findIndex((l) => /-->/.test(l));
|
|
118
|
+
if (ti < 0) continue;
|
|
119
|
+
const [a, b] = lines[ti].split('-->');
|
|
120
|
+
const text = lines.slice(ti + 1).join('\n').trim();
|
|
121
|
+
if (!text) continue;
|
|
122
|
+
const start = tc(a), end = tc(b);
|
|
123
|
+
if (end > start) cues.push({ startFrame: start, durationFrames: end - start, text });
|
|
124
|
+
}
|
|
125
|
+
return cues;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { placeSubtitles, parseSrt };
|
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.128.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
|
@@ -2033,7 +2033,12 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
2033
2033
|
"the one shape where readback and render DIVERGE: the "
|
|
2034
2034
|
"item reads back frozen (source 96..96) but renders "
|
|
2035
2035
|
"MOVING (48/48 unique frames measured). Do not author "
|
|
2036
|
-
"freezes as flat timemaps."
|
|
2036
|
+
"freezes as flat timemaps. AUDIO clips ignore the "
|
|
2037
|
+
"timemap entirely: a 50% keyed map on an imported audio "
|
|
2038
|
+
"clip reads back retimed (source 0..48 over 96 record "
|
|
2039
|
+
"frames) but RENDERS at 100% - unchanged pitch and "
|
|
2040
|
+
"spectrum (highpass/lowpass split identical to the 1x "
|
|
2041
|
+
"reference).",
|
|
2037
2042
|
"recommended": "Author retimes for pre-21 hosts with the keyed "
|
|
2038
2043
|
"form (drt.assemble cuts[].speed does this; encoder "
|
|
2039
2044
|
"byte-exact against a live 19.1.3.7 harvest). Treat "
|