davinci-resolve-mcp 2.78.0 → 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.0"
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()}")