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.
- package/CHANGELOG.md +88 -0
- package/README.md +2 -1
- package/docs/guides/conforming-an-avid-aaf.md +91 -0
- package/docs/reference/api-limitations.md +32 -8
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +3 -1
- package/resolve-advanced/server/author-interchange.mjs +154 -16
- package/resolve-advanced/server/tools/editorial.mjs +5 -1
- package/resolve-advanced/vendor/conform-qc/packaging/emit-fcp7.js +155 -3
- package/resolve-advanced/vendor/conform-qc/packaging/index.js +5 -1
- package/resolve-advanced/vendor/conform-qc/test/packaging.test.js +152 -13
- package/src/granular/common.py +1 -1
- package/src/server.py +79 -3
- package/src/utils/api_truth.py +174 -7
|
@@ -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 (& < >) 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 =
|
|
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
|
-
|
|
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
|
+
});
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.")
|