Skip to content

Commit 438f7f5

Browse files
committed
feat(conform): the conform emulator — interchange in, rendering .drt out
drt.assemble_from_interchange: EDL/OTIO/XML/AAF + a sourceMap → parse → frame-convert → multi-source assemble with native-descriptor transplant → stamped importable .drt, one call. eventsToAssembleSpec anchors the earliest video event at the timeline origin, converts nominal-base event frames to the 24fps template timeline (butt cuts stay gapless through the conversion, verified at 29.97), groups cuts per source, refuses overlaps and unmapped reels loudly, and ships an honesty ledger: flattened retimes, transitions-as-cuts, skipped audio events. Full-route live proof on Studio 19.1.3.7: a three-event EDL cutting between two sources (with an M2 retime) assembled, imported 6/6 linked with exact source in-points, rendered each event's OWN pixels (YAVG 125.6 / 234 / 125.5), full-range render at duration ratio 1.0, retime in the ledger. Scoped honestly: elements on pre-21 hosts are not render-verified — the R21 snippets render black on 19 and the r19 harvest is incomplete (the generator's Sm2TiCompositionTable dependency, the title's per-generation comp-blob layout); with the partial harvest render jobs fail outright, which is worse. Snippet selection stays R21, drt.assemble warns on pre-21 element use, and the harvested r19 snippets ship for the element-transplant expedition.
1 parent 3e72529 commit 438f7f5

16 files changed

Lines changed: 403 additions & 15 deletions

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@
22

33
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.
44

