davinci-resolve-mcp 2.78.1 → 2.79.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.
@@ -6,6 +6,51 @@
6
6
  * NON-NEGOTIABLE #5: keep <in> and <pproTicksIn> CONSISTENT (Resolve reads the
7
7
  * ticks). We always emit ticks = sourceFrame * ticksPerFrame so a re-import
8
8
  * reproduces the same source frame. Round-trippable through parse/xmeml-geometry.
9
+ *
10
+ * Two importer contracts measured against Resolve 19.1.3, both of which fail SILENTLY
11
+ * — a clean-looking XML and a Resolve that does nothing:
12
+ *
13
+ * 1. <pathurl> must be percent-encoded PER SEGMENT. XML-escaping (&amp; &lt; &gt;) is
14
+ * NOT URL-escaping; the two are different contracts and this file previously
15
+ * applied only the first. Encoding the whole path in one pass would escape the '/'
16
+ * separators too, so each segment is encoded and the separators are rejoined.
17
+ * The Python side of this repo already got this right — see
18
+ * src/utils/timeline_xml.py _disk_to_pathurl, which uses urllib.parse.quote
19
+ * (safe='/' by default, i.e. the same per-segment behaviour).
20
+ *
21
+ * What that actually costs you, measured on 19.1.3 one character at a time, each
22
+ * as a raw pathurl vs the same path encoded, in a fresh project with an empty pool:
23
+ *
24
+ * char raw encoded
25
+ * space links links
26
+ * # links links
27
+ * % NO TIMELINE links
28
+ * & links links
29
+ * + links links
30
+ * ? links links
31
+ *
32
+ * So a raw space is survivable on this build — but a literal '%' is not: it makes
33
+ * an invalid percent-escape, and Resolve rejects THE WHOLE IMPORT over it, not the
34
+ * one clip, with no error. Encoding is what Resolve's own FCP7 exporter emits
35
+ * (it writes Media%20Drive), it fixed the fatal case, and it broke none of the
36
+ * other five — so encode, and do not rely on which characters happen to be
37
+ * tolerated by the build in front of you.
38
+ *
39
+ * 2. The SEQUENCE needs its own <rate>. Without it Resolve creates no timeline at all —
40
+ * not a partial one, not an offline one, nothing — however well-formed the clipitems
41
+ * are. Measured by bisecting this emitter's output against Resolve's own FCP7 export
42
+ * of the same cut: adding the sequence <rate> alone flipped the import from "no
43
+ * timeline" to 2 items / 2 linked, and it was the ONLY element that did. <duration>
44
+ * alone did not. The clipitem-level and file-level <duration>/<rate>/<enabled> that
45
+ * Resolve's exporter writes turned out not to be required.
46
+ *
47
+ * 3. Every file def needs a <timecode> block MATCHING the media's embedded timecode.
48
+ * Absent, zero, and wrong all silently reject THE ENTIRE IMPORT — not the one
49
+ * clip. Every property you would naturally suspect (durations, pathurl form,
50
+ * track count) is innocent. Because a wrong block is as fatal as no block, this
51
+ * emitter never guesses one: it writes the block when the clip carries its media's
52
+ * timecode and reports the clips that do not via fcp7TimecodeCoverage(), rather
53
+ * than emitting a plausible 00:00:00:00 that kills the import.
9
54
  */
10
55
 
11
56
  const TPF_24 = 10584000000;
@@ -14,6 +59,104 @@ function esc(s) {
14
59
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15
60
  }
16
61
 
