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.
@@ -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). Inside the protobuf, each F1 container
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) and project_db's `relayout_node_graphs`
17
- * (closed-project bulk patch of ListMgt::LmVersion rows).
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
- if (body[0] !== 0x81) throw new Error('not a grade Body (missing 0x81 magic)');
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
- const positions = options.positions || cleanRowPositions(nodeCount, options);
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 = Buffer.concat([Buffer.from([0x81]), zstd.compress(Buffer.concat(rebuilt))]);
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
- if (body[0] !== 0x81) throw new Error('not a grade Body (missing 0x81 magic)');
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 };
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.5.2"
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()}")
@@ -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
- def archive_project(project_name: str, archive_path: str, archive_src_media: bool = True, archive_render_cache: bool = True, archive_proxy_media: bool = False) -> Dict[str, Any]:
774
- """Archive a project to a file with optional media.
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 file (.dra).
779
- archive_src_media: Include source media in archive. Default: True.
780
- archive_render_cache: Include render cache. Default: True.
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
- result = pm.ArchiveProject(project_name, archive_path, archive_src_media, archive_render_cache, archive_proxy_media)
788
- return {"success": bool(result), "project_name": project_name, "archive_path": archive_path}
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.5.2"
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
- src_media = bool(p.get("src_media", False))
18548
- render_cache = bool(p.get("render_cache", False))
18549
- proxy_media = bool(p.get("proxy_media", False))
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
- return {"success": bool(pm.ArchiveProject(name, path, src_media, render_cache, proxy_media))}
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
- return {"success": bool(pm.ArchiveProject(p["name"], p["path"],
19252
- p.get("src_media", True), p.get("render_cache", True), p.get("proxy_media", False)))}
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")
@@ -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", "safe_project_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"),