davinci-resolve-mcp 2.78.1 → 2.79.1
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 +118 -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/editorial.mjs +13 -4
- package/resolve-advanced/server/tools/drt.mjs +18 -3
- package/resolve-advanced/server/tools/editorial.mjs +11 -3
- 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
|
@@ -70,17 +70,32 @@ const downgradeSchema = z.object({
|
|
|
70
70
|
appVersionString: z.string().optional().describe('DbAppVer comment to stamp (default derived from targetAppVersion, else "<v>.0.0.0000")'),
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* .drt/.drp are ZIP containers, so every reader here takes a FILE PATH. A caller reaching for the
|
|
75
|
+
* content-shaped convention its sibling text formats use (`{ content }`) otherwise gets a bare zod
|
|
76
|
+
* dump that never names the key it wanted.
|
|
77
|
+
*/
|
|
78
|
+
function requirePathArg(args, key, action) {
|
|
79
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
80
|
+
if (typeof a[key] === 'string' && a[key]) return a;
|
|
81
|
+
const given = Object.keys(a).filter((k) => a[k] !== undefined);
|
|
82
|
+
throw new Error(
|
|
83
|
+
`drt.${action}: pass \`${key}\` — an absolute path to the .drt/.drp. A .drt/.drp is a ZIP container, so this action reads a FILE PATH, never file content` +
|
|
84
|
+
`${given.length ? ` (got: ${given.join(', ')})` : ' (got no arguments)'}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
export const drtTool = {
|
|
74
89
|
name: 'drt',
|
|
75
90
|
description:
|
|
76
91
|
'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
|
|
77
92
|
async handler({ action, args }) {
|
|
78
93
|
if (action === 'parse') {
|
|
79
|
-
const p = parseSchema.parse(args);
|
|
94
|
+
const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
|
|
80
95
|
return drt().parseDRT(p.drtPath);
|
|
81
96
|
}
|
|
82
97
|
if (action === 'list_sequences') {
|
|
83
|
-
const p = listSequencesSchema.parse(args);
|
|
98
|
+
const p = listSequencesSchema.parse(requirePathArg(args, 'drpPath', 'list_sequences'));
|
|
84
99
|
const parsed = await drt().parseDRT(p.drpPath);
|
|
85
100
|
const sequences = summarizeDrtTimelines(parsed);
|
|
86
101
|
return { path: p.drpPath, count: sequences.length, sequences };
|
|
@@ -92,7 +107,7 @@ export const drtTool = {
|
|
|
92
107
|
return { outputPath: p.outputPath, bytes: buf.length };
|
|
93
108
|
}
|
|
94
109
|
if (action === 'validate') {
|
|
95
|
-
const p = validateSchema.parse(args);
|
|
110
|
+
const p = validateSchema.parse(requirePathArg(args, 'drtPath', 'validate'));
|
|
96
111
|
return drt().validateDRT(p.drtPath);
|
|
97
112
|
}
|
|
98
113
|
if (action === 'inject_into_drp') {
|
|
@@ -32,11 +32,15 @@ async function parseAnySource(sourcePath, sourceFormat) {
|
|
|
32
32
|
return parseInterchange(fmt, content, {});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// drt/drp are accepted only so the caller gets parseInterchange's NAMED redirect to the
|
|
36
|
+
// path-based reader instead of a bare enum-rejection that reads as "unsupported".
|
|
35
37
|
const parseSchema = z.object({
|
|
36
|
-
format: z.enum(['edl', 'otio', 'xml', 'xmeml', 'fcp7', 'aaf', 'prproj']),
|
|
38
|
+
format: z.enum(['edl', 'otio', 'xml', 'xmeml', 'fcp7', 'aaf', 'prproj', 'drt', 'drp']),
|
|
37
39
|
content: z
|
|
38
40
|
.union([z.string(), z.object({}).passthrough()])
|
|
39
|
-
.describe(
|
|
41
|
+
.describe(
|
|
42
|
+
'EDL text / OTIO JSON (string or object) / XMEML string. For AAF or PRPROJ (binary): the file PATH. For .drt/.drp (ZIP): use list_sequences or drt.parse — this action redirects.',
|
|
43
|
+
),
|
|
40
44
|
fps: z.number().optional(),
|
|
41
45
|
});
|
|
42
46
|
|
|
@@ -82,7 +86,7 @@ const markerSchema = z.object({
|
|
|
82
86
|
export const editorialTool = {
|
|
83
87
|
name: 'editorial',
|
|
84
88
|
description:
|
|
85
|
-
'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions
|
|
89
|
+
'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY — the DRT clip schema has no per-clip speed field, so target `drt` flattens every retime to 100% forward and returns `flattened`/`flattenedCount` naming each event that lost one; `flattened` is always present on `drt`, empty when there were none), turnover_changelist (diff old vs new → moved/retimed/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base), marker_roundtrip (markers with provenance tags). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
|
|
86
90
|
async handler({ action, args }) {
|
|
87
91
|
if (action === 'parse_interchange') {
|
|
88
92
|
const p = parseSchema.parse(args);
|
|
@@ -125,6 +129,10 @@ export const editorialTool = {
|
|
|
125
129
|
eventCount: events.length,
|
|
126
130
|
...(written || { content: authored.content }),
|
|
127
131
|
...(authored.spec ? { spec: authored.spec } : {}),
|
|
132
|
+
// skip-not-fake: the DRT clip schema has no per-clip speed field, so retimes cannot
|
|
133
|
+
// ride into a .drt. Name every event that lost one rather than returning a timeline
|
|
134
|
+
// the caller believes is conformed. Always present on 'drt' (empty = none to lose).
|
|
135
|
+
...(authored.flattened ? { flattened: authored.flattened, flattenedCount: authored.flattened.length } : {}),
|
|
128
136
|
};
|
|
129
137
|
}
|
|
130
138
|
if (action === 'turnover_changelist') {
|
|
@@ -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.1"
|
|
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()}")
|