62
+ /**
63
+ * A local path → a Premiere-style file URL, percent-encoded PER SEGMENT so the '/'
64
+ * separators survive. `encodeURIComponent` on the whole path would turn them into %2F.
65
+ */
66
+ function pathToUrl(p) {
67
+ if (!p) return '';
68
+ return `file://localhost${String(p).split('/').map(encodeURIComponent).join('/')}`;
69
+ }
70
+
71
+ /** Frames dropped per drop-frame minute: 2 at 29.97, 4 at 59.94. */
72
+ const dropPerMinute = (fps) => Math.round(Number(fps) / 30) * 2;
73
+
74
+ /** "HH:MM:SS:FF" (or ";FF" for drop-frame) → absolute frame number. */
75
+ function tcToFrames(tc, fps, drop) {
76
+ const m = String(tc).trim().match(/^(\d+):(\d{1,2}):(\d{1,2})[:;](\d{1,2})$/);
77
+ if (!m) return null;
78
+ const [hh, mm, ss, ff] = m.slice(1).map(Number);
79
+ const base = Math.max(1, Math.round(Number(fps) || 24));
80
+ const isDrop = drop != null ? Boolean(drop) : String(tc).includes(';');
81
+ let frames = ((hh * 3600 + mm * 60 + ss) * base) + ff;
82
+ if (isDrop) {
83
+ const totalMinutes = hh * 60 + mm;
84
+ frames -= dropPerMinute(fps) * (totalMinutes - Math.floor(totalMinutes / 10));
85
+ }
86
+ return frames;
87
+ }
88
+
89
+ /** Absolute frame number → "HH:MM:SS:FF" (";FF" when drop-frame). */
90
+ function framesToTc(frame, fps, drop) {
91
+ const base = Math.max(1, Math.round(Number(fps) || 24));
92
+ const f = Math.max(0, Math.round(Number(frame) || 0));
93
+ const p2 = (n) => String(n).padStart(2, '0');
94
+ let minutes;
95
+ let rem;
96
+ if (drop) {
97
+ // Drop-frame counts SKIP labels, so the label is recovered by walking ten-minute
98
+ // blocks: the first minute of each block drops nothing, the other nine drop `d`.
99
+ const d = dropPerMinute(fps);
100
+ const perMin = base * 60 - d;
101
+ const perTenMin = perMin * 9 + base * 60;
102
+ minutes = Math.floor(f / perTenMin) * 10;
103
+ rem = f % perTenMin;
104
+ if (rem >= base * 60) {
105
+ rem -= base * 60;
106
+ minutes += 1 + Math.floor(rem / perMin);
107
+ rem = (rem % perMin) + d;
108
+ }
109
+ } else {
110
+ minutes = Math.floor(f / (base * 60));
111
+ rem = f % (base * 60);
112
+ }
113
+ const sep = drop ? ';' : ':';
114
+ return `${p2(Math.floor(minutes / 60))}:${p2(minutes % 60)}:${p2(Math.floor(rem / base))}${sep}${p2(rem % base)}`;
115
+ }
116
+
117
+ /**
118
+ * The media's embedded start timecode for a clip, as {frame, string, fps, drop} — or
119
+ * null when the clip does not carry it. Accepts either a timecode string (`startTc`)
120
+ * or an absolute frame (`startTcFrame`); when both are present the frame wins and the
121
+ * string is re-derived from it, so the two halves of the emitted block cannot disagree.
122
+ */
123
+ function clipTimecode(c, seqFps) {
124
+ const fps = Number(c.startTcFps || c.srcFps || seqFps || 24);
125
+ const drop = Boolean(c.startTcDrop);
126
+ let frame = c.startTcFrame != null ? Math.round(Number(c.startTcFrame)) : null;
127
+ if (frame == null && c.startTc != null) frame = tcToFrames(c.startTc, fps, c.startTcDrop);
128
+ if (frame == null || !Number.isFinite(frame)) return null;
129
+ return { frame, string: framesToTc(frame, fps, drop), fps, drop };
130
+ }
131
+
132
+ /**
133
+ * Which clips will emit a <timecode> block and which will not. A clip missing its
134
+ * media's timecode is not a cosmetic gap: Resolve rejects the WHOLE import over a
135
+ * single absent block, so `missing` being non-empty means this XML will import nothing.
136
+ */
137
+ function fcp7TimecodeCoverage(conformed) {
138
+ const seqFps = (conformed.sequence || {}).fps || 24;
139
+ const missing = [];
140
+ let withTimecode = 0;
141
+ (conformed.clips || []).forEach((c, i) => {
142
+ if (clipTimecode(c, seqFps)) withTimecode += 1;
143
+ else missing.push({ index: i, name: c.source_basename || '', path: c.path || '' });
144
+ });
145
+ return {
146
+ total: (conformed.clips || []).length,
147
+ withTimecode,
148
+ missing,
149
+ importable: missing.length === 0,
150
+ };
151
+ }
152
+
153
+ function timecodeBlock(tc, indent) {
154
+ return `\n${indent}<timecode><string>${tc.string}</string><frame>${tc.frame}</frame>`
155
+ + `<displayformat>${tc.drop ? 'DF' : 'NDF'}</displayformat>`
156
+ + `<rate><timebase>${Math.round(tc.fps)}</timebase><ntsc>${Number.isInteger(Number(tc.fps)) ? 'FALSE' : 'TRUE'}</ntsc></rate>`
157
+ + `</timecode>`;
158
+ }
159
+
17
160
  function toFcp7Xml(conformed, opts = {}) {
18
161
  const seq = conformed.sequence || {};
19
162
  const tpf = conformed.ticksPerFrame || opts.ticksPerFrame || TPF_24;
@@ -28,7 +171,8 @@ function toFcp7Xml(conformed, opts = {}) {
28
171
  const ticksIn = inFrame * tpf; // CONSISTENT with <in>
29
172
  const ticksOut = (inFrame + dur) * tpf;
30
173
  const fileId = `file-${i + 1}`;
31
- const url = c.path ? `file://localhost${esc(c.path)}` : '';
174
+ const url = pathToUrl(c.path);
175
+ const tc = clipTimecode(c, fps);
32
176
  return ` <clipitem id="clipitem-${i + 1}">
33
177
  <name>${esc(c.source_basename || `clip${c.seqstart}`)}</name>
34
178
  <start>${c.seqstart}</start>
@@ -37,7 +181,7 @@ function toFcp7Xml(conformed, opts = {}) {
37
181
  <out>${inFrame + dur}</out>
38
182
  <pproTicksIn>${ticksIn}</pproTicksIn>
39
183
  <pproTicksOut>${ticksOut}</pproTicksOut>
40
- <file id="${fileId}"><name>${esc(c.source_basename || '')}</name><pathurl>${url}</pathurl>
184
+ <file id="${fileId}"><name>${esc(c.source_basename || '')}</name><pathurl>${url}</pathurl>${tc ? timecodeBlock(tc, ' ') : ''}
41
185
  <media><video><samplecharacteristics><width>${c.srcW || W}</width><height>${c.srcH || H}</height></samplecharacteristics></video></media>
42
186
  </file>
43
187
  <filter><effect><name>Basic Motion</name><effectid>basic</effectid>
@@ -47,10 +191,18 @@ function toFcp7Xml(conformed, opts = {}) {
47
191
  })
48
192
  .join('\n');
49
193
 
194
+ const seqDur = conformed.clips.reduce((m, c) => Math.max(m, c.seqend || c.seqstart || 0), 0);
195
+ const seqTc = clipTimecode({
196
+ startTc: seq.startTc, startTcFrame: seq.startTcFrame,
197
+ startTcFps: seq.startTcFps || fps, startTcDrop: seq.startTcDrop,
198
+ }, fps);
199
+
50
200
  return `<?xml version="1.0" encoding="UTF-8"?>
51
201
  <xmeml version="4">
52
202
  <sequence id="seq-conform">
53
203
  <name>${esc(seq.name || 'conform')}</name>
204
+ <duration>${seqDur}</duration>
205
+ <rate><timebase>${Math.round(fps)}</timebase><ntsc>${Number.isInteger(Number(fps)) ? 'FALSE' : 'TRUE'}</ntsc></rate>${seqTc ? timecodeBlock(seqTc, ' ') : ''}
54
206
  <media><video>
55
207
  <format><samplecharacteristics><rate><timebase>${fps}</timebase><ntsc>FALSE</ntsc></rate><width>${W}</width><height>${H}</height></samplecharacteristics></format>
56
208
  <track>
@@ -61,4 +213,4 @@ ${clipitems}
61
213
  </xmeml>`;
62
214
  }
63
215
 
64
- module.exports = { toFcp7Xml, TPF_24 };
216
+ module.exports = { toFcp7Xml, fcp7TimecodeCoverage, pathToUrl, tcToFrames, framesToTc, TPF_24 };
@@ -13,7 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { toOtio } = require('./otio');
16
- const { toFcp7Xml } = require('./emit-fcp7');
16
+ const { toFcp7Xml, fcp7TimecodeCoverage } = require('./emit-fcp7');
17
17
  const { toAafModel } = require('./emit-aaf');
18
18
 
19
19
  const MEDIA_MODES = Object.freeze(['relink', 'full', 'consolidate']);
@@ -108,6 +108,9 @@ function buildPackage(conformed, opts = {}) {
108
108
  for (const f of formats) {
109
109
  if (f === 'otio') files.otio = toOtio(conformed);
110
110
  else if (f === 'fcp7Xml') files.fcp7Xml = toFcp7Xml(conformed, opts);
111
+ // A file def with no <timecode> makes Resolve reject THE WHOLE import, silently, so
112
+ // the coverage report rides with the package rather than being something the caller
113
+ // has to know to ask for. importable:false means this XML will import nothing.
111
114
  else if (f === 'aaf') files.aaf = toAafModel(conformed);
112
115
  else if (f === 'drp') files.drp = { blocked: true, reason: 'DRP authoring requires Resolve (Tier 3)' };
113
116
  else throw new Error(`package: unknown format "${f}"`);
@@ -115,6 +118,7 @@ function buildPackage(conformed, opts = {}) {
115
118
  const manifest = buildManifest(conformed, opts);
116
119
  const v2FlagTrack = opts.v2Flags || [];
117
120
  const pkg = { mediaMode, formats, files, media: buildMedia(conformed, mediaMode), v2FlagTrack, manifest };
121
+ if (files.fcp7Xml) pkg.fcp7Timecode = fcp7TimecodeCoverage(conformed);
118
122
  pkg.record = makeConformPackage({ mediaMode, formats, manifest, v2FlagCount: v2FlagTrack.length, version: opts.version, createdAt: opts.createdAt });
119
123
  return pkg;
120
124
  }
@@ -11,15 +11,21 @@ const path = require('node:path');
11
11
  const pkgRoot = require('../index');
12
12
  const P = require('../packaging');
13
13
  const { toOtio, readOtioSourceFrames } = require('../packaging/otio');
14
- const { toFcp7Xml } = require('../packaging/emit-fcp7');
14
+ const { toFcp7Xml, fcp7TimecodeCoverage, pathToUrl, tcToFrames, framesToTc } = require('../packaging/emit-fcp7');
15
15
  const { toAafModel, readAafSourceFrames } = require('../packaging/emit-aaf');
16
16
  const { parseGeometry } = require('../parse');
17
17
  const oracle = require('../oracle');
18
18
 
19
- const GOLDEN = JSON.parse(fs.readFileSync(path.join(pkgRoot.reelFixtureDir(), 'golden_oracle.json'), 'utf8'));
19
+ // The golden-answer reel is git-ignored client material (see index.js reelFixtureDir),
20
+ // so on a fresh clone it is absent and the tests that need it SKIP. Loading it at module
21
+ // scope made the whole file fail to load instead — which took the fixture-free tests
22
+ // below down with it, and those are exactly the ones that pin the importer contracts.
23
+ const GOLDEN_PATH = path.join(pkgRoot.reelFixtureDir(), 'golden_oracle.json');
24
+ const GOLDEN = fs.existsSync(GOLDEN_PATH) ? JSON.parse(fs.readFileSync(GOLDEN_PATH, 'utf8')) : null;
25
+ const needsGolden = { skip: GOLDEN ? false : 'golden reel fixture not present' };
20
26
 
21
27
  // A conformed timeline: each clip resolved to its source frame (= expected_source_start).
22
- const CONFORMED = {
28
+ const CONFORMED = GOLDEN && {
23
29
  sequence: GOLDEN.sequence,
24
30
  ticksPerFrame: GOLDEN.ticksPerFrame,
25
31
  clips: GOLDEN.clips.map((c) => ({
@@ -35,16 +41,16 @@ const CONFORMED = {
35
41
  path: `/vol/online/${c.source_basename}`,
36
42
  })),
37
43
  };
38
- const CTX = { ticksPerFrame: GOLDEN.ticksPerFrame, sequenceWidth: GOLDEN.sequence.width };
44
+ const CTX = GOLDEN && { ticksPerFrame: GOLDEN.ticksPerFrame, sequenceWidth: GOLDEN.sequence.width };
39
45
 
40
- test('package: OTIO canonical round-trips conformed source frames (all 327)', () => {
46
+ test('package: OTIO canonical round-trips conformed source frames (all 327)', needsGolden, () => {
41
47
  const otio = toOtio(CONFORMED);
42
48
  const back = readOtioSourceFrames(otio);
43
49
  assert.equal(back.length, 327);
44
50
  for (let i = 0; i < back.length; i++) assert.equal(back[i].sourceFrame, CONFORMED.clips[i].sourceFrame);
45
51
  });
46
52
 
47
- test('package: FCP7 XML emit round-trips through the parser (in+ticks consistent, all 327)', () => {
53
+ test('package: FCP7 XML emit round-trips through the parser (in+ticks consistent, all 327)', needsGolden, () => {
48
54
  const xml = toFcp7Xml(CONFORMED);
49
55
  const parsed = parseGeometry(xml);
50
56
  const bySeq = new Map(parsed.clips.map((c) => [c.seqstart, c]));
@@ -60,13 +66,13 @@ test('package: FCP7 XML emit round-trips through the parser (in+ticks consistent
60
66
  assert.equal(checked, 327);
61
67
  });
62
68
 
63
- test('package: AAF model round-trips source frames (binary serialization deferred)', () => {
69
+ test('package: AAF model round-trips source frames (binary serialization deferred)', needsGolden, () => {
64
70
  const back = readAafSourceFrames(toAafModel(CONFORMED));
65
71
  assert.equal(back.length, 327);
66
72
  assert.equal(back[0].sourceFrame, CONFORMED.clips[0].sourceFrame);
67
73
  });
68
74
 
69
- test('package: builder matrix — subset of formats + relink media mode', () => {
75
+ test('package: builder matrix — subset of formats + relink media mode', needsGolden, () => {
70
76
  const pkg = P.buildPackage(CONFORMED, { mediaMode: 'relink', formats: ['otio', 'fcp7Xml', 'aaf'] });
71
77
  assert.ok(pkg.files.otio && pkg.files.fcp7Xml && pkg.files.aaf);
72
78
  assert.equal(pkg.media.mode, 'relink');
@@ -74,12 +80,12 @@ test('package: builder matrix — subset of formats + relink media mode', () =>
74
80
  assert.ok(pkg.media.references.length === 327);
75
81
  });
76
82
 
77
- test('package: full/consolidate media modes are BLOCKED (require volumes)', () => {
83
+ test('package: full/consolidate media modes are BLOCKED (require volumes)', needsGolden, () => {
78
84
  assert.equal(P.buildPackage(CONFORMED, { mediaMode: 'full' }).media.blocked, true);
79
85
  assert.equal(P.buildPackage(CONFORMED, { mediaMode: 'consolidate' }).media.blocked, true);
80
86
  });
81
87
 
82
- test('package: V2 flag track + provenance manifest + ConformPackage record', () => {
88
+ test('package: V2 flag track + provenance manifest + ConformPackage record', needsGolden, () => {
83
89
  const v2Flags = [{ cutId: 'seq2983', klass: 'wrong-source', note: 'flagged' }];
84
90
  const repairByCut = { seq192: { strategy: 'add-subclip-startoffset', confidence: 1 } };
85
91
  const pkg = P.buildPackage(CONFORMED, { formats: ['otio'], v2Flags, repairByCut });
@@ -91,7 +97,7 @@ test('package: V2 flag track + provenance manifest + ConformPackage record', ()
91
97
  assert.equal(pkg.record.v2FlagCount, 1);
92
98
  });
93
99
 
94
- test('package: gated auto-apply applies only auto-apply fixes + records undo', () => {
100
+ test('package: gated auto-apply applies only auto-apply fixes + records undo', needsGolden, () => {
95
101
  const small = { sequence: GOLDEN.sequence, ticksPerFrame: GOLDEN.ticksPerFrame, clips: [
96
102
  { cutId: 'a', seqstart: 0, sourceFrame: 100, scale: 175 },
97
103
  { cutId: 'b', seqstart: 48, sourceFrame: 200, scale: 175 },
@@ -108,7 +114,7 @@ test('package: gated auto-apply applies only auto-apply fixes + records undo', (
108
114
  assert.equal(out.deferred[0].cutId, 'b');
109
115
  });
110
116
 
111
- test('package: delivery to a local folder writes the files', () => {
117
+ test('package: delivery to a local folder writes the files', needsGolden, () => {
112
118
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cqc-pkg-'));
113
119
  try {
114
120
  const pkg = P.buildPackage(CONFORMED, { formats: ['otio', 'fcp7Xml'], v2Flags: [{ cutId: 'x' }] });
@@ -122,7 +128,7 @@ test('package: delivery to a local folder writes the files', () => {
122
128
  }
123
129
  });
124
130
 
125
- test('package: delivery to Download Center enqueues keys via injected sink (mock R2)', async () => {
131
+ test('package: delivery to Download Center enqueues keys via injected sink (mock R2)', needsGolden, async () => {
126
132
  const uploaded = {};
127
133
  const enqueue = async (key, content) => { uploaded[key] = content; };
128
134
  const pkg = P.buildPackage(CONFORMED, { formats: ['otio'] });
@@ -131,3 +137,136 @@ test('package: delivery to Download Center enqueues keys via injected sink (mock
131
137
  assert.ok(keys.includes('conform/manifest.json'));
132
138
  assert.equal(Object.keys(uploaded).length, keys.length);
133
139
  });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Resolve 19.1.3 importer contracts. Both failures are SILENT — a clean-looking
143
+ // XML and a Resolve that does nothing — so they are pinned here rather than left
144
+ // to be rediscovered. Fixture-free on purpose: these must run on a fresh clone.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ // One clip whose media path has a space in two different segments, carrying an
148
+ // embedded start timecode of 01:00:00:00 @ 24.
149
+ const TC_CONFORMED = {
150
+ sequence: { name: 'tc', width: 1920, height: 1080, fps: 24 },
151
+ ticksPerFrame: 10584000000,
152
+ clips: [{
153
+ cutId: 'a',
154
+ seqstart: 0,
155
+ seqend: 48,
156
+ sourceFrame: 86400,
157
+ source_basename: 'take 01.mov',
158
+ path: '/Volumes/Media Drive/A Roll/take 01.mov',
159
+ startTc: '01:00:00:00',
160
+ startTcFps: 24,
161
+ }],
162
+ };
163
+
164
+ test('U7: pathurl is percent-encoded PER SEGMENT — %20 for spaces, / separators intact', () => {
165
+ // Measured on 19.1.3: a raw space survives, but a raw '%' is an invalid escape and
166
+ // kills THE WHOLE IMPORT. Encode everything rather than betting on the tolerated set.
167
+ assert.equal(
168
+ pathToUrl('/Volumes/Media Drive/A Roll/take 01.mov'),
169
+ 'file://localhost/Volumes/Media%20Drive/A%20Roll/take%2001.mov',
170
+ );
171
+ // Encoding the path in ONE pass would have produced %2F separators — the bug that
172
+ // makes "just percent-encode it" fail differently instead of working.
173
+ assert.equal(pathToUrl('/a b/c d').includes('%2F'), false);
174
+ // The fatal one: a literal '%' must become %25, or the URL carries an invalid escape
175
+ // and Resolve creates no timeline at all.
176
+ assert.equal(pathToUrl('/vol/take%03.mov'), 'file://localhost/vol/take%2503.mov');
177
+
178
+ const xml = toFcp7Xml(TC_CONFORMED);
179
+ const url = xml.match(/<pathurl>([^<]*)<\/pathurl>/)[1];
180
+ assert.equal(url, 'file://localhost/Volumes/Media%20Drive/A%20Roll/take%2001.mov');
181
+ assert.equal(/<pathurl>[^<]* /.test(xml), false, 'no raw space survives inside a pathurl');
182
+ });
183
+
184
+ test('U8: every file def carries a <timecode> block MATCHING the media embedded TC', () => {
185
+ // Absent, zero, and wrong all silently reject THE ENTIRE IMPORT (measured, 19.1.3).
186
+ const xml = toFcp7Xml(TC_CONFORMED);
187
+ const block = xml.match(/<timecode>[\s\S]*?<\/timecode>/);
188
+ assert.ok(block, 'a file def with known media TC emits a <timecode> block');
189
+ assert.match(block[0], /<string>01:00:00:00<\/string>/);
190
+ assert.match(block[0], /<frame>86400<\/frame>/); // 01:00:00:00 @ 24
191
+ assert.match(block[0], /<displayformat>NDF<\/displayformat>/);
192
+ assert.match(block[0], /<timebase>24<\/timebase>/);
193
+ // It lives INSIDE the <file> def, not at sequence level.
194
+ assert.match(xml, /<file id="file-1">[\s\S]*?<timecode>[\s\S]*?<\/timecode>[\s\S]*?<\/file>/);
195
+ // Not the zero block that reads as present and imports as nothing.
196
+ assert.equal(/<string>00:00:00:00<\/string>/.test(xml), false);
197
+ });
198
+
199
+ test('U8: startTcFrame and startTc agree; the frame wins and the string is re-derived', () => {
200
+ const byFrame = toFcp7Xml({ ...TC_CONFORMED, clips: [{ ...TC_CONFORMED.clips[0], startTc: undefined, startTcFrame: 86400 }] });
201
+ assert.match(byFrame, /<string>01:00:00:00<\/string>/);
202
+ assert.match(byFrame, /<frame>86400<\/frame>/);
203
+ // A disagreeing pair cannot emit a half-right block: the frame is authoritative.
204
+ const both = toFcp7Xml({ ...TC_CONFORMED, clips: [{ ...TC_CONFORMED.clips[0], startTc: '05:00:00:00', startTcFrame: 86400 }] });
205
+ assert.match(both, /<string>01:00:00:00<\/string>/);
206
+ });
207
+
208
+ test('U8: drop-frame media emits a DF block with the drop-frame frame count', () => {
209
+ const df = toFcp7Xml({
210
+ ...TC_CONFORMED,
211
+ clips: [{ ...TC_CONFORMED.clips[0], startTc: '01:00:00;00', startTcFps: 29.97, startTcDrop: true }],
212
+ });
213
+ assert.match(df, /<string>01:00:00;00<\/string>/);
214
+ assert.match(df, /<frame>107892<\/frame>/); // DF, not the 108000 an NDF count would give
215
+ assert.match(df, /<displayformat>DF<\/displayformat>/);
216
+ assert.match(df, /<ntsc>TRUE<\/ntsc>/);
217
+ });
218
+
219
+ test('U8: a clip with NO media timecode emits no block and is REPORTED, not guessed', () => {
220
+ // A wrong block is exactly as fatal as no block, so the emitter never invents one —
221
+ // it says which clips it could not write, and that the XML will import nothing.
222
+ const noTc = { ...TC_CONFORMED, clips: [{ ...TC_CONFORMED.clips[0], startTc: undefined, startTcFps: undefined }] };
223
+ const xml = toFcp7Xml(noTc);
224
+ assert.equal(/<timecode>/.test(xml), false);
225
+ const cov = fcp7TimecodeCoverage(noTc);
226
+ assert.equal(cov.total, 1);
227
+ assert.equal(cov.withTimecode, 0);
228
+ assert.equal(cov.importable, false);
229
+ assert.equal(cov.missing[0].name, 'take 01.mov');
230
+
231
+ const ok = fcp7TimecodeCoverage(TC_CONFORMED);
232
+ assert.equal(ok.withTimecode, 1);
233
+ assert.equal(ok.importable, true);
234
+ assert.deepEqual(ok.missing, []);
235
+ });
236
+
237
+ test('U8: buildPackage carries the timecode coverage alongside the fcp7 XML', () => {
238
+ const pkg = P.buildPackage(TC_CONFORMED, { formats: ['fcp7Xml'] });
239
+ assert.equal(pkg.fcp7Timecode.importable, true);
240
+ assert.equal(pkg.fcp7Timecode.withTimecode, 1);
241
+ // Not attached when no fcp7 XML was asked for.
242
+ assert.equal(P.buildPackage(TC_CONFORMED, { formats: ['otio'] }).fcp7Timecode, undefined);
243
+ });
244
+
245
+ test('U8: timecode <-> frame conversion is exact, non-drop and drop', () => {
246
+ for (const [tc, fps] of [['01:00:00:00', 24], ['00:59:50:00', 24], ['10:00:00:00', 25], ['23:59:59:23', 24]]) {
247
+ assert.equal(framesToTc(tcToFrames(tc, fps, false), fps, false), tc, `${tc} @${fps}`);
248
+ }
249
+ assert.equal(tcToFrames('01:00:00;00', 29.97, true), 107892);
250
+ assert.equal(tcToFrames('00:10:00;00', 29.97, true), 17982);
251
+ assert.equal(tcToFrames('00:01:00;02', 29.97, true), 1800);
252
+ for (const f of [0, 1800, 17981, 17982, 107892]) {
253
+ assert.equal(tcToFrames(framesToTc(f, 29.97, true), 29.97, true), f, `DF frame ${f}`);
254
+ }
255
+ });
256
+
257
+ test('emit: the SEQUENCE carries its own <rate> — without it Resolve creates no timeline', () => {
258
+ // Bisected against Resolve's own FCP7 export on 19.1.3: the sequence <rate> was the
259
+ // single element that flipped this emitter's output from "no timeline" to 2 items /
260
+ // 2 linked. <duration> alone did not, and the clipitem/file <duration>/<rate>/<enabled>
261
+ // that Resolve's exporter writes were not required.
262
+ const xml = toFcp7Xml(TC_CONFORMED);
263
+ const head = xml.slice(0, xml.indexOf('<media>'));
264
+ assert.match(head, /<rate><timebase>24<\/timebase><ntsc>FALSE<\/ntsc><\/rate>/);
265
+ assert.match(head, /<duration>48<\/duration>/);
266
+ // A sequence start timecode rides at sequence level when the caller supplies one.
267
+ const withTc = toFcp7Xml({ ...TC_CONFORMED, sequence: { ...TC_CONFORMED.sequence, startTc: '00:59:50:00' } });
268
+ assert.match(withTc.slice(0, withTc.indexOf('<media>')), /<string>00:59:50:00<\/string>/);
269
+ // 23.976 is the fractional case: NTSC must read TRUE, timebase must round to 24.
270
+ const ntsc = toFcp7Xml({ ...TC_CONFORMED, sequence: { ...TC_CONFORMED.sequence, fps: 23.976 } });
271
+ assert.match(ntsc.slice(0, ntsc.indexOf('<media>')), /<timebase>24<\/timebase><ntsc>TRUE<\/ntsc>/);
272
+ });
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.78.1"
88
+ VERSION = "2.79.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.78.1"
14
+ VERSION = "2.79.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -5999,7 +5999,10 @@ _PRPROJ_REFUSAL = (
5999
5999
  "advanced MCP — editorial.list_sequences / editorial.parse_interchange (format 'prproj'); "
6000
6000
  "(2) convert it to an importable interchange — editorial.convert_to_interchange "
6001
6001
  "(target 'otio'|'edl'|'drt') — then import that here with import_timeline_checked. "
6002
- "Editorial timing/cuts/transitions/speed carry over; per-clip effects/Lumetri color do not. "
6002
+ "Editorial timing/cuts/transitions carry over; per-clip effects/Lumetri color do not. "
6003
+ "Speed/reverse carry on 'otio' and 'edl' ONLY — the DRT clip schema has no per-clip "
6004
+ "speed field, so 'drt' flattens every retime to 100% forward and reports them in "
6005
+ "`flattened`. Prefer 'otio' or 'edl' for a cut that carries retimes. "
6003
6006
  "Alternatively export FCP7 XML / AAF / FCPXML from Premiere and conform that."
6004
6007
  )
6005
6008
 
@@ -6008,6 +6011,12 @@ _PRPROJ_REFUSAL = (
6008
6011
  # pool after import, not by rewriting the file.
6009
6012
  _BINARY_INTERCHANGE_EXTS = {".aaf"}
6010
6013
 
6014
+ # Interchange that is not XML at all. `.otio` is JSON, so the sanitize/relink pass —
6015
+ # which parses the file as XML and rewrites <pathurl> elements — cannot run on it, and
6016
+ # the missing-media / generator-clip advice it exists to give is not the diagnosis for
6017
+ # an .otio that fails to import.
6018
+ _JSON_INTERCHANGE_EXTS = {".otio"}
6019
+
6011
6020
 
6012
6021
  def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6013
6022
  path = p.get("path")
@@ -6018,12 +6027,50 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6018
6027
  ext = os.path.splitext(path)[1].lower()
6019
6028
  if ext == ".prproj":
6020
6029
  return _err(_PRPROJ_REFUSAL, category="invalid_input")
6030
+ # ImportTimelineFromFile silently no-ops on the never-saved default project: it returns
6031
+ # nothing, creates no timeline, and reports no cause. The generic "Resolve created no
6032
+ # timeline" error that came back instead sent people to source-clip resolution and
6033
+ # sanitize_media — the wrong road entirely, because the file is fine.
6034
+ #
6035
+ # This is the last unrecorded member of a family the repo already documents: SaveProject
6036
+ # returns False here (and blocks forever headless), CreateProject fails against a dirty
6037
+ # untitled project, and DeleteProject's workaround warns against leaving the session on
6038
+ # this fallback. Refusing early is what makes the cause visible.
6039
+ #
6040
+ # The refusal is HARD — no override flag. An override would reintroduce exactly the
6041
+ # silent no-op this exists to kill, since the call cannot succeed either way.
6042
+ #
6043
+ # Resolve exposes no "is this project unsaved" predicate (no IsModified / IsSaved), so
6044
+ # the test is the literal fallback name. Known hole, stated rather than hidden: a
6045
+ # localized Resolve may not name it "Untitled Project", and this guard will miss it —
6046
+ # such a session gets the old generic error, not a wrong answer.
6047
+ from src.utils.project_cleanup import UNSAVED_DEFAULT_PROJECT
6048
+ try:
6049
+ _current_name = proj.GetName()
6050
+ except Exception:
6051
+ _current_name = None
6052
+ if _current_name == UNSAVED_DEFAULT_PROJECT:
6053
+ return _err(
6054
+ f"Cannot import a timeline into the never-saved default project "
6055
+ f"('{UNSAVED_DEFAULT_PROJECT}') — Resolve accepts the call and creates no "
6056
+ f"timeline, with no error naming the cause.",
6057
+ category="invalid_input",
6058
+ remediation=(
6059
+ "The project state is the problem, not the file. Save this project under a "
6060
+ "name, or load an existing named project (project_manager.load), then retry "
6061
+ "the import unchanged."
6062
+ ),
6063
+ )
6021
6064
  # AAF (and any binary interchange) is read natively by Resolve. The XML
6022
6065
  # sanitize/relink path parses the file as text, so it must be SKIPPED for AAF
6023
6066
  # even when sanitize_media / relink_search_roots is passed; fuzzy XML relink is
6024
6067
  # N/A (media links through the media pool). We still detect the created
6025
6068
  # (possibly offline) timeline via the before/after id diff.
6026
6069
  is_binary = ext in _BINARY_INTERCHANGE_EXTS
6070
+ # .otio is JSON. The sanitize/relink pass parses the file as XML, so it cannot run here
6071
+ # any more than it can on an AAF — and its advice (missing media, generator clips) is
6072
+ # not the diagnosis for an .otio that fails to import.
6073
+ is_json = ext in _JSON_INTERCHANGE_EXTS
6027
6074
  # sanitize_media (alias: relink_media) rewrites the XML to drop clipitems that
6028
6075
  # reference missing media or are generators (slug/solid w/ no pathurl) — both
6029
6076
  # abort Resolve's scripting-API import and leave the timeline fully offline.
@@ -6043,7 +6090,13 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6043
6090
  "relinks via the media pool. Imported the file as-is; pass relink_search_roots to "
6044
6091
  "auto-relink after import."
6045
6092
  )
6046
- sanitize = sanitize_requested and not is_binary
6093
+ elif is_json and (sanitize_requested or _binary_has_roots):
6094
+ binary_relink_note = (
6095
+ f"sanitize_media / XML path-rewrite relink are N/A for {ext} (JSON, not XML) — "
6096
+ "imported as-is. Media links by the target_url recorded in the file; relink via "
6097
+ "the media pool afterward (see `relink`)."
6098
+ )
6099
+ sanitize = sanitize_requested and not is_binary and not is_json
6047
6100
  if not sanitize and p.get("require_temp_path", True) and not _render_temp_path_ok(path):
6048
6101
  return _err(
6049
6102
  "path must be under the system temp directory unless require_temp_path=False",
@@ -6140,6 +6193,26 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6140
6193
  "Otherwise verify it exports/opens in Resolve directly, or convert to "
6141
6194
  "FCP7 XML / FCPXML upstream and import that."
6142
6195
  )
6196
+ elif is_json:
6197
+ # Measured on 19.1.3: Resolve DOES import OTIO through the scripting API —
6198
+ # its own EXPORT_OTIO output re-imports cleanly. What it refuses is a
6199
+ # document that is valid OTIO but not Resolve-shaped, and the shape it cares
6200
+ # about most is the source frame ORIGIN: a clip's source_range must sit
6201
+ # inside the media's real timecode range, so 0-based source offsets against
6202
+ # media that starts at 01:00:00:00 produce no timeline at all. Missing media
6203
+ # and sanitize_media are not the diagnosis here and pointing at them wastes
6204
+ # the caller's time on a file whose media is online.
6205
+ remediation = (
6206
+ f"Resolve created no timeline from this {ext}. This is usually the "
6207
+ "document's shape, not its media: Resolve expects Clip.2 with a "
6208
+ "media_references map, an available_range on each reference, and "
6209
+ "source_range frames expressed against the media's own timecode origin "
6210
+ "(0-based source offsets fail when the media does not start at "
6211
+ "00:00:00:00). Author it with editorial.convert_to_interchange "
6212
+ "(target 'otio'), supplying each event's media start timecode, and check "
6213
+ "the returned mediaOriginAssumed list. To compare against a known-good "
6214
+ "file, export any timeline with timeline.export EXPORT_OTIO."
6215
+ )
6143
6216
  elif sanitize:
6144
6217
  remediation = None
6145
6218
  else:
@@ -6180,6 +6253,9 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
6180
6253
  if is_binary:
6181
6254
  msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
6182
6255
  "at the media roots — AAF media links there, not by rewriting the file.")
6256
+ elif is_json:
6257
+ msg += (" Relink via the media pool — .otio is JSON, so sanitize_media cannot "
6258
+ "rewrite its paths; check each clip's target_url instead.")
6183
6259
  elif not sanitize:
6184
6260
  msg += (" Retry with sanitize_media=True to drop missing-media/generator "
6185
6261
  "clips so the remaining media links automatically.")