Skip to content

Commit ccb5185

Browse files
committed
feat(conform): audio cross-fades authored — harvested template, ramp-verified
- Harvested a real audio Sm2TiTransition by importing an FCP7 KGAudioTransCrossFade via XMEML (Resolve stores PrettyType 'Final Cut Pro 7' for it — and renders it); bundled as templates/transition-cross-fade-r19.xml (Element-wrapped, guard test extended). - placeTransition: trackType 'audio' places the harvested cross-fade (unknown trackType still refuses); assemble passes transitions[].trackType. - eventsToAssembleSpec: audio events with dissolves author audio cross-fades under the same abut/handle geometry; drops carry trackType:'audio' + reason. - Render proof (19.1.3.7): the offline-authored crossfade's highpass-RMS RAMPS through the junction (-27.6 → -25.6 → -23.0 → -21.9), identical shape to a Resolve-authored control; a butt cut steps. - XMEML import gotcha recorded: an <audio><channelcount> block inside a FILE definition aborts the entire import silently ('created no timeline'). - 2 new bridge tests; wrapper guard covers the new template; guide table row. Suites after last edit (incl. version bump): Node 828 pass / 0 fail; Python 3101 passed + 799 subtests.
1 parent 3e8efb8 commit ccb5185

14 files changed

Lines changed: 110 additions & 13 deletions