5+
## What's New in v2.108.0
6+
7+
**The conform emulator, coast to coast.** An interchange file goes in; an
8+
importable, RENDERING native .drt comes out — one call.
9+
10+
### Added
11+
12+
- **`drt.assemble_from_interchange`**: EDL/OTIO/XML/AAF + a sourceMap
13+
(reel → {mediaFilePath, spec}) → parse → frame-convert → multi-source
14+
assemble with native-descriptor transplant → stamped .drt. The
15+
events→spec bridge (`eventsToAssembleSpec`) anchors the earliest video
16+
event at the timeline origin, converts nominal-base event frames to the
17+
24fps template timeline (round(frames × 24 / nominalFps) — butt cuts stay
18+
gapless through the conversion, verified at 29.97), groups cuts per
19+
source, refuses overlapping record ranges and unmapped reels loudly, and
20+
returns an honesty ledger: flattened retimes (the clip schema has no
21+
per-clip speed), transitions treated as cuts, audio events skipped (cuts
22+
carry their own linked A1).
23+
24+
Live-proofed on Studio 19.1.3.7 with the full route: a three-event EDL
25+
cutting between two sources (with an M2 retime line) assembled, imported
26+
6/6 linked with exact source in-points, and rendered each event's OWN
27+
pixels — YAVG 125.6 / 234 / 125.5 — with a full-range render verifying at
28+
duration ratio 1.0 and the retime present in the ledger.
29+
30+
### Scoped honestly
31+
32+
Title/generator elements on a pre-21 host are NOT render-verified: the
33+
harvested snippets are Resolve-21 structures that import and read back
34+
correctly but render black on 19.1.3 (measured), and the r19 snippet
35+
harvest is incomplete (the generator's Sm2TiCompositionTable dependency,
36+
the title's per-generation comp-blob layout) — with the partial harvest,
37+
render jobs fail outright, which is worse. Snippet selection stays on the
38+
R21 structures, `drt.assemble` warns when elements target a pre-21 host,
39+
and the harvested r19 snippets ship in the templates directory for the
40+
element-transplant expedition. Media cuts render everywhere the transplant
41+
path covers.
42+
543
## What's New in v2.107.0
644

745
**Multi-source media authoring.** `drt.assemble`'s media support grows from

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
English | [简体中文](README.zh-CN.md)
44

5-
[![Version](https://img.shields.io/badge/version-2.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5+
[![Version](https://img.shields.io/badge/version-2.108.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
66
[![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
77
[![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
88
[![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)

README.zh-CN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[English](README.md) | 简体中文
44

5-
[![Version](https://img.shields.io/badge/version-2.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5+
[![Version](https://img.shields.io/badge/version-2.108.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
66
[![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
77
[![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
88
[![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
1212
[![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
1313
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
1414

15-
> 本翻译对应 v2.107.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15+
> 本翻译对应 v2.108.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
1616
1717
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
1818

install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
# ─── Version ──────────────────────────────────────────────────────────────────
3939

40-
VERSION = "2.107.0"
40+
VERSION = "2.108.0"
4141
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
4242
# Resolve's scripting bridge loads into newer interpreters on recent builds
4343
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "davinci-resolve-mcp",
3-
"version": "2.107.0",
3+
"version": "2.108.0",
44
"description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
55
"license": "MIT",
66
"author": "Samuel Gursky <samgursky@gmail.com>",

resolve-advanced/server/author-interchange.mjs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,103 @@ export function eventsToOTIO(events, opts = {}) {
192192
}
193193

194194
/** Build a CMX3600 EDL string (cuts + M2 speed). Video events only, per EDL convention. */
195+
/**
196+
* eventsToAssembleSpec — normalized interchange events → drt.assemble media spec.
197+
*
198+
* The coast-to-coast bridge: parseInterchange's events (EDL/AAF/OTIO/XML)
199+
* become an importable, RENDERING native .drt via assembleTimeline's
200+
* transplant path. Frame math: event frames are NOMINAL-base at the event's
201+
* fps (v2.104.6 convention, measured against Resolve); the assemble template
202+
* timeline runs 24fps with origin 86400, so rec/src frames convert as
203+
* round(frames × 24 / nominalFps). Placement anchors the EARLIEST video
204+
* event at the origin. Honesty ledger in the returned report: flattened
205+
* retimes (the template clip schema has no per-clip speed), dropped
206+
* transitions (treated as cuts at their boundary), and skipped audio events
207+
* (cuts carry linked A1 audio from their own source already).
208+
*
209+
* @param {Array} events - normalized events (parseInterchange shape)
210+
* @param {object} opts
211+
* @param {Object<string,{mediaFilePath:string, spec:object}>} opts.sourceMap -
212+
* event.source (reel) → media file + spec. Every VIDEO event's source must
213+
* be mapped; unmapped reels refuse with the reel names listed.
214+
* @param {string} [opts.timelineName]
215+
* @returns {{spec: object, report: object}}
216+
*/
217+
export function eventsToAssembleSpec(events, opts = {}) {
218+
const { sourceMap, timelineName } = opts;
219+
if (!Array.isArray(events) || !events.length) {
220+
throw new TypeError('eventsToAssembleSpec: events must be a non-empty array');
221+
}
222+
if (!sourceMap || typeof sourceMap !== 'object') {
223+
throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
224+
}
225+
const ORIGIN = 86400;
226+
const vids = events.filter((e) => e.track !== 'A' && e.recIn != null && e.recOut != null);
227+
const audioSkipped = events.length - vids.length;
228+
if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
229+
230+
const unmapped = [...new Set(vids.map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
231+
if (unmapped.length) {
232+
throw new Error(
233+
`eventsToAssembleSpec: unmapped source reel(s): ${unmapped.join(', ')} — ` +
234+
'every video event needs a sourceMap entry {mediaFilePath, spec}',
235+
);
236+
}
237+
238+
const toTl = (frames, fps) => Math.round((frames * 24) / Math.round(fps || 24));
239+
const flattenedRetimes = [];
240+
const droppedTransitions = [];
241+
const perSource = new Map();
242+
const placements = [];
243+
244+
const minRec = Math.min(...vids.map((e) => toTl(e.recIn, e.fps)));
245+
for (const e of vids) {
246+
const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
247+
const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
248+
const durationFrames = recOut - recIn;
249+
if (durationFrames <= 0) continue;
250+
if ((e.speed ?? 100) !== 100 || e.reverse) {
251+
flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
252+
}
253+
if (e.transition) {
254+
droppedTransitions.push({ index: e.index, type: e.transition.type, duration: e.transition.duration });
255+
}
256+
const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps) };
257+
placements.push({ start: recIn, end: recOut, index: e.index });
258+
if (!perSource.has(e.source)) perSource.set(e.source, []);
259+
perSource.get(e.source).push(cut);
260+
}
261+
262+
placements.sort((a, b) => a.start - b.start);
263+
for (let i = 1; i < placements.length; i += 1) {
264+
if (placements[i].start < placements[i - 1].end) {
265+
throw new Error(
266+
`eventsToAssembleSpec: events ${placements[i - 1].index} and ${placements[i].index} ` +
267+
'overlap on the record track after frame conversion — a single V1 cannot hold both. ' +
268+
'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
269+
);
270+
}
271+
}
272+
273+
const media = [...perSource.entries()].map(([reel, cuts]) => ({
274+
mediaFilePath: sourceMap[reel].mediaFilePath,
275+
spec: sourceMap[reel].spec,
276+
cuts,
277+
}));
278+
279+
return {
280+
spec: { timelineName, media },
281+
report: {
282+
videoEvents: vids.length,
283+
sources: media.length,
284+
audioEventsSkipped: audioSkipped,
285+
flattenedRetimes,
286+
droppedTransitions,
287+
origin: ORIGIN,
288+
},
289+
};
290+
}
291+
195292
export function eventsToEDL(events, opts = {}) {
196293
const fps = opts.fps || events.find((e) => e.fps)?.fps || 24;
197294
const vids = events.filter((e) => e.track !== 'A').sort((a, b) => (a.recIn ?? 0) - (b.recIn ?? 0));

resolve-advanced/server/tools/drt.mjs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ const authorSchema = z.object({
2929
outputPath: z.string().describe('Absolute path where the .drt will be written'),
3030
});
3131
const validateSchema = z.object({ drtPath: z.string().describe('Absolute path to a .drt file') });
32+
const assembleFromInterchangeSchema = z.object({
33+
format: z.enum(['edl', 'otio', 'xml', 'aaf']).describe('Interchange format of the input'),
34+
path: z.string().optional().describe('Path to the interchange file (aaf REQUIRES a path)'),
35+
content: z.string().optional().describe('Inline interchange text (edl/otio/xml)'),
36+
fps: z.number().optional().describe('Event frame rate for parsing (default 24; use e.g. 29.97 for NTSC EDLs)'),
37+
sourceMap: z
38+
.record(z.object({ mediaFilePath: z.string(), spec: z.object({}).passthrough() }))
39+
.describe('reel/source name → {mediaFilePath, spec:{width,height,frameCount,fps}}; every video event must map'),
40+
timelineName: z.string().optional(),
41+
outputPath: z.string().describe('Where the importable .drt is written'),
42+
targetAppVersion: z.union([z.string(), z.number()]).optional()
43+
.describe("Host Resolve version, e.g. '19.1' for pre-21"),
44+
});
3245
const assembleSchema = z.object({
3346
spec: z
3447
.object({})
@@ -135,7 +148,7 @@ function requirePathArg(args, key, action) {
135148
export const drtTool = {
136149
name: 'drt',
137150
description:
138-
'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
151+
'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes flatten, transitions become cuts, ledger in `conform`), assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
139152
async handler({ action, args }) {
140153
if (action === 'parse') {
141154
const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
@@ -190,6 +203,52 @@ export const drtTool = {
190203
await fs.writeFile(p.outputPath, outBuf);
191204
return { outputPath: p.outputPath, bytes: outBuf.length, seqContainersInjected: injected, projectFolder };
192205
}
206+
if (action === 'assemble_from_interchange') {
207+
// Coast-to-coast conform: interchange in, importable RENDERING .drt out.
208+
const p = assembleFromInterchangeSchema.parse(args);
209+
const { parseInterchange } = await import('../editorial.mjs');
210+
const { eventsToAssembleSpec } = await import('../author-interchange.mjs');
211+
let content = p.content;
212+
if (p.format === 'aaf') {
213+
if (!p.path) return { error: 'aaf input requires path' };
214+
content = p.path;
215+
} else if (!content) {
216+
if (!p.path) return { error: 'provide content or path' };
217+
content = await fs.readFile(p.path, 'utf8');
218+
}
219+
const events = parseInterchange(p.format, content, { fps: p.fps ?? 24 });
220+
if (!events || !events.length) return { error: 'no events parsed from the interchange input' };
221+
const { spec, report } = eventsToAssembleSpec(events, {
222+
sourceMap: p.sourceMap, timelineName: p.timelineName,
223+
});
224+
if (p.targetAppVersion !== undefined) {
225+
spec.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
226+
}
227+
const { assembleTimeline } = drp();
228+
const { buffer, timelineName, mediaDescriptor } = await assembleTimeline(spec);
229+
let outBuf = buffer;
230+
let stamped = null;
231+
if (p.targetAppVersion !== undefined) {
232+
const targetPV = resolveTargetProjectVersion({ targetAppVersion: p.targetAppVersion });
233+
const appVer = `${p.targetAppVersion}${'.0'.repeat(Math.max(0, 4 - String(p.targetAppVersion).split('.').length))}`;
234+
const zip = await JSZip.loadAsync(buffer);
235+
const { out } = await applyVersionStamps(zip, targetPV, appVer);
236+
outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
237+
stamped = { targetProjectVersion: targetPV };
238+
}
239+
await fs.writeFile(p.outputPath, outBuf);
240+
return {
241+
outputPath: p.outputPath,
242+
bytes: outBuf.length,
243+
timelineName,
244+
mediaDescriptor,
245+
stamped,
246+
conform: report,
247+
note:
248+
'Import with timeline.import_timeline_checked (timeline is named after the FILE). ' +
249+
'Retimes are flattened and transitions become cuts — see `conform` for the ledger.',
250+
};
251+
}
193252
if (action === 'assemble') {
194253
const p = assembleSchema.parse(args);
195254
// Native-schema authoring: template-spliced real Resolve structures
@@ -225,6 +284,14 @@ export const drtTool = {
225284
stamped,
226285
templateVersion: spec.templateVersion ?? 21,
227286
mediaDescriptor: mediaDescriptor ?? 'none',
287+
...((spec.elements || []).length && (spec.templateVersion ?? 21) < 21
288+
? {
289+
elementsWarning:
290+
'title/generator elements on a pre-21 host are NOT render-verified: the harvested ' +
291+
'snippets are Resolve-21 structures, which import and read back correctly but render ' +
292+
'black on 19.1.3 (measured). Media cuts render; verify element output before delivery.',
293+
}
294+
: {}),
228295
...(mediaDescriptor === 'repoint-fallback'
229296
? {
230297
warning:

0 commit comments

Comments
 (0)