davinci-resolve-mcp 4.5.2 → 4.6.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 +117 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +16 -2
- package/docs/kernels/color-grade-kernel.md +3 -0
- package/docs/reference/api-coverage.md +1 -1
- package/docs/reference/api-limitations.md +9 -1
- package/docs/reference/project-archive.md +81 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +10 -0
- package/resolve-advanced/server/drp-node-graph-relayout.mjs +567 -0
- package/resolve-advanced/server/tools/drp.mjs +47 -2
- package/resolve-advanced/server/tools/drx.mjs +6 -2
- package/resolve-advanced/server/tools/project_db.mjs +3 -1
- package/resolve-advanced/vendor/drx-codec/node-layout.js +214 -13
- package/src/granular/common.py +1 -1
- package/src/granular/project.py +31 -9
- package/src/server.py +34 -7
- package/src/utils/api_truth.py +43 -0
- package/src/utils/archive_guard.py +97 -0
- package/src/utils/destructive_hook.py +3 -1
- package/src/utils/execution_lifecycle.py +6 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-.drp "Cleanup Node Graph" — every node graph an exported project carries,
|
|
3
|
+
* scoped by anything the .drp can name, relaid out to Resolve's clean row with the
|
|
4
|
+
* grade content byte-preserved.
|
|
5
|
+
*
|
|
6
|
+
* Where node graphs live in a .drp (measured on Resolve 19.1.3 exports, DbPrjVer 14):
|
|
7
|
+
*
|
|
8
|
+
* SeqContainer/<uuid>.xml VideoTrackVec → Sm2TiTrack(Type 0, Sequence=SEQ) → Items →
|
|
9
|
+
* Sm2TiVideoClip → pLmVerTable → Locals → LmVersion [kind local]
|
|
10
|
+
* (one Element per LOCAL VERSION — a clip with three
|
|
11
|
+
* versions carries three graphs; pActive names the live one)
|
|
12
|
+
* MediaPool/**\/MpFolder.xml Sm2MpVideoClip → pLmVerTable (VerType 1) [kind remote]
|
|
13
|
+
* Sm2Timeline → Name + Sm2Sequence(DbId=SEQ) → pLmVerTable [kind sequence]
|
|
14
|
+
* project.xml Sm2GroupList → Sm2Group → Name + pLmVerTable
|
|
15
|
+
* ("Pre Clip Grade" / "Post Clip Grade" versions) [kind group]
|
|
16
|
+
*
|
|
17
|
+
* Every LmVersion has a UUID DbId, so a rewrite targets `<ListMgt::LmVersion DbId="…">`
|
|
18
|
+
* and replaces only the `<Body>` inside it. HasCorrection is left exactly as found
|
|
19
|
+
* (relayout is not a grade change), unlike inject-grades which flips it on purpose.
|
|
20
|
+
*
|
|
21
|
+
* The position rewrite itself is node-layout.js (the same bytes Resolve's own Cleanup
|
|
22
|
+
* Node Graph writes — see that module). This file is the inventory + scope + zip
|
|
23
|
+
* plumbing + read-back verification around it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import JSZip from 'jszip';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const layout = () => require('../vendor/drx-codec/node-layout.js');
|
|
33
|
+
const drxParser = () => require('../vendor/drx-codec/drx-parser.js');
|
|
34
|
+
|
|
35
|
+
export const GRAPH_KINDS = ['local', 'remote', 'group', 'sequence'];
|
|
36
|
+
|
|
37
|
+
// ── tiny XML helpers (Resolve's serializer is regular enough for regex) ─────
|
|
38
|
+
|
|
39
|
+
const scalar = (xml, tag) => {
|
|
40
|
+
const m = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
|
|
41
|
+
return m ? m[1].trim() : null;
|
|
42
|
+
};
|
|
43
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
|
|
45
|
+
/** Elements `<Tag DbId="…">…</Tag>` — Resolve never nests a tag inside itself. */
|
|
46
|
+
function elements(xml, tag) {
|
|
47
|
+
const re = new RegExp(`<${tag} DbId="([^"]+)">([\\s\\S]*?)</${tag}>`, 'g');
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const m of xml.matchAll(re)) out.push({ id: m[1], inner: m[2], start: m.index, end: m.index + m[0].length });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Versions of one `<pLmVerTable>` block: [{id, name, hasCorrection, verType, bodyHex, active}]. */
|
|
54
|
+
function versionsOf(ownerXml) {
|
|
55
|
+
const vt = ownerXml.match(/<pLmVerTable>([\s\S]*?)<\/pLmVerTable>/);
|
|
56
|
+
if (!vt) return { versions: [], active: null, linkedGroup: null, tableVerType: null };
|
|
57
|
+
const tbl = vt[1];
|
|
58
|
+
const active = scalar(tbl, 'pActive');
|
|
59
|
+
const linkedGroup = scalar(tbl, 'LinkedGroup');
|
|
60
|
+
const tableVerType = scalar(tbl, 'VerType');
|
|
61
|
+
const versions = elements(tbl, 'ListMgt::LmVersion').map((v) => {
|
|
62
|
+
const body = (v.inner.match(/<Body>([0-9a-fA-F\s]*)<\/Body>/) || [null, ''])[1];
|
|
63
|
+
return {
|
|
64
|
+
id: v.id,
|
|
65
|
+
name: scalar(v.inner, 'Name') || '',
|
|
66
|
+
hasCorrection: /<HasCorrection>true<\/HasCorrection>/.test(v.inner),
|
|
67
|
+
verType: Number(scalar(v.inner, 'VerType') ?? tableVerType ?? 0),
|
|
68
|
+
bodyHex: body ? body.replace(/[^0-9a-fA-F]/g, '').toLowerCase() : '',
|
|
69
|
+
active: v.id === active,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return { versions, active, linkedGroup, tableVerType };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function globToRegExp(glob) {
|
|
76
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
77
|
+
return new RegExp(`^${esc}$`, 'i');
|
|
78
|
+
}
|
|
79
|
+
function matchesAny(value, globs) {
|
|
80
|
+
if (globs === undefined || globs === null) return true;
|
|
81
|
+
const list = Array.isArray(globs) ? globs : [globs];
|
|
82
|
+
if (list.length === 0) return true;
|
|
83
|
+
const v = String(value ?? '');
|
|
84
|
+
return list.some((g) => globToRegExp(g).test(v));
|
|
85
|
+
}
|
|
86
|
+
const asList = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
|
|
87
|
+
|
|
88
|
+
// ── zip plumbing ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const isSeqContainer = (p) => /(^|\/)SeqContainer(\d*\.xml|\/[^/]+\.xml)$/.test(p);
|
|
91
|
+
const isMpFolder = (p) => /(^|\/)MpFolder\.xml$/.test(p);
|
|
92
|
+
const isProjectXml = (p) => /(^|\/)project\.xml$/.test(p);
|
|
93
|
+
|
|
94
|
+
async function loadZip(drpPath) {
|
|
95
|
+
const buf = await fs.readFile(drpPath);
|
|
96
|
+
const zip = await JSZip.loadAsync(buf);
|
|
97
|
+
const entries = {};
|
|
98
|
+
const names = [];
|
|
99
|
+
zip.forEach((p, e) => {
|
|
100
|
+
if (!e.dir) names.push(p);
|
|
101
|
+
});
|
|
102
|
+
for (const p of names.sort()) {
|
|
103
|
+
if (isSeqContainer(p) || isMpFolder(p) || isProjectXml(p)) entries[p] = await zip.file(p).async('string');
|
|
104
|
+
}
|
|
105
|
+
return { zip, entries };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── inventory ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Index every node graph in the .drp.
|
|
112
|
+
* @returns {{ graphs: Graph[], timelines: [{name, sequenceId}], groups: [{id, name}], projectName }}
|
|
113
|
+
*/
|
|
114
|
+
export function indexNodeGraphs(entries) {
|
|
115
|
+
const mpXmls = Object.entries(entries).filter(([p]) => isMpFolder(p));
|
|
116
|
+
const seqXmls = Object.entries(entries).filter(([p]) => isSeqContainer(p));
|
|
117
|
+
const projEntry = Object.entries(entries).find(([p]) => isProjectXml(p));
|
|
118
|
+
const projectXml = projEntry ? projEntry[1] : '';
|
|
119
|
+
const projectName = scalar(projectXml, 'ProjectName') || scalar(projectXml, 'Name') || null;
|
|
120
|
+
|
|
121
|
+
// Groups: id → name (project.xml). Their pre/post graphs are versions of the group.
|
|
122
|
+
const groups = [];
|
|
123
|
+
const graphs = [];
|
|
124
|
+
const groupList = projectXml.match(/<GroupListObj>([\s\S]*?)<\/GroupListObj>/);
|
|
125
|
+
if (groupList) {
|
|
126
|
+
for (const g of elements(groupList[1], 'Sm2Group')) {
|
|
127
|
+
const name = scalar(g.inner, 'Name') || g.id;
|
|
128
|
+
groups.push({ id: g.id, name });
|
|
129
|
+
const { versions } = versionsOf(g.inner);
|
|
130
|
+
for (const v of versions) {
|
|
131
|
+
graphs.push({
|
|
132
|
+
kind: 'group',
|
|
133
|
+
entry: projEntry[0],
|
|
134
|
+
versionId: v.id,
|
|
135
|
+
versionName: v.name,
|
|
136
|
+
active: true,
|
|
137
|
+
hasCorrection: v.hasCorrection,
|
|
138
|
+
bodyHex: v.bodyHex,
|
|
139
|
+
group: name,
|
|
140
|
+
slot: /pre/i.test(v.name) ? 'pre' : /post/i.test(v.name) ? 'post' : null,
|
|
141
|
+
label: `${name} · ${v.name}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const groupNameById = new Map(groups.map((g) => [g.id, g.name]));
|
|
147
|
+
|
|
148
|
+
// Timelines: sequence id → name (media pool). Sequence-level graphs ride along.
|
|
149
|
+
const timelines = [];
|
|
150
|
+
const timelineBySeq = new Map();
|
|
151
|
+
for (const [entry, xml] of mpXmls) {
|
|
152
|
+
for (const t of elements(xml, 'Sm2Timeline')) {
|
|
153
|
+
const name = scalar(t.inner, 'Name') || t.id;
|
|
154
|
+
const seq = t.inner.match(/<Sm2Sequence DbId="([^"]+)">([\s\S]*?)<\/Sm2Sequence>/);
|
|
155
|
+
const sequenceId = seq ? seq[1] : null;
|
|
156
|
+
timelines.push({ name, timelineId: t.id, sequenceId });
|
|
157
|
+
if (sequenceId) timelineBySeq.set(sequenceId, name);
|
|
158
|
+
if (seq) {
|
|
159
|
+
const { versions } = versionsOf(seq[2]);
|
|
160
|
+
for (const v of versions) {
|
|
161
|
+
graphs.push({
|
|
162
|
+
kind: 'sequence',
|
|
163
|
+
entry,
|
|
164
|
+
versionId: v.id,
|
|
165
|
+
versionName: v.name,
|
|
166
|
+
active: v.active,
|
|
167
|
+
hasCorrection: v.hasCorrection,
|
|
168
|
+
bodyHex: v.bodyHex,
|
|
169
|
+
timeline: name,
|
|
170
|
+
label: `${name} · ${v.name}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Remote versions sit on the media-pool clip itself.
|
|
176
|
+
for (const c of elements(xml, 'Sm2MpVideoClip')) {
|
|
177
|
+
const { versions } = versionsOf(c.inner);
|
|
178
|
+
if (!versions.length) continue;
|
|
179
|
+
const mediaPath = scalar(c.inner, 'MediaFilePath') || '';
|
|
180
|
+
const clipName = scalar(c.inner, 'Name') || path.basename(mediaPath) || c.id;
|
|
181
|
+
for (const v of versions) {
|
|
182
|
+
graphs.push({
|
|
183
|
+
kind: 'remote',
|
|
184
|
+
entry,
|
|
185
|
+
versionId: v.id,
|
|
186
|
+
versionName: v.name,
|
|
187
|
+
active: v.active,
|
|
188
|
+
hasCorrection: v.hasCorrection,
|
|
189
|
+
bodyHex: v.bodyHex,
|
|
190
|
+
mediaPoolClipId: c.id,
|
|
191
|
+
clipName,
|
|
192
|
+
mediaPath,
|
|
193
|
+
mediaName: path.basename(mediaPath),
|
|
194
|
+
label: `${clipName} · ${v.name} (remote)`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Local versions: per timeline clip, in track/item order.
|
|
201
|
+
for (const [entry, xml] of seqXmls) {
|
|
202
|
+
const vec = xml.match(/<VideoTrackVec>([\s\S]*?)<\/VideoTrackVec>/);
|
|
203
|
+
if (!vec) continue;
|
|
204
|
+
// Track numbers count per SEQUENCE (V1, V2, … as the timeline shows them) —
|
|
205
|
+
// Resolve writes one container per timeline, but never assume it.
|
|
206
|
+
const trackNoBySeq = new Map();
|
|
207
|
+
for (const t of elements(vec[1], 'Sm2TiTrack')) {
|
|
208
|
+
if (Number(scalar(t.inner, 'Type') ?? 0) !== 0) continue; // video tracks only
|
|
209
|
+
const sequenceId = scalar(t.inner, 'Sequence');
|
|
210
|
+
const trackNo = (trackNoBySeq.get(sequenceId) || 0) + 1;
|
|
211
|
+
trackNoBySeq.set(sequenceId, trackNo);
|
|
212
|
+
const timeline = timelineBySeq.get(sequenceId) || sequenceId || '(unknown timeline)';
|
|
213
|
+
let clipNo = 0;
|
|
214
|
+
for (const c of elements(t.inner, 'Sm2TiVideoClip')) {
|
|
215
|
+
clipNo += 1;
|
|
216
|
+
const start = Number(scalar(c.inner, 'Start'));
|
|
217
|
+
const duration = Number(scalar(c.inner, 'Duration'));
|
|
218
|
+
const mediaPath = scalar(c.inner, 'MediaFilePath') || '';
|
|
219
|
+
const clipName = scalar(c.inner, 'Name') || path.basename(mediaPath) || c.id;
|
|
220
|
+
const { versions, linkedGroup } = versionsOf(c.inner);
|
|
221
|
+
const group = linkedGroup ? groupNameById.get(linkedGroup) || null : null;
|
|
222
|
+
for (const v of versions) {
|
|
223
|
+
graphs.push({
|
|
224
|
+
kind: 'local',
|
|
225
|
+
entry,
|
|
226
|
+
versionId: v.id,
|
|
227
|
+
versionName: v.name,
|
|
228
|
+
active: v.active,
|
|
229
|
+
hasCorrection: v.hasCorrection,
|
|
230
|
+
bodyHex: v.bodyHex,
|
|
231
|
+
clipId: c.id,
|
|
232
|
+
clipName,
|
|
233
|
+
timeline,
|
|
234
|
+
track: trackNo,
|
|
235
|
+
clipIndex: clipNo,
|
|
236
|
+
start: Number.isFinite(start) ? start : null,
|
|
237
|
+
end: Number.isFinite(start) && Number.isFinite(duration) ? start + duration - 1 : null,
|
|
238
|
+
duration: Number.isFinite(duration) ? duration : null,
|
|
239
|
+
mediaPath,
|
|
240
|
+
mediaName: path.basename(mediaPath),
|
|
241
|
+
group,
|
|
242
|
+
versionCount: versions.length,
|
|
243
|
+
label: `${timeline} V${trackNo} #${clipNo} ${clipName}${versions.length > 1 ? ` · ${v.name}` : ''}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { graphs, timelines, groups, projectName };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── scope ───────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Does one graph fall inside one scope object? All present selectors AND.
|
|
257
|
+
* Selectors that only make sense for clip graphs (timelines, tracks, frames, …)
|
|
258
|
+
* exclude the other kinds when set — a scope naming a timeline never sweeps
|
|
259
|
+
* group graphs along by accident.
|
|
260
|
+
*/
|
|
261
|
+
export function graphInScope(g, s, decoded) {
|
|
262
|
+
if (!s) return true;
|
|
263
|
+
const kinds = asList(s.kinds);
|
|
264
|
+
if (kinds.length && !kinds.includes(g.kind)) return false;
|
|
265
|
+
const clipish = g.kind === 'local';
|
|
266
|
+
const clipOnly = (cond) => (clipish ? cond() : false);
|
|
267
|
+
|
|
268
|
+
if (s.timelines !== undefined && asList(s.timelines).length && !(asList(s.timelines).includes('*'))) {
|
|
269
|
+
if (g.kind === 'group' || g.kind === 'remote') return false;
|
|
270
|
+
if (!matchesAny(g.timeline, s.timelines)) return false;
|
|
271
|
+
}
|
|
272
|
+
if (asList(s.tracks).length && !clipOnly(() => asList(s.tracks).map(Number).includes(g.track))) return false;
|
|
273
|
+
if (asList(s.clipIds).length && !clipOnly(() => asList(s.clipIds).includes(g.clipId))) return false;
|
|
274
|
+
if (asList(s.excludeClipIds).length && clipish && asList(s.excludeClipIds).includes(g.clipId)) return false;
|
|
275
|
+
if (asList(s.names).length && !clipOnly(() => matchesAny(g.clipName, s.names))) return false;
|
|
276
|
+
if (asList(s.media).length) {
|
|
277
|
+
if (g.kind !== 'local' && g.kind !== 'remote') return false;
|
|
278
|
+
if (!(matchesAny(g.mediaName, s.media) || matchesAny(g.mediaPath, s.media))) return false;
|
|
279
|
+
}
|
|
280
|
+
if (Array.isArray(s.frames) && s.frames.length === 2) {
|
|
281
|
+
const [a, b] = s.frames.map(Number);
|
|
282
|
+
if (!clipOnly(() => g.start !== null && g.end !== null && g.end >= a && g.start <= b)) return false;
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(s.clipRange) && s.clipRange.length === 2) {
|
|
285
|
+
const [a, b] = s.clipRange.map(Number);
|
|
286
|
+
if (!clipOnly(() => g.clipIndex >= a && g.clipIndex <= b)) return false;
|
|
287
|
+
}
|
|
288
|
+
if (asList(s.groups).length) {
|
|
289
|
+
if (g.kind === 'local') {
|
|
290
|
+
if (!g.group || !asList(s.groups).some((x) => globToRegExp(x).test(g.group))) return false;
|
|
291
|
+
} else if (g.kind === 'group') {
|
|
292
|
+
if (!asList(s.groups).some((x) => globToRegExp(x).test(g.group))) return false;
|
|
293
|
+
} else return false;
|
|
294
|
+
}
|
|
295
|
+
if (asList(s.excludeGroups).length && (g.kind === 'local' || g.kind === 'group') && g.group) {
|
|
296
|
+
if (asList(s.excludeGroups).some((x) => globToRegExp(x).test(g.group))) return false;
|
|
297
|
+
}
|
|
298
|
+
if (s.versions === 'active' && !g.active) return false;
|
|
299
|
+
if (asList(s.versionNames).length && !matchesAny(g.versionName, s.versionNames)) return false;
|
|
300
|
+
if (s.gradedOnly !== false) {
|
|
301
|
+
// "graded" = Resolve's own flag OR a graph that visibly has more than the one default node.
|
|
302
|
+
if (!g.hasCorrection && !(decoded && decoded.nodeCount >= 2)) return false;
|
|
303
|
+
}
|
|
304
|
+
if (s.minNodes !== undefined && !(decoded && decoded.nodeCount >= Number(s.minNodes))) return false;
|
|
305
|
+
if (s.maxNodes !== undefined && !(decoded && decoded.nodeCount <= Number(s.maxNodes))) return false;
|
|
306
|
+
if (asList(s.nodeLabels).length) {
|
|
307
|
+
const labels = (decoded && decoded.labels) || [];
|
|
308
|
+
if (!labels.some((l) => matchesAny(l, s.nodeLabels))) return false;
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── decode helpers ──────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
const DRX_ENVELOPE = (hex) =>
|
|
316
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n<Resolve_Color_Exchange>\n <Label>x</Label>\n <Width>1920</Width>\n <Height>1080</Height>\n <Body>${hex}</Body>\n</Resolve_Color_Exchange>\n`;
|
|
317
|
+
|
|
318
|
+
async function decodeGraph(g, { labels, layoutOpts }) {
|
|
319
|
+
if (!g.bodyHex) return { error: 'no <Body>' };
|
|
320
|
+
const body = Buffer.from(g.bodyHex, 'hex');
|
|
321
|
+
let topo;
|
|
322
|
+
try {
|
|
323
|
+
topo = await layout().readTopology(body, layoutOpts);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return { error: e.message };
|
|
326
|
+
}
|
|
327
|
+
const positions = topo.nodes.map((n) => [n.x, n.y]);
|
|
328
|
+
const out = { nodeCount: positions.length, positions, planned: topo.planned, meta: topo.meta, edges: topo.edges.length };
|
|
329
|
+
if (labels) {
|
|
330
|
+
try {
|
|
331
|
+
const parsed = await drxParser().parseDRXContent(DRX_ENVELOPE(g.bodyHex));
|
|
332
|
+
out.labels = (parsed.nodes || []).map((n) => String(n.label || ''));
|
|
333
|
+
} catch {
|
|
334
|
+
out.labels = [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const within = (a, b, tol) => a != null && b != null && Math.abs(a - b) <= tol;
|
|
341
|
+
function isClean(before, target, tol = 2) {
|
|
342
|
+
return (
|
|
343
|
+
before.length === target.length &&
|
|
344
|
+
before.every(([bx, by], i) => within(bx, target[i][0], tol) && within(by, target[i][1], tol))
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── the sweep ───────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {string} drpPath
|
|
352
|
+
* @param {object} opts
|
|
353
|
+
* @param {string} [opts.outputPath] required unless dryRun
|
|
354
|
+
* @param {boolean} [opts.dryRun]
|
|
355
|
+
* @param {object|object[]} [opts.scope] one scope object, or several (rows union)
|
|
356
|
+
* @param {{originX?,originY?,spacingX?}} [opts.layout]
|
|
357
|
+
* @param {boolean} [opts.includeLabels] decode node labels into the report (slower)
|
|
358
|
+
* @param {number} [opts.itemLimit] cap on `items` in the report (default 2000)
|
|
359
|
+
*/
|
|
360
|
+
export async function relayoutDrpNodeGraphs(drpPath, opts = {}) {
|
|
361
|
+
const dryRun = opts.dryRun === undefined ? !opts.outputPath : Boolean(opts.dryRun);
|
|
362
|
+
if (!dryRun && !opts.outputPath) throw new Error('outputPath is required unless dryRun');
|
|
363
|
+
if (!dryRun && path.resolve(opts.outputPath) === path.resolve(drpPath)) {
|
|
364
|
+
throw new Error('refusing to overwrite the source .drp — write to a new outputPath');
|
|
365
|
+
}
|
|
366
|
+
const scopes = asList(opts.scope).length ? asList(opts.scope) : [{}];
|
|
367
|
+
const layoutOpts = {
|
|
368
|
+
originX: opts.layout?.originX,
|
|
369
|
+
originY: opts.layout?.originY,
|
|
370
|
+
spacingX: opts.layout?.spacingX,
|
|
371
|
+
spacingY: opts.layout?.spacingY,
|
|
372
|
+
layout: opts.layout?.mode,
|
|
373
|
+
};
|
|
374
|
+
const wantLabels = Boolean(opts.includeLabels) || scopes.some((s) => asList(s.nodeLabels).length);
|
|
375
|
+
|
|
376
|
+
const { zip, entries } = await loadZip(drpPath);
|
|
377
|
+
const index = indexNodeGraphs(entries);
|
|
378
|
+
const lay = layout();
|
|
379
|
+
|
|
380
|
+
const items = [];
|
|
381
|
+
const skipped = [];
|
|
382
|
+
const changes = []; // {entry, versionId, bodyHex(new)}
|
|
383
|
+
const totals = { graphs: index.graphs.length, matched: 0, relaid: 0, alreadyClean: 0, skipped: 0, empty: 0, excluded: 0 };
|
|
384
|
+
const byKind = {};
|
|
385
|
+
const bump = (kind, key) => {
|
|
386
|
+
byKind[kind] = byKind[kind] || { graphs: 0, matched: 0, relaid: 0, alreadyClean: 0, skipped: 0, empty: 0 };
|
|
387
|
+
byKind[kind][key] += 1;
|
|
388
|
+
};
|
|
389
|
+
const versionNames = new Set();
|
|
390
|
+
let stacked = 0;
|
|
391
|
+
const nodeCountByTimeline = {};
|
|
392
|
+
|
|
393
|
+
for (const g of index.graphs) {
|
|
394
|
+
bump(g.kind, 'graphs');
|
|
395
|
+
versionNames.add(g.versionName);
|
|
396
|
+
if (g.kind === 'local') {
|
|
397
|
+
const t = (nodeCountByTimeline[g.timeline] = nodeCountByTimeline[g.timeline] || { graphs: 0, graded: 0, tracks: 0, clips: new Set() });
|
|
398
|
+
t.graphs += 1;
|
|
399
|
+
if (g.hasCorrection) t.graded += 1;
|
|
400
|
+
t.tracks = Math.max(t.tracks, g.track);
|
|
401
|
+
t.clips.add(g.clipId);
|
|
402
|
+
}
|
|
403
|
+
const decoded = await decodeGraph(g, { labels: wantLabels, layoutOpts });
|
|
404
|
+
if (decoded.error) {
|
|
405
|
+
// Undecodable bodies are reported, never rewritten — and never fail the sweep.
|
|
406
|
+
skipped.push({ key: g.versionId, kind: g.kind, label: g.label, reason: decoded.error });
|
|
407
|
+
totals.skipped += 1;
|
|
408
|
+
bump(g.kind, 'skipped');
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (decoded.nodeCount === 0) {
|
|
412
|
+
// Resolve's default body carries NO node message (the node is created lazily
|
|
413
|
+
// in the UI) — nothing to lay out, and not an error.
|
|
414
|
+
totals.empty += 1;
|
|
415
|
+
bump(g.kind, 'empty');
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const inScope = scopes.some((s) => graphInScope(g, s, decoded));
|
|
419
|
+
if (!inScope) {
|
|
420
|
+
totals.excluded += 1;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
totals.matched += 1;
|
|
424
|
+
bump(g.kind, 'matched');
|
|
425
|
+
if ((decoded.meta.lanes || 1) > 1) stacked += 1;
|
|
426
|
+
const target = decoded.planned;
|
|
427
|
+
const clean = isClean(decoded.positions, target);
|
|
428
|
+
const item = {
|
|
429
|
+
key: g.versionId,
|
|
430
|
+
kind: g.kind,
|
|
431
|
+
label: g.label,
|
|
432
|
+
timeline: g.timeline ?? null,
|
|
433
|
+
track: g.track ?? null,
|
|
434
|
+
clipIndex: g.clipIndex ?? null,
|
|
435
|
+
clipId: g.clipId ?? g.mediaPoolClipId ?? null,
|
|
436
|
+
clipName: g.clipName ?? null,
|
|
437
|
+
start: g.start ?? null,
|
|
438
|
+
end: g.end ?? null,
|
|
439
|
+
mediaName: g.mediaName ?? null,
|
|
440
|
+
group: g.group ?? null,
|
|
441
|
+
versionId: g.versionId,
|
|
442
|
+
versionName: g.versionName,
|
|
443
|
+
active: g.active,
|
|
444
|
+
hasCorrection: g.hasCorrection,
|
|
445
|
+
nodes: decoded.nodeCount,
|
|
446
|
+
layout: decoded.meta,
|
|
447
|
+
...(decoded.labels ? { nodeLabels: decoded.labels } : {}),
|
|
448
|
+
before: decoded.positions,
|
|
449
|
+
after: target,
|
|
450
|
+
status: clean ? 'clean' : dryRun ? 'would-relayout' : 'relaid',
|
|
451
|
+
};
|
|
452
|
+
if (clean) {
|
|
453
|
+
totals.alreadyClean += 1;
|
|
454
|
+
bump(g.kind, 'alreadyClean');
|
|
455
|
+
} else {
|
|
456
|
+
totals.relaid += 1;
|
|
457
|
+
bump(g.kind, 'relaid');
|
|
458
|
+
if (!dryRun) {
|
|
459
|
+
const r = await lay.relayoutBody(Buffer.from(g.bodyHex, 'hex'), layoutOpts);
|
|
460
|
+
if (r.nodeCount !== decoded.nodeCount) throw new Error(`node count changed while relaying ${g.label}: ${decoded.nodeCount} → ${r.nodeCount}`);
|
|
461
|
+
changes.push({ entry: g.entry, versionId: g.versionId, bodyHex: r.body.toString('hex'), positions: r.positions });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
items.push(item);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const context = {
|
|
468
|
+
project: index.projectName,
|
|
469
|
+
timelines: index.timelines.map((t) => ({
|
|
470
|
+
name: t.name,
|
|
471
|
+
sequenceId: t.sequenceId,
|
|
472
|
+
tracks: nodeCountByTimeline[t.name]?.tracks || 0,
|
|
473
|
+
clips: nodeCountByTimeline[t.name]?.clips.size || 0,
|
|
474
|
+
graphs: nodeCountByTimeline[t.name]?.graphs || 0,
|
|
475
|
+
graded: nodeCountByTimeline[t.name]?.graded || 0,
|
|
476
|
+
})),
|
|
477
|
+
groups: index.groups.map((g) => ({
|
|
478
|
+
name: g.name,
|
|
479
|
+
clips: new Set(index.graphs.filter((x) => x.kind === 'local' && x.group === g.name).map((x) => x.clipId)).size,
|
|
480
|
+
})),
|
|
481
|
+
versionNames: [...versionNames].sort(),
|
|
482
|
+
kinds: Object.fromEntries(GRAPH_KINDS.map((k) => [k, byKind[k]?.graphs || 0])),
|
|
483
|
+
multiVersionClips: new Set(index.graphs.filter((x) => x.kind === 'local' && x.versionCount > 1).map((x) => x.clipId)).size,
|
|
484
|
+
stackedGraphs: stacked,
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const limit = opts.itemLimit ?? 2000;
|
|
488
|
+
const report = {
|
|
489
|
+
dryRun,
|
|
490
|
+
drpPath,
|
|
491
|
+
outputPath: dryRun ? null : opts.outputPath,
|
|
492
|
+
layout: {
|
|
493
|
+
originX: layoutOpts.originX ?? 290,
|
|
494
|
+
originY: layoutOpts.originY ?? 428,
|
|
495
|
+
spacingX: layoutOpts.spacingX ?? 495,
|
|
496
|
+
spacingY: layoutOpts.spacingY ?? 178,
|
|
497
|
+
mode: layoutOpts.layout ?? 'topology',
|
|
498
|
+
},
|
|
499
|
+
scope: scopes,
|
|
500
|
+
totals: {
|
|
501
|
+
graphs: totals.graphs,
|
|
502
|
+
matched: totals.matched,
|
|
503
|
+
[dryRun ? 'wouldRelayout' : 'relaid']: totals.relaid,
|
|
504
|
+
alreadyClean: totals.alreadyClean,
|
|
505
|
+
skipped: totals.skipped,
|
|
506
|
+
empty: totals.empty,
|
|
507
|
+
excluded: totals.excluded,
|
|
508
|
+
},
|
|
509
|
+
byKind,
|
|
510
|
+
context,
|
|
511
|
+
items: items.slice(0, limit),
|
|
512
|
+
itemsTruncated: Math.max(0, items.length - limit),
|
|
513
|
+
skipped,
|
|
514
|
+
};
|
|
515
|
+
if (dryRun) return report;
|
|
516
|
+
|
|
517
|
+
// ── write: replace each changed version's <Body> by its DbId ───────────────
|
|
518
|
+
const touched = new Map(); // entry → xml
|
|
519
|
+
for (const ch of changes) {
|
|
520
|
+
const xml = touched.get(ch.entry) ?? entries[ch.entry];
|
|
521
|
+
const re = new RegExp(`(<ListMgt::LmVersion DbId="${escapeRegex(ch.versionId)}">[\\s\\S]*?<Body>)[0-9a-fA-F\\s]*(</Body>[\\s\\S]*?</ListMgt::LmVersion>)`);
|
|
522
|
+
if (!re.test(xml)) throw new Error(`version ${ch.versionId} not found in ${ch.entry} at write time`);
|
|
523
|
+
touched.set(ch.entry, xml.replace(re, `$1${ch.bodyHex}$2`));
|
|
524
|
+
}
|
|
525
|
+
for (const [entry, xml] of touched) zip.file(entry, xml);
|
|
526
|
+
const outBuf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
|
|
527
|
+
await fs.mkdir(path.dirname(opts.outputPath), { recursive: true });
|
|
528
|
+
await fs.writeFile(opts.outputPath, outBuf);
|
|
529
|
+
|
|
530
|
+
// ── read-back verify: the file on disk, re-indexed from scratch ───────────
|
|
531
|
+
const back = await loadZip(opts.outputPath);
|
|
532
|
+
const backIndex = indexNodeGraphs(back.entries);
|
|
533
|
+
const byId = new Map(backIndex.graphs.map((g) => [g.versionId, g]));
|
|
534
|
+
const problems = [];
|
|
535
|
+
if (backIndex.graphs.length !== index.graphs.length) {
|
|
536
|
+
problems.push(`graph count ${index.graphs.length} → ${backIndex.graphs.length}`);
|
|
537
|
+
}
|
|
538
|
+
const changedIds = new Set(changes.map((c) => c.versionId));
|
|
539
|
+
for (const g of index.graphs) {
|
|
540
|
+
const b = byId.get(g.versionId);
|
|
541
|
+
if (!b) {
|
|
542
|
+
problems.push(`${g.label}: missing after write`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (b.hasCorrection !== g.hasCorrection) problems.push(`${g.label}: HasCorrection changed`);
|
|
546
|
+
if (!changedIds.has(g.versionId)) {
|
|
547
|
+
if (b.bodyHex !== g.bodyHex) problems.push(`${g.label}: untouched body changed`);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
const ch = changes.find((c) => c.versionId === g.versionId);
|
|
551
|
+
let pos;
|
|
552
|
+
try {
|
|
553
|
+
pos = await lay.readNodePositions(Buffer.from(b.bodyHex, 'hex'));
|
|
554
|
+
} catch (e) {
|
|
555
|
+
problems.push(`${g.label}: undecodable after write (${e.message})`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (JSON.stringify(pos) !== JSON.stringify(ch.positions)) problems.push(`${g.label}: positions ${JSON.stringify(pos)} ≠ ${JSON.stringify(ch.positions)}`);
|
|
559
|
+
}
|
|
560
|
+
report.verify = { ok: problems.length === 0, checked: index.graphs.length, rewritten: changes.length, bytes: outBuf.length, problems: problems.slice(0, 50) };
|
|
561
|
+
if (problems.length) {
|
|
562
|
+
// Never hand back a file that failed its own read-back.
|
|
563
|
+
await fs.rm(opts.outputPath, { force: true });
|
|
564
|
+
throw new Error(`relayout read-back verify failed (${problems.length}): ${problems.slice(0, 3).join(' | ')} — output deleted`);
|
|
565
|
+
}
|
|
566
|
+
return report;
|
|
567
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Place: place_fusion_title, place_generator, place_transition
|
|
8
8
|
* Edit: move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline
|
|
9
9
|
* Media: relink_media, repoint_media
|
|
10
|
-
* Grade: inject_grades, extract_node_graphs, diff
|
|
10
|
+
* Grade: inject_grades, extract_node_graphs, relayout_node_graphs, diff
|
|
11
11
|
* Nested: list_nested, read_nested, read_nested_titles, set_nested_title_text (compounds + nested timelines)
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
import { drp } from '../libs.mjs';
|
|
17
17
|
import { decodeGroupGrades } from '../group-grade-read.mjs';
|
|
18
|
+
import { relayoutDrpNodeGraphs, GRAPH_KINDS } from '../drp-node-graph-relayout.mjs';
|
|
18
19
|
|
|
19
20
|
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
20
21
|
|
|
@@ -31,6 +32,29 @@ const io = {
|
|
|
31
32
|
outputPath: z.string().describe('Absolute path for the written .drp'),
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
const strList = z.union([z.string(), z.array(z.string())]);
|
|
36
|
+
const graphScope = z
|
|
37
|
+
.object({
|
|
38
|
+
timelines: strList.optional().describe('Timeline name(s), exact or glob. Clip graphs only — group/remote graphs never match a timeline selector.'),
|
|
39
|
+
tracks: z.array(z.number().int().positive()).optional().describe('1-based video track indices'),
|
|
40
|
+
clipIds: z.array(z.string()).optional().describe('Timeline-item DbIds (= the scripting API unique ids)'),
|
|
41
|
+
excludeClipIds: z.array(z.string()).optional(),
|
|
42
|
+
names: strList.optional().describe('Clip name glob(s), e.g. "A0*.mov"'),
|
|
43
|
+
media: strList.optional().describe('Media file name or full path glob(s)'),
|
|
44
|
+
frames: z.tuple([z.number().int(), z.number().int()]).optional().describe('Record-frame range [in, out] (absolute timeline frames, as the .drp stores them); any overlapping clip matches'),
|
|
45
|
+
clipRange: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional().describe('1-based clip position range on the track [from, to]'),
|
|
46
|
+
groups: z.array(z.string()).optional().describe('Color group name glob(s) — clip graphs in the group plus the group graphs themselves'),
|
|
47
|
+
excludeGroups: z.array(z.string()).optional(),
|
|
48
|
+
kinds: z.array(z.enum(GRAPH_KINDS)).optional().describe('local (clip versions), remote (media-pool versions), group (pre/post clip), sequence (timeline-level). Default all.'),
|
|
49
|
+
versions: z.enum(['all', 'active']).optional().describe('all versions of a clip (default) or only the active one'),
|
|
50
|
+
versionNames: strList.optional().describe('Version name glob(s), e.g. "Version 2" or "Pre*"'),
|
|
51
|
+
gradedOnly: z.boolean().optional().describe('Default true: HasCorrection set OR ≥2 nodes. false sweeps single-node ungraded graphs too'),
|
|
52
|
+
minNodes: z.number().int().nonnegative().optional(),
|
|
53
|
+
maxNodes: z.number().int().nonnegative().optional(),
|
|
54
|
+
nodeLabels: strList.optional().describe('Match graphs containing a node whose label matches the glob(s)'),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
|
|
34
58
|
const S = {
|
|
35
59
|
create_empty_project: z.object({ outputPath: io.outputPath, timelineName: z.string().optional() }),
|
|
36
60
|
assemble_timeline: z.object({
|
|
@@ -107,6 +131,23 @@ const S = {
|
|
|
107
131
|
style: z.string().optional(),
|
|
108
132
|
size: z.number().optional(),
|
|
109
133
|
}),
|
|
134
|
+
relayout_node_graphs: z.object({
|
|
135
|
+
drpPath: io.drpPath,
|
|
136
|
+
outputPath: z.string().optional().describe('Where to write the relaid-out .drp (never the source). Omit for a dry run.'),
|
|
137
|
+
dryRun: z.boolean().optional().describe('Report without writing (default: true when outputPath is omitted)'),
|
|
138
|
+
scope: z.union([graphScope, z.array(graphScope)]).optional().describe('One scope (selectors AND) or several (rows union). Omit = every graded graph in the project.'),
|
|
139
|
+
layout: z
|
|
140
|
+
.object({
|
|
141
|
+
originX: z.number().int().optional().describe('Clean-row start x (default 290 — matches native Cleanup Node Graph)'),
|
|
142
|
+
originY: z.number().int().optional().describe('Clean-row y (default 428)'),
|
|
143
|
+
spacingX: z.number().int().optional().describe('Clean-row x spacing (default 495)'),
|
|
144
|
+
spacingY: z.number().int().optional().describe('Lane pitch for stacked branches (default 178 — Resolve\'s vertical placement grid; not yet measured against native Cleanup on a mixer graph)'),
|
|
145
|
+
mode: z.enum(['topology', 'row']).optional().describe('topology (default): rank by wiring, stack branches into lanes; row: one row in list order (pre-2026-09-14 behaviour)'),
|
|
146
|
+
})
|
|
147
|
+
.optional(),
|
|
148
|
+
includeLabels: z.boolean().optional().describe('Decode node labels into every item (slower; implied by scope.nodeLabels)'),
|
|
149
|
+
itemLimit: z.number().int().positive().optional().describe('Cap on report items (default 2000)'),
|
|
150
|
+
}),
|
|
110
151
|
extract_group_grades: z.object({
|
|
111
152
|
drpPath: io.drpPath,
|
|
112
153
|
groups: z.array(z.string()).optional().describe('Color group names; default = all groups in the project'),
|
|
@@ -129,7 +170,7 @@ async function writeOp(fnName, drpPath, opts, outputPath) {
|
|
|
129
170
|
export const drpTool = {
|
|
130
171
|
name: 'drp',
|
|
131
172
|
description:
|
|
132
|
-
'DaVinci Resolve project (.drp) authoring + editing — offline, no Resolve required. Actions: create_empty_project, assemble_timeline, add_media_clip, place_fusion_title, place_generator, place_transition, move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline, relink_media, repoint_media, inject_grades, extract_node_graphs, extract_group_grades, diff, extract_lut_refs, list_nested, read_nested, read_nested_titles, set_nested_title_text.',
|
|
173
|
+
'DaVinci Resolve project (.drp) authoring + editing — offline, no Resolve required. Actions: create_empty_project, assemble_timeline, add_media_clip, place_fusion_title, place_generator, place_transition, move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline, relink_media, repoint_media, inject_grades, extract_node_graphs, relayout_node_graphs (whole-project "Cleanup Node Graph" on an exported .drp — every node graph the project carries: every LOCAL version of every clip, remote versions, group pre/post, timeline-level; scoped by timeline/track/clip id/name/media/frame range/clip position/color group/version/node count/node label; grade content byte-preserved, HasCorrection untouched, dry-run report + read-back verify; round trip: project_manager.export_project → relayout_node_graphs → project_manager.import_project as a sibling project), extract_group_grades, diff, extract_lut_refs, list_nested, read_nested, read_nested_titles, set_nested_title_text.',
|
|
133
174
|
async handler({ action, args }) {
|
|
134
175
|
const gen = drp();
|
|
135
176
|
|
|
@@ -262,6 +303,10 @@ export const drpTool = {
|
|
|
262
303
|
const { drpPath, outputPath, ...opts } = p;
|
|
263
304
|
return writeOp('setNestedTitleText', drpPath, opts, outputPath);
|
|
264
305
|
}
|
|
306
|
+
if (action === 'relayout_node_graphs') {
|
|
307
|
+
const p = S.relayout_node_graphs.parse(args);
|
|
308
|
+
return relayoutDrpNodeGraphs(p.drpPath, p);
|
|
309
|
+
}
|
|
265
310
|
if (action === 'extract_group_grades') {
|
|
266
311
|
const p = S.extract_group_grades.parse(args);
|
|
267
312
|
const groups = await decodeGroupGrades(p.drpPath, { groups: p.groups, includePreClip: p.includePreClip });
|