docs/guides/native-drt-authoring.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ window. `render.verify_output` covers the container-level checks.
4242
| Media cuts, multi-source | `media: [{mediaFilePath, spec, cuts}]` | v2.106–2.107 |
4343
| Multi-track video (V2+ stacking) | `cuts[].track` (video-only above V1) | v2.112 |
4444
| Cross-dissolves | `transitions: [{track, atFrame, durationFrames}]` | v2.111 |
45+
| Audio cross-fades | `transitions[].trackType: 'audio'` | v2.116 |
4546
| Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
4647
| Constant retimes, reverse | `cuts[].reverse` | v2.114 |
4748
| Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |

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.115.1"
40+
VERSION = "2.116.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.115.1",
3+
"version": "2.116.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: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
299299
const audioTrackNum = (t) => { const m = /^A(\d+)?$/.exec(String(t)); return m && m[1] ? parseInt(m[1], 10) : 1; };
300300
const audioPlacements = [];
301301
const audioRetimesSkipped = [];
302+
const audioTransCandidates = [];
302303
for (const e of auds) {
303304
const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
304305
const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
@@ -309,7 +310,16 @@ export function eventsToAssembleSpec(events, opts = {}) {
309310
}
310311
const track = audioTrackNum(e.track);
311312
const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), audioOnly: true, track };
312-
audioPlacements.push({ start: recIn, end: recOut, index: e.index, track });
313+
if (e.transition) {
314+
let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
315+
d += d % 2;
316+
audioTransCandidates.push({
317+
atFrame: recIn, durationFrames: d, track,
318+
index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
319+
source: e.source, srcIn: cut.srcIn,
320+
});
321+
}
322+
audioPlacements.push({ start: recIn, end: recOut, index: e.index, track, source: e.source, srcIn: cut.srcIn, durationFrames });
313323
if (!perSource.has(e.source)) perSource.set(e.source, []);
314324
perSource.get(e.source).push(cut);
315325
}
@@ -384,6 +394,29 @@ export function eventsToAssembleSpec(events, opts = {}) {
384394
}
385395
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
386396
}
397+
// Audio cross-fades, same geometry rules (render-verified on 19.1.3.7 via
398+
// the harvested cross-fade template: the highpass RMS ramps, not steps).
399+
for (const c of audioTransCandidates) {
400+
const prev = audioPlacements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
401+
if (!prev) {
402+
droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio', reason: 'no abutting predecessor at the cut' });
403+
continue;
404+
}
405+
const half = c.durationFrames / 2;
406+
const bHandle = c.srcIn >= half;
407+
const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
408+
const aFrames = aSpec && Number(aSpec.frameCount);
409+
const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
410+
if (!bHandle || !aHandle) {
411+
droppedTransitions.push({
412+
index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio',
413+
reason: `insufficient handles for a centered ${c.durationFrames}f cross-fade` +
414+
`${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
415+
});
416+
continue;
417+
}
418+
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
419+
}
387420

388421
return {
389422
spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },

resolve-advanced/server/tools/drt.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const assembleSchema = z.object({
4646
spec: z
4747
.object({})
4848
.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), 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),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)."),
5050
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
5151
targetAppVersion: z
5252
.union([z.string(), z.number()])

resolve-advanced/test/events-to-assemble.test.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,36 @@ test('audio cuts beyond the template ceiling refuse at assemble time', async ()
308308
/audio track 9 exceeds the template's 8 audio tracks/,
309309
);
310310
});
311+
312+
// Audio cross-fades: same geometry rules as video dissolves; the harvested
313+
// cross-fade template renders a RAMP through the junction (verified on
314+
// 19.1.3.7 against a Resolve-authored control: -27.6 → -25.6 → -23.0 → -21.9
315+
// highpass-RMS, identical shape).
316+
test('an EDL audio dissolve with handles authors an audio cross-fade', () => {
317+
const edl = [
318+
'TITLE: AX',
319+
'FCM: NON-DROP FRAME',
320+
'001 TAPE1 A C 00:00:01:00 00:00:03:00 01:00:00:00 01:00:02:00',
321+
'002 TAPE2 A D 024 00:00:01:00 00:00:03:00 01:00:02:00 01:00:04:00',
322+
'003 TAPE1 V C 00:00:01:00 00:00:05:00 01:00:00:00 01:00:04:00',
323+
'',
324+
].join('\n');
325+
const { spec, report } = eventsToAssembleSpec(parseEDL(edl, { fps: 24 }), { sourceMap: MAP });
326+
assert.deepEqual(report.authoredTransitions, [{ track: 1, atFrame: 86448, durationFrames: 24, trackType: 'audio' }]);
327+
assert.equal(report.droppedTransitions.length, 0);
328+
assert.deepEqual(spec.transitions, report.authoredTransitions);
329+
});
330+
331+
test('an audio dissolve without an abutting predecessor drops with trackType audio', () => {
332+
const edl = [
333+
'TITLE: AX',
334+
'FCM: NON-DROP FRAME',
335+
'001 TAPE1 V C 00:00:01:00 00:00:05:00 01:00:00:00 01:00:04:00',
336+
'002 TAPE2 A D 024 00:00:01:00 00:00:03:00 01:00:02:00 01:00:04:00',
337+
'',
338+
].join('\n');
339+
const { report } = eventsToAssembleSpec(parseEDL(edl, { fps: 24 }), { sourceMap: MAP });
340+
assert.equal(report.droppedTransitions.length, 1);
341+
assert.equal(report.droppedTransitions[0].trackType, 'audio');
342+
assert.match(report.droppedTransitions[0].reason, /no abutting predecessor/);
343+
});

resolve-advanced/test/media-template-transplant.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ test('harvested r19 snippets are Element-wrapped (unwrapped clips break the trac
124124
// outright with no status (measured); the wrapper is load-bearing.
125125
const fs3 = require('node:fs');
126126
const path3 = require('node:path');
127-
for (const f of ['fusion-title-r19.xml', 'generator-solid-color-r19.xml', 'fusion-title.xml', 'generator-solid-color.xml']) {
127+
for (const f of ['fusion-title-r19.xml', 'generator-solid-color-r19.xml', 'fusion-title.xml', 'generator-solid-color.xml', 'transition-cross-fade-r19.xml']) {
128128
const s = fs3.readFileSync(
129129
path3.join(process.cwd(), 'vendor', 'drp-format', 'templates', f), 'utf8').trim();
130130
assert.ok(s.startsWith('<Element>'), `${f} must start with <Element>`);

resolve-advanced/vendor/drp-format/__tests__/place-transition.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,7 @@ test('placeTransition errors when no abutting boundary at atFrame', async () =>
4444
test('placeTransition validates args', async () => {
4545
const buf = await synth2();
4646
await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, durationFrames: 1 }), /durationFrames/);
47-
await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, trackType: 'audio' }), /only video/);
47+
// trackType 'audio' is now supported (harvested cross-fade template,
48+
// v2.116.0); an unknown trackType still refuses.
49+
await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, trackType: 'subtitle' }), /video or audio/);
4850
});

resolve-advanced/vendor/drp-format/assemble-timeline.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ async function assembleTimeline(spec = {}) {
181181
if (!tr || typeof tr !== 'object') throw new TypeError(`assembleTimeline: transitions[${i}] must be an object`);
182182
({ buffer } = await placeTransition(buffer, {
183183
track: tr.track, atFrame: tr.atFrame, durationFrames: tr.durationFrames,
184+
trackType: tr.trackType || 'video',
184185
}));
185186
}
186187

0 commit comments

Comments
 (0)