davinci-resolve-mcp 4.5.2 → 4.6.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 +101 -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 +26 -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
|
@@ -128,6 +128,8 @@ const relayoutSchema = z
|
|
|
128
128
|
originX: z.number().int().optional().describe('Clean-row start x (default 290 — matches native Cleanup Node Graph)'),
|
|
129
129
|
originY: z.number().int().optional().describe('Clean-row y (default 428)'),
|
|
130
130
|
spacingX: z.number().int().optional().describe('Clean-row x spacing (default 495)'),
|
|
131
|
+
spacingY: z.number().int().optional().describe('Lane pitch for stacked branches (default 178; unmeasured against native Cleanup on mixers)'),
|
|
132
|
+
layout: z.enum(['topology', 'row']).optional().describe('topology (default): rank by wiring, stack branches; row: single row in list order'),
|
|
131
133
|
})
|
|
132
134
|
.refine((a) => a.drxPath || a.content, { message: 'provide drxPath or content' });
|
|
133
135
|
|
|
@@ -815,6 +817,8 @@ export const drxTool = {
|
|
|
815
817
|
originX: p.originX,
|
|
816
818
|
originY: p.originY,
|
|
817
819
|
spacingX: p.spacingX,
|
|
820
|
+
spacingY: p.spacingY,
|
|
821
|
+
layout: p.layout,
|
|
818
822
|
});
|
|
819
823
|
// Splice the new Body into the ORIGINAL envelope (byte-lossless outside the
|
|
820
824
|
// position varints — labels, keyframes, OFX, still metadata all pass through).
|
|
@@ -826,9 +830,9 @@ export const drxTool = {
|
|
|
826
830
|
}
|
|
827
831
|
if (p.outPath) {
|
|
828
832
|
await fs.writeFile(p.outPath, out);
|
|
829
|
-
return { outPath: p.outPath, nodeCount: r.nodeCount, positionsBefore: before, positions: r.positions };
|
|
833
|
+
return { outPath: p.outPath, nodeCount: r.nodeCount, positionsBefore: before, positions: r.positions, layout: r.meta };
|
|
830
834
|
}
|
|
831
|
-
return { content: out, nodeCount: r.nodeCount, positionsBefore: before, positions: r.positions };
|
|
835
|
+
return { content: out, nodeCount: r.nodeCount, positionsBefore: before, positions: r.positions, layout: r.meta };
|
|
832
836
|
}
|
|
833
837
|
|
|
834
838
|
if (action === 'contrast_normalize') {
|
|
@@ -62,6 +62,8 @@ const relayoutSchema = z.object({
|
|
|
62
62
|
originX: z.number().int().optional().describe('Clean-row start x (default 290 — matches native Cleanup Node Graph)'),
|
|
63
63
|
originY: z.number().int().optional().describe('Clean-row y (default 428)'),
|
|
64
64
|
spacingX: z.number().int().optional().describe('Clean-row x spacing (default 495)'),
|
|
65
|
+
spacingY: z.number().int().optional().describe('Lane pitch for stacked branches (default 178)'),
|
|
66
|
+
layout: z.enum(['topology', 'row']).optional().describe('topology (default) or row'),
|
|
65
67
|
iConfirmProjectClosed: confirm,
|
|
66
68
|
});
|
|
67
69
|
|
|
@@ -172,7 +174,7 @@ export const projectDbTool = {
|
|
|
172
174
|
if (action === 'relayout_node_graphs') {
|
|
173
175
|
const p = relayoutSchema.parse(args);
|
|
174
176
|
const layout = require('../../vendor/drx-codec/node-layout.js');
|
|
175
|
-
const layoutOpts = { originX: p.originX, originY: p.originY, spacingX: p.spacingX };
|
|
177
|
+
const layoutOpts = { originX: p.originX, originY: p.originY, spacingX: p.spacingX, spacingY: p.spacingY, layout: p.layout };
|
|
176
178
|
const dbPath = await resolveDbPath(p);
|
|
177
179
|
const write = !p.dryRun;
|
|
178
180
|
if (write) requireClosed(p);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Node-graph LAYOUT rewriter — repositions nodes in a DRX/LmVersion grade Body
|
|
3
3
|
* without touching any grade content.
|
|
4
4
|
*
|
|
5
|
-
* A grade Body is [0x81] + zstd(protobuf)
|
|
5
|
+
* A grade Body is [0x81] + zstd(protobuf), or [0x80] + protobuf STORED uncompressed
|
|
6
|
+
* (how Resolve serialises small/default graphs in .drp exports). Inside the protobuf, each F1 container
|
|
6
7
|
* holds F7 node messages whose F4 (xPos) / F5 (yPos) varints are the node-editor
|
|
7
8
|
* layout. Resolve's own "Cleanup Node Graph" rewrites ONLY those two fields
|
|
8
9
|
* (verified by before/after Project.db diff, 2026-07): its clean layout is an
|
|
@@ -12,9 +13,22 @@
|
|
|
12
13
|
* containing length prefixes) survives verbatim, so the rewrite is lossless for
|
|
13
14
|
* OFX/uncalibrated params, keyframes, labels — everything.
|
|
14
15
|
*
|
|
16
|
+
* TOPOLOGY (2026-09-14): positions come from the graph's own connections, not from
|
|
17
|
+
* the node list order. Each F1 container also carries F8 connection messages
|
|
18
|
+
* (F1 = from node id, F3 = to node id, F5/F6 = source/target port — 64 is the RGB
|
|
19
|
+
* path, 16 is a key link — F2/F4 = slot on multi-input nodes, F7 = creation order)
|
|
20
|
+
* and F9/F10 input/output markers whose F3.F4 name the chain's first and last node.
|
|
21
|
+
* Nodes are ranked by longest RGB path from the source (x), branches of a split are
|
|
22
|
+
* stacked into lanes (y) and a merge returns to its lowest input lane. A plain
|
|
23
|
+
* chain therefore lands on exactly the measured clean row — in CHAIN order, which
|
|
24
|
+
* the list order is not (Resolve lists nodes by id; a node inserted mid-chain has
|
|
25
|
+
* a high id). Key links never move a node. Lane spacing (spacingY, default 178 —
|
|
26
|
+
* Resolve's own vertical placement grid seen in every stacked export) is NOT yet
|
|
27
|
+
* measured against native Cleanup on a mixer graph; the x row is.
|
|
28
|
+
*
|
|
15
29
|
* Consumed by the drx tool's `relayout` action (live single-shot path: grab →
|
|
16
|
-
* relayout → ApplyGradeFromDRX)
|
|
17
|
-
* (closed-project bulk patch
|
|
30
|
+
* relayout → ApplyGradeFromDRX), drp's `relayout_node_graphs` (exported project
|
|
31
|
+
* sweep) and project_db's `relayout_node_graphs` (closed-project bulk patch).
|
|
18
32
|
*/
|
|
19
33
|
|
|
20
34
|
'use strict';
|
|
@@ -65,6 +79,28 @@ async function getZstd() {
|
|
|
65
79
|
return backend;
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
// ---- Body container: 0x81 = zstd-compressed, 0x80 = stored (uncompressed) ----
|
|
83
|
+
|
|
84
|
+
const MAGIC_ZSTD = 0x81;
|
|
85
|
+
const MAGIC_STORED = 0x80;
|
|
86
|
+
|
|
87
|
+
/** Split a Body into its container kind and the raw protobuf. */
|
|
88
|
+
async function unwrapBody(body) {
|
|
89
|
+
if (body[0] === MAGIC_ZSTD) {
|
|
90
|
+
const zstd = await getZstd();
|
|
91
|
+
return { magic: MAGIC_ZSTD, proto: zstd.decompress(body.subarray(1)) };
|
|
92
|
+
}
|
|
93
|
+
if (body[0] === MAGIC_STORED) return { magic: MAGIC_STORED, proto: Buffer.from(body.subarray(1)) };
|
|
94
|
+
throw new Error('not a grade Body (missing 0x81/0x80 magic)');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-wrap a protobuf in the SAME container kind it came from (lossless in kind). */
|
|
98
|
+
async function wrapBody(magic, proto) {
|
|
99
|
+
if (magic === MAGIC_STORED) return Buffer.concat([Buffer.from([MAGIC_STORED]), proto]);
|
|
100
|
+
const zstd = await getZstd();
|
|
101
|
+
return Buffer.concat([Buffer.from([MAGIC_ZSTD]), zstd.compress(proto)]);
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
// ---- minimal protobuf walker (varint + length-delimited only, as the Body uses) ----
|
|
69
105
|
|
|
70
106
|
function readVarint(buf, p) {
|
|
@@ -125,6 +161,160 @@ const F_NODE = 7; // node message inside the F1 container
|
|
|
125
161
|
const F_X = 4;
|
|
126
162
|
const F_Y = 5;
|
|
127
163
|
|
|
164
|
+
// Connection message field numbers.
|
|
165
|
+
const F_CONN = 8;
|
|
166
|
+
const RGB_PORT = 64;
|
|
167
|
+
const KEY_PORT = 16;
|
|
168
|
+
|
|
169
|
+
/** Decode nodes (list order) + connections + chain endpoints from a decompressed Body. */
|
|
170
|
+
function decodeTopology(proto) {
|
|
171
|
+
const nodes = [];
|
|
172
|
+
const edges = [];
|
|
173
|
+
let first = null;
|
|
174
|
+
let last = null;
|
|
175
|
+
for (const t of fields(proto)) {
|
|
176
|
+
if (t.f !== 1 || t.wt !== 2) continue;
|
|
177
|
+
for (const g of fields(t.val)) {
|
|
178
|
+
if (g.f === F_NODE && g.wt === 2) {
|
|
179
|
+
const n = { id: null, index: null, x: null, y: null };
|
|
180
|
+
for (const h of fields(g.val)) {
|
|
181
|
+
if (h.wt !== 0) continue;
|
|
182
|
+
if (h.f === 1) n.id = Number(h.val);
|
|
183
|
+
else if (h.f === 2) n.index = Number(h.val);
|
|
184
|
+
else if (h.f === F_X) n.x = Number(h.val);
|
|
185
|
+
else if (h.f === F_Y) n.y = Number(h.val);
|
|
186
|
+
}
|
|
187
|
+
nodes.push(n);
|
|
188
|
+
} else if (g.f === F_CONN && g.wt === 2) {
|
|
189
|
+
const e = { from: null, to: null, fromPort: RGB_PORT, toPort: RGB_PORT, fromSlot: 0, toSlot: 0, order: 0 };
|
|
190
|
+
for (const h of fields(g.val)) {
|
|
191
|
+
if (h.wt !== 0) continue;
|
|
192
|
+
const v = Number(h.val);
|
|
193
|
+
if (h.f === 1) e.from = v;
|
|
194
|
+
else if (h.f === 2) e.fromSlot = v;
|
|
195
|
+
else if (h.f === 3) e.to = v;
|
|
196
|
+
else if (h.f === 4) e.toSlot = v;
|
|
197
|
+
else if (h.f === 5) e.fromPort = v;
|
|
198
|
+
else if (h.f === 6) e.toPort = v;
|
|
199
|
+
else if (h.f === 7) e.order = v;
|
|
200
|
+
}
|
|
201
|
+
if (e.from !== null && e.to !== null) edges.push(e);
|
|
202
|
+
} else if ((g.f === 9 || g.f === 10) && g.wt === 2) {
|
|
203
|
+
for (const h of fields(g.val)) {
|
|
204
|
+
if (h.f !== 3 || h.wt !== 2) continue;
|
|
205
|
+
for (const k of fields(h.val)) if (k.f === 4 && k.wt === 0) (g.f === 9 ? (first = Number(k.val)) : (last = Number(k.val)));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { nodes, edges, first, last };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Plan positions from topology. Returns positions in NODE LIST order (what
|
|
215
|
+
* relayoutBody writes) plus a meta block. Falls back to the plain row — in
|
|
216
|
+
* chain order when the graph has a chain, else list order — on anything it
|
|
217
|
+
* cannot rank (cycles, dangling ids).
|
|
218
|
+
*
|
|
219
|
+
* Lanes: walking ranks left→right, a node with k RGB outputs fans its children
|
|
220
|
+
* into lanes lane, lane+1, … (ordered by the target's input slot, then creation
|
|
221
|
+
* order); a node with several RGB inputs (a mixer) sits on the lowest of its
|
|
222
|
+
* input lanes; two nodes claiming one (rank, lane) cell push the later one down.
|
|
223
|
+
*/
|
|
224
|
+
function planPositions(topo, { originX = 290, originY = 428, spacingX = 495, spacingY = 178, layout = 'topology' } = {}) {
|
|
225
|
+
const n = topo.nodes.length;
|
|
226
|
+
const ids = topo.nodes.map((v) => v.id);
|
|
227
|
+
const byId = new Map(ids.map((id, i) => [id, i]));
|
|
228
|
+
const rowPositions = (orderIdx) => {
|
|
229
|
+
const pos = new Array(n);
|
|
230
|
+
orderIdx.forEach((listIdx, k) => (pos[listIdx] = [originX + k * spacingX, originY]));
|
|
231
|
+
return pos;
|
|
232
|
+
};
|
|
233
|
+
const listOrder = topo.nodes.map((_, i) => i);
|
|
234
|
+
if (n === 0) return { positions: [], meta: { kind: 'empty', ranks: 0, lanes: 0 } };
|
|
235
|
+
if (layout === 'row' || n === 1) return { positions: rowPositions(listOrder), meta: { kind: 'row', ranks: n, lanes: 1 } };
|
|
236
|
+
|
|
237
|
+
const flow = topo.edges.filter((e) => !(e.fromPort === KEY_PORT && e.toPort === KEY_PORT) && byId.has(e.from) && byId.has(e.to) && e.from !== e.to);
|
|
238
|
+
const keyLinks = topo.edges.length - flow.length;
|
|
239
|
+
if (!flow.length) {
|
|
240
|
+
// No wiring at all: order by Resolve's own index field, then list order.
|
|
241
|
+
const order = [...listOrder].sort((a, b) => (topo.nodes[a].index ?? 1e9) - (topo.nodes[b].index ?? 1e9) || a - b);
|
|
242
|
+
return { positions: rowPositions(order), meta: { kind: 'row', ranks: n, lanes: 1, keyLinks } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const out = new Map(ids.map((id) => [id, []]));
|
|
246
|
+
const indeg = new Map(ids.map((id) => [id, 0]));
|
|
247
|
+
for (const e of flow) {
|
|
248
|
+
out.get(e.from).push(e);
|
|
249
|
+
indeg.set(e.to, indeg.get(e.to) + 1);
|
|
250
|
+
}
|
|
251
|
+
// Branch order at a fan-out: the slot this edge lands in, else the slot the
|
|
252
|
+
// branch eventually feeds on its merge (Resolve numbers mixer inputs top-down),
|
|
253
|
+
// else creation order.
|
|
254
|
+
const mergeSlot = (id) => Math.min(Infinity, ...(out.get(id) || []).map((e) => e.toSlot || Infinity));
|
|
255
|
+
for (const list of out.values()) list.sort((a, b) => (a.toSlot || 0) - (b.toSlot || 0) || mergeSlot(a.to) - mergeSlot(b.to) || a.order - b.order);
|
|
256
|
+
|
|
257
|
+
// Longest-path ranks (Kahn); a cycle leaves nodes unranked → fall back.
|
|
258
|
+
const rank = new Map();
|
|
259
|
+
const queue = ids.filter((id) => indeg.get(id) === 0);
|
|
260
|
+
const remaining = new Map(indeg);
|
|
261
|
+
for (const id of queue) rank.set(id, 0);
|
|
262
|
+
while (queue.length) {
|
|
263
|
+
const id = queue.shift();
|
|
264
|
+
for (const e of out.get(id)) {
|
|
265
|
+
rank.set(e.to, Math.max(rank.get(e.to) ?? 0, rank.get(id) + 1));
|
|
266
|
+
remaining.set(e.to, remaining.get(e.to) - 1);
|
|
267
|
+
if (remaining.get(e.to) === 0) queue.push(e.to);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (rank.size !== n) {
|
|
271
|
+
const order = [...listOrder].sort((a, b) => (topo.nodes[a].index ?? 1e9) - (topo.nodes[b].index ?? 1e9) || a - b);
|
|
272
|
+
return { positions: rowPositions(order), meta: { kind: 'row', ranks: n, lanes: 1, keyLinks, fallback: 'unrankable graph' } };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Lanes, walking ranks left → right (parents are always placed before children):
|
|
276
|
+
// no input → lane 0
|
|
277
|
+
// one input p → lane(p) + k, k = this edge's position among p's outputs
|
|
278
|
+
// several inputs → the lowest input lane (a merge comes back to the main line)
|
|
279
|
+
// A (rank, lane) cell taken by an earlier node pushes the later one down.
|
|
280
|
+
const lane = new Map();
|
|
281
|
+
const taken = new Set();
|
|
282
|
+
const claim = (id, want) => {
|
|
283
|
+
let l = want;
|
|
284
|
+
while (taken.has(`${rank.get(id)}:${l}`)) l += 1;
|
|
285
|
+
lane.set(id, l);
|
|
286
|
+
taken.add(`${rank.get(id)}:${l}`);
|
|
287
|
+
};
|
|
288
|
+
const idx = (id) => topo.nodes[byId.get(id)].index ?? 0;
|
|
289
|
+
const byRank = [...ids].sort((a, b) => rank.get(a) - rank.get(b) || (a === topo.first ? -1 : b === topo.first ? 1 : 0) || idx(a) - idx(b));
|
|
290
|
+
const inputsOf = new Map(ids.map((id) => [id, []]));
|
|
291
|
+
for (const e of flow) inputsOf.get(e.to).push(e);
|
|
292
|
+
for (const id of byRank) {
|
|
293
|
+
const ins = inputsOf.get(id);
|
|
294
|
+
let want = 0;
|
|
295
|
+
if (ins.length === 1) {
|
|
296
|
+
const p = ins[0].from;
|
|
297
|
+
const k = out.get(p).findIndex((e) => e.to === id);
|
|
298
|
+
want = (lane.get(p) ?? 0) + Math.max(0, k);
|
|
299
|
+
} else if (ins.length > 1) {
|
|
300
|
+
want = Math.min(...ins.map((e) => lane.get(e.from) ?? 0));
|
|
301
|
+
}
|
|
302
|
+
claim(id, want);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const positions = new Array(n);
|
|
306
|
+
let maxRank = 0;
|
|
307
|
+
let maxLane = 0;
|
|
308
|
+
for (const id of ids) {
|
|
309
|
+
const r = rank.get(id);
|
|
310
|
+
const l = lane.get(id);
|
|
311
|
+
maxRank = Math.max(maxRank, r);
|
|
312
|
+
maxLane = Math.max(maxLane, l);
|
|
313
|
+
positions[byId.get(id)] = [originX + r * spacingX, originY + l * spacingY];
|
|
314
|
+
}
|
|
315
|
+
return { positions, meta: { kind: maxLane > 0 ? 'dag' : 'chain', ranks: maxRank + 1, lanes: maxLane + 1, keyLinks } };
|
|
316
|
+
}
|
|
317
|
+
|
|
128
318
|
/**
|
|
129
319
|
* Resolve-cleanup-style positions for n nodes: one evenly spaced row.
|
|
130
320
|
* Defaults are the exact values Resolve's own Cleanup Node Graph produced
|
|
@@ -169,9 +359,7 @@ function rewriteNodeMessage(nodeBuf, x, y) {
|
|
|
169
359
|
*/
|
|
170
360
|
async function relayoutBody(body, options = {}) {
|
|
171
361
|
if (!Buffer.isBuffer(body)) body = Buffer.from(body);
|
|
172
|
-
|
|
173
|
-
const zstd = await getZstd();
|
|
174
|
-
const proto = zstd.decompress(body.subarray(1));
|
|
362
|
+
const { magic, proto } = await unwrapBody(body);
|
|
175
363
|
|
|
176
364
|
// First pass: count nodes so the default layout can be sized.
|
|
177
365
|
const top = fields(proto);
|
|
@@ -181,7 +369,13 @@ async function relayoutBody(body, options = {}) {
|
|
|
181
369
|
}
|
|
182
370
|
if (nodeCount === 0) throw new Error('relayout refused: Body decodes to 0 nodes');
|
|
183
371
|
|
|
184
|
-
|
|
372
|
+
let meta = { kind: 'explicit', ranks: nodeCount, lanes: 1 };
|
|
373
|
+
let positions = options.positions;
|
|
374
|
+
if (!positions) {
|
|
375
|
+
const planned = planPositions(decodeTopology(proto), options);
|
|
376
|
+
positions = planned.positions;
|
|
377
|
+
meta = planned.meta;
|
|
378
|
+
}
|
|
185
379
|
if (positions.length < nodeCount) {
|
|
186
380
|
throw new Error(`positions has ${positions.length} entries but the grade has ${nodeCount} nodes`);
|
|
187
381
|
}
|
|
@@ -213,8 +407,8 @@ async function relayoutBody(body, options = {}) {
|
|
|
213
407
|
}
|
|
214
408
|
}
|
|
215
409
|
|
|
216
|
-
const out =
|
|
217
|
-
return { body: out, nodeCount, positions: positions.slice(0, nodeCount) };
|
|
410
|
+
const out = await wrapBody(magic, Buffer.concat(rebuilt));
|
|
411
|
+
return { body: out, nodeCount, positions: positions.slice(0, nodeCount), meta };
|
|
218
412
|
}
|
|
219
413
|
|
|
220
414
|
/** Hex-string convenience wrapper (DRX <Body> / DB dumps are handled as hex upstream). */
|
|
@@ -223,12 +417,19 @@ async function relayoutBodyHex(bodyHex, options = {}) {
|
|
|
223
417
|
return { ...r, bodyHex: r.body.toString('hex') };
|
|
224
418
|
}
|
|
225
419
|
|
|
420
|
+
/** Decode nodes + connections + planned positions without rewriting anything. */
|
|
421
|
+
async function readTopology(body, options = {}) {
|
|
422
|
+
if (!Buffer.isBuffer(body)) body = Buffer.from(body);
|
|
423
|
+
const { proto } = await unwrapBody(body);
|
|
424
|
+
const topo = decodeTopology(proto);
|
|
425
|
+
const planned = planPositions(topo, options);
|
|
426
|
+
return { ...topo, planned: planned.positions, meta: planned.meta };
|
|
427
|
+
}
|
|
428
|
+
|
|
226
429
|
/** Decode just the node positions from a Body (for dry runs / verification). */
|
|
227
430
|
async function readNodePositions(body) {
|
|
228
431
|
if (!Buffer.isBuffer(body)) body = Buffer.from(body);
|
|
229
|
-
|
|
230
|
-
const zstd = await getZstd();
|
|
231
|
-
const proto = zstd.decompress(body.subarray(1));
|
|
432
|
+
const { proto } = await unwrapBody(body);
|
|
232
433
|
const positions = [];
|
|
233
434
|
for (const t of fields(proto)) {
|
|
234
435
|
if (t.f !== 1 || t.wt !== 2) continue;
|
|
@@ -246,4 +447,4 @@ async function readNodePositions(body) {
|
|
|
246
447
|
return positions;
|
|
247
448
|
}
|
|
248
449
|
|
|
249
|
-
module.exports = { relayoutBody, relayoutBodyHex, readNodePositions, cleanRowPositions };
|
|
450
|
+
module.exports = { relayoutBody, relayoutBodyHex, readNodePositions, readTopology, decodeTopology, planPositions, cleanRowPositions, unwrapBody, wrapBody, encodeVarint };
|
package/src/granular/common.py
CHANGED
|
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
|
|
|
93
93
|
handlers=[logging.StreamHandler()],
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
VERSION = "4.
|
|
96
|
+
VERSION = "4.6.0"
|
|
97
97
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
98
98
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
99
99
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/granular/project.py
CHANGED
|
@@ -769,23 +769,45 @@ def get_project_info_endpoint() -> Dict[str, Any]:
|
|
|
769
769
|
return get_project_info(current_project)
|
|
770
770
|
|
|
771
771
|
|
|
772
|
-
@mcp.tool()
|
|
773
|
-
|
|
774
|
-
|
|
772
|
+
@mcp.tool(annotations=DESTRUCTIVE_TOOL)
|
|
773
|
+
@granular_destructive_op()
|
|
774
|
+
def archive_project(project_name: str, archive_path: str, archive_src_media: bool = False,
|
|
775
|
+
archive_render_cache: bool = False, archive_proxy_media: bool = False,
|
|
776
|
+
acknowledge_trap: bool = False) -> Dict[str, Any]:
|
|
777
|
+
"""Archive a project to a file. On Resolve 21.1.0.14 this never produces an archive.
|
|
778
|
+
|
|
779
|
+
Measured: with source media and proxies off, ArchiveProject returns False and
|
|
780
|
+
writes nothing, open or closed. With either on it crashes Resolve and loses
|
|
781
|
+
unsaved work in the open project. So every flag defaults off, and the two
|
|
782
|
+
crashing flags are refused unless acknowledge_trap is true. The result
|
|
783
|
+
reports what the native call returned. See src/utils/archive_guard.py.
|
|
775
784
|
|
|
776
785
|
Args:
|
|
777
786
|
project_name: Name of the project to archive.
|
|
778
|
-
archive_path: Absolute path for the archive
|
|
779
|
-
archive_src_media: Include source media
|
|
780
|
-
archive_render_cache: Include render cache. Default:
|
|
781
|
-
archive_proxy_media: Include proxy media. Default: False.
|
|
787
|
+
archive_path: Absolute path for the archive (.dra).
|
|
788
|
+
archive_src_media: Include source media. Crashes Resolve 21.1.0.14. Default: False.
|
|
789
|
+
archive_render_cache: Include render cache. Default: False.
|
|
790
|
+
archive_proxy_media: Include proxy media. Crashes Resolve 21.1.0.14. Default: False.
|
|
791
|
+
acknowledge_trap: Must be true to send either crashing flag.
|
|
782
792
|
"""
|
|
793
|
+
from src.utils import archive_guard
|
|
794
|
+
flags, flag_err = archive_guard.read_flags({
|
|
795
|
+
"src_media": archive_src_media, "render_cache": archive_render_cache,
|
|
796
|
+
"proxy_media": archive_proxy_media})
|
|
797
|
+
if flag_err:
|
|
798
|
+
return {"error": flag_err}
|
|
799
|
+
if not acknowledge_trap:
|
|
800
|
+
refused = archive_guard.crash_refusal("granular", "archive_project", flags)
|
|
801
|
+
if refused:
|
|
802
|
+
return refused
|
|
783
803
|
resolve = get_resolve()
|
|
784
804
|
if resolve is None:
|
|
785
805
|
return {"error": "Not connected to DaVinci Resolve"}
|
|
786
806
|
pm = resolve.GetProjectManager()
|
|
787
|
-
|
|
788
|
-
|
|
807
|
+
native = pm.ArchiveProject(project_name, archive_path, flags["src_media"],
|
|
808
|
+
flags["render_cache"], flags["proxy_media"])
|
|
809
|
+
return archive_guard.outcome(native, flags, project_name=project_name,
|
|
810
|
+
archive_path=archive_path)
|
|
789
811
|
|
|
790
812
|
|
|
791
813
|
@mcp.tool()
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "4.
|
|
14
|
+
VERSION = "4.6.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -52,6 +52,7 @@ from src.utils.resolve211_edits import validate_edit_options, validate_transitio
|
|
|
52
52
|
|
|
53
53
|
# Platform-specific Resolve paths
|
|
54
54
|
from src.utils.cdl import normalize_cdl_payload
|
|
55
|
+
from src.utils import archive_guard
|
|
55
56
|
from src.utils import typed_api_search
|
|
56
57
|
from src.utils import lut_files
|
|
57
58
|
from src.utils import resolve_writes as _resolve_writes
|
|
@@ -18544,11 +18545,18 @@ def _safe_project_archive(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
18544
18545
|
path_err = _project_path_guard(path, require_temp_path=p.get("require_temp_path", True))
|
|
18545
18546
|
if path_err:
|
|
18546
18547
|
return path_err
|
|
18547
|
-
|
|
18548
|
-
|
|
18549
|
-
|
|
18548
|
+
flags, flag_err = archive_guard.read_flags(p)
|
|
18549
|
+
if flag_err:
|
|
18550
|
+
return _err(flag_err, code="INVALID_ARCHIVE_FLAG", category="invalid_input")
|
|
18551
|
+
src_media, render_cache, proxy_media = (flags["src_media"], flags["render_cache"],
|
|
18552
|
+
flags["proxy_media"])
|
|
18550
18553
|
if (src_media or render_cache or proxy_media) and not p.get("allow_media_archive", False):
|
|
18551
18554
|
return _err("Archive media/cache/proxy flags must stay false unless allow_media_archive=True")
|
|
18555
|
+
# allow_media_archive guards size; this guards the crash. Both are required.
|
|
18556
|
+
if not p.get("acknowledge_trap"):
|
|
18557
|
+
refused = archive_guard.crash_refusal("project_manager", "safe_project_archive", flags)
|
|
18558
|
+
if refused:
|
|
18559
|
+
return refused
|
|
18552
18560
|
os.makedirs(_project_path_parent(path), exist_ok=True)
|
|
18553
18561
|
if p.get("dry_run"):
|
|
18554
18562
|
return _ok(
|
|
@@ -18559,7 +18567,8 @@ def _safe_project_archive(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
18559
18567
|
render_cache=render_cache,
|
|
18560
18568
|
proxy_media=proxy_media,
|
|
18561
18569
|
)
|
|
18562
|
-
|
|
18570
|
+
native = pm.ArchiveProject(name, path, src_media, render_cache, proxy_media)
|
|
18571
|
+
return archive_guard.outcome(native, flags, name=name, path=path)
|
|
18563
18572
|
|
|
18564
18573
|
|
|
18565
18574
|
def _safe_project_restore(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -19248,8 +19257,18 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
19248
19257
|
})
|
|
19249
19258
|
if err:
|
|
19250
19259
|
return _err(err)
|
|
19251
|
-
|
|
19252
|
-
|
|
19260
|
+
# Every flag defaults OFF. The native defaults turn source media on, and on
|
|
19261
|
+
# 21.1.0.14 that crashes Resolve (see utils/archive_guard.py).
|
|
19262
|
+
flags, flag_err = archive_guard.read_flags(p)
|
|
19263
|
+
if flag_err:
|
|
19264
|
+
return _err(flag_err, code="INVALID_ARCHIVE_FLAG", category="invalid_input")
|
|
19265
|
+
if not p.get("acknowledge_trap"):
|
|
19266
|
+
refused = archive_guard.crash_refusal("project_manager", "archive", flags)
|
|
19267
|
+
if refused:
|
|
19268
|
+
return refused
|
|
19269
|
+
native = pm.ArchiveProject(p["name"], p["path"], flags["src_media"],
|
|
19270
|
+
flags["render_cache"], flags["proxy_media"])
|
|
19271
|
+
return archive_guard.outcome(native, flags, name=p["name"], path=p["path"])
|
|
19253
19272
|
elif action == "restore":
|
|
19254
19273
|
if not p.get("path"):
|
|
19255
19274
|
return _err("restore requires path")
|
package/src/utils/api_truth.py
CHANGED
|
@@ -3373,6 +3373,47 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
3373
3373
|
"second contributor. The current-timeline pointer moved to "
|
|
3374
3374
|
"the duplicate, and SetCurrentTimeline put it back.",
|
|
3375
3375
|
},
|
|
3376
|
+
{
|
|
3377
|
+
"symbol": "ProjectManager.ArchiveProject",
|
|
3378
|
+
"object": "ProjectManager",
|
|
3379
|
+
"signature": "(projectName, filePath, isArchiveSrcMedia=True, isArchiveRenderCache=True, isArchiveProxyMedia=False) -> bool",
|
|
3380
|
+
"reality": "No scriptable call produces an archive. With source media and "
|
|
3381
|
+
"proxies off it returns False instantly and writes nothing, for an "
|
|
3382
|
+
"open or a closed project and with render cache on or off. With "
|
|
3383
|
+
"isArchiveSrcMedia or isArchiveProxyMedia on it creates an empty "
|
|
3384
|
+
"directory at the target and Resolve crashes (SIGSEGV) in the same "
|
|
3385
|
+
"second; the call comes back through the bridge as None and every "
|
|
3386
|
+
"later handle is dead. Four crashes, one of them in a Blackmagic "
|
|
3387
|
+
"Cloud library, share identical top stack frames in Fusion "
|
|
3388
|
+
"script-symbol teardown on the UI thread; a separate crash in the "
|
|
3389
|
+
"same session during DeleteProject/LoadProject had a different "
|
|
3390
|
+
"stack, so the signature belongs to the archive calls. Media-flag "
|
|
3391
|
+
"calls crashed 4 of 4, flags-off calls 0 of 5. A file already at the "
|
|
3392
|
+
"target survived every case byte for byte, including a crash, so "
|
|
3393
|
+
"the destination is never overwritten; unsaved work in the open "
|
|
3394
|
+
"project is what is lost. The native defaults turn source media on, "
|
|
3395
|
+
"so a default call crashes Resolve. Resolve logs nothing about the "
|
|
3396
|
+
"False returns.",
|
|
3397
|
+
"recommended": "Keep isArchiveSrcMedia and isArchiveProxyMedia off unless you "
|
|
3398
|
+
"have verified the build, and save every open project first. "
|
|
3399
|
+
"Treat False as 'nothing archived', not as a path problem: a "
|
|
3400
|
+
".dra and a folder-style path fail identically. Archive from "
|
|
3401
|
+
"the Project Manager UI when you need a real archive.",
|
|
3402
|
+
"tags": ["crash", "unreliable-return", "silent-failure", "project", "reported"],
|
|
3403
|
+
"submit": "bug",
|
|
3404
|
+
"verified_on": "DaVinci Resolve Studio 21.1.0.14",
|
|
3405
|
+
"measured": "2026-09-14 on a disposable local project with one synthetic "
|
|
3406
|
+
"clip, one isolated call per case: all flags off (open project) "
|
|
3407
|
+
"False; all off (closed) False; render cache only False; source "
|
|
3408
|
+
"media None + crash + empty dir; proxy media None + crash + empty "
|
|
3409
|
+
"dir; unrelated file at target with flags off False, file "
|
|
3410
|
+
"byte-identical; populated directory at target with flags off "
|
|
3411
|
+
"False, untouched; source media onto an existing file None + "
|
|
3412
|
+
"crash, file byte-identical. 19.1.3.7 (mode matrix 2026-08-02, "
|
|
3413
|
+
"GUI and headless): False for .dra and folder paths, flags off.",
|
|
3414
|
+
"mitigation": ["project_manager.archive", "project_manager.safe_project_archive",
|
|
3415
|
+
"archive_project"],
|
|
3416
|
+
},
|
|
3376
3417
|
|
|
3377
3418
|
]
|
|
3378
3419
|
|
|
@@ -3432,6 +3473,8 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
|
|
|
3432
3473
|
("timeline_item_color", "export_lut"): ["TimelineItem.ExportLUT"],
|
|
3433
3474
|
("timeline_item_color", "safe_export_lut"): ["TimelineItem.ExportLUT"],
|
|
3434
3475
|
("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
|
|
3476
|
+
("project_manager", "archive"): ["ProjectManager.ArchiveProject"],
|
|
3477
|
+
("project_manager", "safe_project_archive"): ["ProjectManager.ArchiveProject"],
|
|
3435
3478
|
}
|
|
3436
3479
|
|
|
3437
3480
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""`ProjectManager.ArchiveProject` on Resolve 21.1: a silent no-op or a crash.
|
|
2
|
+
|
|
3
|
+
Measured on Studio 21.1.0.14 against a disposable local project holding one
|
|
4
|
+
synthetic clip, archived by name. Every row is one isolated call.
|
|
5
|
+
|
|
6
|
+
================================= ======== ====================================
|
|
7
|
+
include flags returns effect
|
|
8
|
+
================================= ======== ====================================
|
|
9
|
+
all off (project open or closed) ``False`` nothing written, instantly
|
|
10
|
+
render cache only ``False`` nothing written, instantly
|
|
11
|
+
source media ``None`` empty directory at the target, then
|
|
12
|
+
Resolve crashes (SIGSEGV)
|
|
13
|
+
proxy media ``None`` same crash
|
|
14
|
+
================================= ======== ====================================
|
|
15
|
+
|
|
16
|
+
Four crashes, one of them inside a Blackmagic Cloud library, share identical top
|
|
17
|
+
stack frames, in Fusion script-symbol teardown on the UI thread. A crashing call
|
|
18
|
+
comes back through the bridge as ``None`` rather than a bool, and every handle
|
|
19
|
+
after it is dead. A file already at the target survived every case byte for
|
|
20
|
+
byte, including the crash, so the destination is never the casualty. Unsaved
|
|
21
|
+
work in whatever project is open is.
|
|
22
|
+
|
|
23
|
+
On 19.1.3.7 this repo's mode matrix already recorded ``False`` for both a ``.dra``
|
|
24
|
+
and a folder-style path with every flag off. No scriptable call on either build
|
|
25
|
+
has produced an archive.
|
|
26
|
+
|
|
27
|
+
So the native defaults, ``isArchiveSrcMedia=True`` and ``isArchiveRenderCache=
|
|
28
|
+
True``, crash Resolve 21.1.0.14, and so did both wrappers here, which inherited
|
|
29
|
+
them. These helpers default every flag to off, refuse the two crashing flags
|
|
30
|
+
unless the caller says ``acknowledge_trap``, and report what the native call
|
|
31
|
+
actually returned rather than a bare bool.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from typing import Any, Dict, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
#: Flags measured to crash Resolve 21.1.0.14 when set.
|
|
37
|
+
CRASH_FLAGS = ("src_media", "proxy_media")
|
|
38
|
+
FLAG_NAMES = ("src_media", "render_cache", "proxy_media")
|
|
39
|
+
|
|
40
|
+
_MEASURED = ("Measured on DaVinci Resolve Studio 21.1.0.14; the full table is in "
|
|
41
|
+
"src/utils/archive_guard.py and the ProjectManager.ArchiveProject "
|
|
42
|
+
"api_truth entry.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_flags(params: Dict[str, Any]) -> Tuple[Optional[Dict[str, bool]], Optional[str]]:
|
|
46
|
+
"""Every include flag, defaulting to off. Only real booleans are accepted.
|
|
47
|
+
|
|
48
|
+
A JSON caller sending ``"false"`` would have turned a flag ON through a bare
|
|
49
|
+
``bool()``, and two of these flags crash Resolve.
|
|
50
|
+
"""
|
|
51
|
+
flags: Dict[str, bool] = {}
|
|
52
|
+
for name in FLAG_NAMES:
|
|
53
|
+
value = params.get(name, False)
|
|
54
|
+
if not isinstance(value, bool):
|
|
55
|
+
return None, (f"{name} must be true or false, got {value!r}. Two of the "
|
|
56
|
+
"archive flags crash Resolve 21.1, so a non-boolean is "
|
|
57
|
+
"refused rather than guessed at.")
|
|
58
|
+
flags[name] = value
|
|
59
|
+
return flags, None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def crash_refusal(tool: str, action: str, flags: Dict[str, bool]) -> Optional[Dict[str, Any]]:
|
|
63
|
+
"""Refuse the crashing flags unless the caller explicitly accepts the risk."""
|
|
64
|
+
crashing = [f for f in CRASH_FLAGS if flags.get(f)]
|
|
65
|
+
if not crashing:
|
|
66
|
+
return None
|
|
67
|
+
return {
|
|
68
|
+
"success": False,
|
|
69
|
+
"error": (f"'{tool}.{action}' is refused: with {' and '.join(crashing)} on, "
|
|
70
|
+
"ProjectManager.ArchiveProject crashes Resolve 21.1.0.14 before "
|
|
71
|
+
"writing anything but an empty directory, and unsaved work in the "
|
|
72
|
+
"open project is lost. Re-send with acknowledge_trap=true only on a "
|
|
73
|
+
"build you have verified."),
|
|
74
|
+
"known_limitation": [_MEASURED],
|
|
75
|
+
"crash_flags": crashing,
|
|
76
|
+
"retry_with": {"acknowledge_trap": True},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def outcome(native: Any, flags: Dict[str, bool], **extra: Any) -> Dict[str, Any]:
|
|
81
|
+
"""Report the native return as an observation, never as an assumption."""
|
|
82
|
+
out: Dict[str, Any] = {"flags": dict(flags), "native_returned": native}
|
|
83
|
+
out.update(extra)
|
|
84
|
+
if native is True:
|
|
85
|
+
out["success"] = True
|
|
86
|
+
elif native is None:
|
|
87
|
+
out["success"] = False
|
|
88
|
+
out["error"] = ("ArchiveProject returned no result. On 21.1.0.14 that is what a "
|
|
89
|
+
"crashed Resolve looks like through the bridge; check whether "
|
|
90
|
+
"Resolve is still running.")
|
|
91
|
+
else:
|
|
92
|
+
out["success"] = False
|
|
93
|
+
out["error"] = ("Resolve returned False and wrote nothing. With source media and "
|
|
94
|
+
"proxies off this is the measured result on 21.1.0.14 whether the "
|
|
95
|
+
"project is open or closed; no scriptable call has produced an "
|
|
96
|
+
"archive on 21.1.0.14 or 19.1.3.7.")
|
|
97
|
+
return out
|
|
@@ -104,7 +104,8 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
|
104
104
|
"delete_by_color",
|
|
105
105
|
"delete_by_custom_data",
|
|
106
106
|
}),
|
|
107
|
-
"project_manager": frozenset({"delete", "
|
|
107
|
+
"project_manager": frozenset({"archive", "delete", "safe_project_archive",
|
|
108
|
+
"safe_project_delete"}),
|
|
108
109
|
"project_settings": frozenset({"delete_color_group"}),
|
|
109
110
|
"render": frozenset({"delete_all_jobs", "delete_job", "delete_preset"}),
|
|
110
111
|
"render_presets": frozenset({"delete_burnin"}),
|
|
@@ -369,6 +370,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
|
|
|
369
370
|
("script_plugin", "safe_install_extension"),
|
|
370
371
|
("script_plugin", "safe_remove_extension"),
|
|
371
372
|
("project_manager", "safe_project_delete"),
|
|
373
|
+
("project_manager", "safe_project_archive"),
|
|
372
374
|
("lut", "attenuate"),
|
|
373
375
|
("lut", "install"),
|
|
374
376
|
("lut", "remove"),
|