davinci-resolve-mcp 2.46.0 → 2.47.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 CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.47.0
6
+
7
+ Edit-engine plan browser in the control panel (Media → Edit Plans) — the
8
+ panel UX pass for the v2.45.0 edit engine. Chat-first: the panel surfaces
9
+ plans and evidence; execution stays in chat behind the confirm-token gate.
10
+
11
+ - **Added** a plan browser at `#analysis/review/plans`: every saved
12
+ `edit_engine` plan with kind, summary, save time, and an `executed` chip;
13
+ fingerprint-corrupt plans surface as warning rows instead of being hidden.
14
+ - **Added** plan detail views per kind: selects decisions render with shot
15
+ thumbnails, rank, duration, rationale, and a deep link to the shot page;
16
+ tighten plans list each dead-air lift with its transcript-gap evidence and
17
+ skipped items; swap plans show the current item plus numbered alternates
18
+ with similarity scores. Executed plans show their execution readback.
19
+ - **Added** a copyable per-kind execute chat prompt on each plan (the panel
20
+ never executes; swaps include an `alternate_index` placeholder).
21
+ - **Added** `/api/edit_plans` + `/api/edit_plans/<plan_id>` panel endpoints
22
+ (DB/file only — no Resolve round-trips; decisions are enriched server-side
23
+ with `resolve_clip_id` and a `thumb_frame_index` for the existing frames
24
+ route).
25
+ - **Changed** `edit_engine.list_plans` gained `include_corrupt` (default off;
26
+ the MCP action shape is unchanged).
27
+
28
+ ## What's New in v2.46.1
29
+
30
+ Test and hygiene hardening; no public tool surface changes.
31
+
32
+ - **Fixed** the three `InventoryCacheReuseTests` failures that appeared
33
+ whenever a live Resolve instance was running: the reuse/build-path tests
34
+ now stub the read-only Resolve probe, so the suite is green with Resolve
35
+ open or closed.
36
+ - **Added** `src/utils/project_cleanup.delete_project_safely`: a retrying
37
+ delete helper for disposable Resolve projects (switch away from the target,
38
+ retry `DeleteProject` once after a pause, report the leftover by name on
39
+ persistent failure). The live edit-engine validation harness now uses it
40
+ during cleanup, so disposable pilot projects stop lingering in the library.
41
+
5
42
  ## What's New in v2.46.0
6
43
 
7
44
  Community PR bundle: five contributed fixes and features (#62–#66), live-validated
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.46.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.47.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -706,6 +706,12 @@ The engine needs the analysis substrate: analyzed clips in the DB (run
706
706
  `db_ingest` on older roots), transcripts for tighten, and visual embeddings
707
707
  (`build_embeddings(kinds=['visual'])`) for swap.
708
708
 
709
+ Plans are reviewable in the control panel (Media → Edit Plans, v2.47.0+):
710
+ decisions/lifts/alternates with thumbnails and rationale, deep links to shot
711
+ pages, and a copyable per-kind execute prompt. The panel never executes —
712
+ when the user says they reviewed a plan there, execution still comes back
713
+ through chat with the confirm-token gate.
714
+
709
715
  **`timeline_versioning`** — Version-on-mutate, archive, rollback, brain-edit history (C6).
710
716
 
711
717
  Every destructive timeline op (compound, captions, ripple delete, gap close,
@@ -26,10 +26,10 @@ pidfile and `resolve_control(action="close_control_panel")` stops it.
26
26
 
27
27
  ## Navigation and deep links
28
28
 
29
- Top-level sections: **Overview**, **Media** (Inventory / Review / History),
30
- **AI Console**, **Setup** (Resolve / MCP / Storage / Tools / Media Pool
31
- History), **Docs**, and **Preferences** (Analysis / Caps + Safety / Metadata
32
- and Markers / Paths and Workflow / MCP Updates). The project selector on the
29
+ Top-level sections: **Overview**, **Media** (Inventory / Review / History /
30
+ Edit Plans), **AI Console**, **Setup** (Resolve / MCP / Storage / Tools /
31
+ Media Pool History), **Docs**, and **Preferences** (Analysis / Caps + Safety /
32
+ Metadata and Markers / Paths and Workflow / MCP Updates). The project selector on the
33
33
  right scopes the panel to an analysis context; **View All Projects** opens a
34
34
  read-only browser over the Resolve project database with confirm-gated
35
35
  loading.
@@ -42,6 +42,7 @@ into chat:
42
42
  #analysis/media #analysis/review
43
43
  #analysis/review/history #analysis/review/clip/<clip_id>
44
44
  #analysis/review/clip/<clip_id>/shot/<n> #analysis/review/clip/<clip_id>/transcript
45
+ #analysis/review/plans #analysis/review/plans/<plan_id>
45
46
  #diagnostics/mcp #preferences/caps
46
47
  ```
47
48
 
@@ -131,6 +132,26 @@ timeline before risky work, and select any timeline to inspect its version
131
132
  chain and the edits between versions. Every destructive timeline edit made
132
133
  through the MCP archives a version here automatically.
133
134
 
135
+ ### Media → Edit Plans
136
+
137
+ ![Edit plan browser](../images/control-panel/11-edit-plans.png)
138
+
139
+ Dry-run plans saved by the edit engine (`edit_engine` actions
140
+ `plan_selects` / `plan_tighten` / `plan_swap`). The list shows each plan's
141
+ kind, summary, save time, and an `executed` chip once it has run; plans that
142
+ fail their fingerprint check appear as a warning row instead of being hidden.
143
+ Opening a plan shows its full evidence: selects decisions render with shot
144
+ thumbnails, rank, duration, and rationale (with a deep link to each shot
145
+ page); tighten plans list every dead-air lift with its transcript-gap
146
+ evidence; swap plans show the current item and each numbered alternate with
147
+ its similarity score.
148
+
149
+ The panel never executes a plan. Each detail view carries a copyable chat
150
+ prompt (per kind, with the `alternate_index` placeholder for swaps) — paste
151
+ it into your MCP chat session, which holds the confirm-token gate and the
152
+ versioned execution path. Executed plans keep their execution readback
153
+ inline.
154
+
134
155
  ### AI Console
135
156
 
136
157
  ![AI Console with governance](../images/control-panel/09-ai-console.png)
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.46.0"
38
+ VERSION = "2.47.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.46.0",
3
+ "version": "2.47.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -57,6 +57,7 @@ def main() -> int:
57
57
  ("#preferences/analysis", "08-preferences-analysis.png", 3000),
58
58
  ("#aiconsole", "09-ai-console.png", 3000),
59
59
  ("#analysis/review/history", "10-history.png", 3500),
60
+ ("#analysis/review/plans", "11-edit-plans.png", 3500),
60
61
  ]
61
62
  with sync_playwright() as pw:
62
63
  browser = pw.chromium.launch()
@@ -2379,6 +2379,84 @@ HTML = r"""<!doctype html>
2379
2379
  }
2380
2380
  .history-rollback-btn:hover { background: var(--accent-warning); color: var(--bg-base); }
2381
2381
 
2382
+ /* ─── Edit-engine plan browser ──────────────────────────────────── */
2383
+ .plans-toolbar {
2384
+ display: flex;
2385
+ align-items: center;
2386
+ justify-content: space-between;
2387
+ gap: var(--space-3);
2388
+ margin-bottom: var(--space-3);
2389
+ }
2390
+ .plans-toolbar .section-meta { margin-left: var(--space-2); }
2391
+ .plans-list { display: grid; gap: var(--space-2); }
2392
+ .plan-row {
2393
+ display: flex;
2394
+ align-items: center;
2395
+ gap: var(--space-3);
2396
+ padding: var(--space-2) var(--space-3);
2397
+ border: 1px solid var(--border-default);
2398
+ border-radius: var(--radius-md);
2399
+ background: var(--lab-panel-elevated, var(--bg-base));
2400
+ cursor: pointer;
2401
+ }
2402
+ .plan-row:hover { background: var(--bg-muted); }
2403
+ .plan-row.is-corrupt {
2404
+ cursor: default;
2405
+ border-color: var(--accent-warning);
2406
+ background: var(--accent-warning-muted);
2407
+ }
2408
+ .plan-row .summary { flex: 1; min-width: 0; }
2409
+ .plan-row .saved-at { color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }
2410
+ .plan-chip {
2411
+ display: inline-block;
2412
+ padding: 1px var(--space-2);
2413
+ border-radius: var(--radius-sm);
2414
+ font-size: var(--text-xs);
2415
+ font-weight: 600;
2416
+ text-transform: uppercase;
2417
+ letter-spacing: 0.04em;
2418
+ border: 1px solid var(--border-default);
2419
+ color: var(--text-secondary);
2420
+ white-space: nowrap;
2421
+ }
2422
+ .plan-chip.kind-selects { color: var(--accent-success); border-color: var(--accent-success); }
2423
+ .plan-chip.kind-tighten { color: var(--accent-warning); border-color: var(--accent-warning); }
2424
+ .plan-chip.kind-swap { color: var(--accent-info, var(--text-secondary)); border-color: currentColor; }
2425
+ .plan-chip.executed { color: var(--text-muted); text-transform: none; letter-spacing: 0; }
2426
+ .plan-chip.corrupt { color: var(--accent-warning); border-color: var(--accent-warning); }
2427
+ .plan-detail-header {
2428
+ display: flex;
2429
+ align-items: center;
2430
+ flex-wrap: wrap;
2431
+ gap: var(--space-2);
2432
+ margin-bottom: var(--space-3);
2433
+ }
2434
+ .plan-detail-header .timeline-name { font-weight: 600; font-size: var(--text-md); }
2435
+ .plan-detail-body { display: grid; gap: var(--space-3); }
2436
+ .plan-section {
2437
+ padding: var(--space-3);
2438
+ border: 1px solid var(--border-default);
2439
+ border-radius: var(--radius-md);
2440
+ background: var(--lab-panel-elevated, var(--bg-base));
2441
+ }
2442
+ .plan-section > strong { display: block; margin-bottom: var(--space-2); }
2443
+ .plan-decision-card {
2444
+ display: flex;
2445
+ gap: var(--space-3);
2446
+ padding: var(--space-2) 0;
2447
+ border-bottom: 1px solid var(--border-default);
2448
+ }
2449
+ .plan-decision-card:last-child { border-bottom: none; }
2450
+ .plan-decision-card .review-thumb { width: 120px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); flex: none; }
2451
+ .plan-decision-card .review-thumb.placeholder {
2452
+ display: flex; align-items: center; justify-content: center;
2453
+ background: var(--bg-muted); color: var(--text-muted); font-size: var(--text-xs);
2454
+ }
2455
+ .plan-decision-card .body { flex: 1; min-width: 0; display: grid; gap: 2px; align-content: start; }
2456
+ .plan-decision-card .title-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
2457
+ .plan-decision-card .rationale { color: var(--text-secondary); font-size: var(--text-xs); }
2458
+ .plan-decision-card .meta { color: var(--text-muted); font-size: var(--text-xs); }
2459
+
2382
2460
  /* ─── Analysis caps preferences widget ──────────────────────────── */
2383
2461
  .caps-section {
2384
2462
  margin-top: var(--space-4);
@@ -3847,6 +3925,7 @@ HTML = r"""<!doctype html>
3847
3925
  <button class="nav-dropdown-item" data-panel-target="analysis" data-subpage-target="media" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m7 15 3-3 2 2 4-5 1 2"></path></svg></span>Inventory</button>
3848
3926
  <button class="nav-dropdown-item" data-panel-target="analysis" data-subpage-target="review" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="14" rx="2"></rect><circle cx="9" cy="10" r="2"></circle><path d="m21 17-5-5-9 9"></path></svg></span>Review</button>
3849
3927
  <button class="nav-dropdown-item" id="navHistoryItem" data-panel-target="analysis" data-subpage-target="review" data-review-view="history" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg></span>History</button>
3928
+ <button class="nav-dropdown-item" id="navPlansItem" data-panel-target="analysis" data-subpage-target="review" data-review-view="plans" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="2" width="8" height="4" rx="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M9 12h6"></path><path d="M9 16h6"></path></svg></span>Edit Plans</button>
3850
3929
  </div>
3851
3930
  </div>
3852
3931
  <button class="control-tab" data-panel-target="aiconsole">AI Console</button>
@@ -4192,6 +4271,20 @@ HTML = r"""<!doctype html>
4192
4271
  </section>
4193
4272
  </div>
4194
4273
  </div>
4274
+ <div id="reviewPlansView" style="display:none">
4275
+ <div class="plans-toolbar">
4276
+ <div>
4277
+ <strong>Edit plans</strong>
4278
+ <span class="section-meta">Dry-run plans saved by the edit engine — review here, execute from chat.</span>
4279
+ </div>
4280
+ <button id="plansRefreshBtn" class="secondary" type="button">Refresh</button>
4281
+ </div>
4282
+ <div id="plansListBody" class="plans-list"><div class="empty">Loading…</div></div>
4283
+ </div>
4284
+ <div id="reviewPlanView" style="display:none">
4285
+ <div id="planDetailHeader" class="plan-detail-header"></div>
4286
+ <div id="planDetailBody" class="plan-detail-body"></div>
4287
+ </div>
4195
4288
  </section>
4196
4289
  </main>
4197
4290
 
@@ -5160,6 +5253,10 @@ HTML = r"""<!doctype html>
5160
5253
  if (r.view === 'combined' && Array.isArray(r.combinedClipIds) && r.combinedClipIds.length) {
5161
5254
  return `/combined/${r.combinedClipIds.map(encodeURIComponent).join(',')}`;
5162
5255
  }
5256
+ if (r.view === 'plan' && state.plans?.currentPlanId) {
5257
+ return `/plans/${encodeURIComponent(state.plans.currentPlanId)}`;
5258
+ }
5259
+ if (r.view === 'plans') return '/plans';
5163
5260
  return '';
5164
5261
  }
5165
5262
 
@@ -5191,6 +5288,15 @@ HTML = r"""<!doctype html>
5191
5288
  reviewSetView('history');
5192
5289
  refreshHistoryTimelines().catch(alertError);
5193
5290
  }
5291
+ // Deep links: #analysis/review/plans[/<plan_id>]
5292
+ if (panelName === 'analysis' && subpage === 'review' && route[2] === 'plans') {
5293
+ if (route[3]) {
5294
+ openEditPlan(decodeURIComponent(route[3]), { pushHash: false }).catch(alertError);
5295
+ } else {
5296
+ reviewSetView('plans', { pushHash: false });
5297
+ refreshEditPlans().catch(alertError);
5298
+ }
5299
+ }
5194
5300
  // Deep links: #analysis/review/combined/<id1,id2,...>
5195
5301
  if (panelName === 'analysis' && subpage === 'review' && route[2] === 'combined' && route[3]) {
5196
5302
  const ids = decodeURIComponent(route[3]).split(',').filter(Boolean);
@@ -7210,6 +7316,229 @@ HTML = r"""<!doctype html>
7210
7316
  await refreshHistoryTimelines();
7211
7317
  }
7212
7318
 
7319
+ // ─── Edit-engine plan browser (chat-first: the panel surfaces plans
7320
+ // and evidence; execution happens via copyable chat prompts only) ─
7321
+ state.plans = state.plans || { list: null, currentPlanId: null, payload: null };
7322
+
7323
+ const PLAN_KIND_LABELS = { selects: 'Selects', tighten: 'Tighten', swap: 'Swap' };
7324
+
7325
+ function planExecutePrompt(plan) {
7326
+ const id = plan.plan_id;
7327
+ if (plan.kind === 'selects') {
7328
+ return `Execute edit plan ${id}: call edit_engine(action="execute_selects", params={"plan_id": "${id}"}). It creates a NEW selects timeline; nothing existing is touched. Show me the readback when done.`;
7329
+ }
7330
+ if (plan.kind === 'tighten') {
7331
+ return `Execute edit plan ${id}: call edit_engine(action="execute_tighten", params={"plan_id": "${id}"}). It assembles a tightened VARIANT timeline from the keep ranges; the original timeline is never mutated. Show me the readback when done.`;
7332
+ }
7333
+ if (plan.kind === 'swap') {
7334
+ return `Execute edit plan ${id} with alternate <N>: call edit_engine(action="execute_swap", params={"plan_id": "${id}", "alternate_index": <N>}) — replace <N> with the number of the alternate I picked below (0-based). The timeline is version-archived before the swap. Show me the readback when done.`;
7335
+ }
7336
+ return `Load edit plan ${id} with edit_engine(action="get_plan", params={"plan_id": "${id}"}) and walk me through it.`;
7337
+ }
7338
+
7339
+ function planThumb(row, altText) {
7340
+ const clipId = row.resolve_clip_id;
7341
+ const frameIndex = row.thumb_frame_index;
7342
+ if (clipId && frameIndex != null) {
7343
+ return `<img class="review-thumb" loading="lazy" src="/api/clips/${encodeURIComponent(clipId)}/frames/${frameIndex}" alt="${escapeHtml(altText)}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'review-thumb placeholder',textContent:'no frame'}))">`;
7344
+ }
7345
+ return '<div class="review-thumb placeholder">no frame</div>';
7346
+ }
7347
+
7348
+ async function refreshEditPlans() {
7349
+ const body = $('plansListBody');
7350
+ if (body) body.innerHTML = '<div class="empty">Loading…</div>';
7351
+ const data = await api('/api/edit_plans').catch(err => ({ success: false, error: String(err) }));
7352
+ if (!data || !data.success) {
7353
+ if (body) body.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
7354
+ return;
7355
+ }
7356
+ state.plans.list = data.plans || [];
7357
+ renderPlansList();
7358
+ }
7359
+
7360
+ function renderPlansList() {
7361
+ const body = $('plansListBody');
7362
+ if (!body) return;
7363
+ const plans = state.plans.list || [];
7364
+ if (!plans.length) {
7365
+ body.innerHTML = '<div class="empty">No edit plans yet. Ask your chat session to plan one, e.g. “Plan a selects reel from this bin” (edit_engine action="plan_selects").</div>';
7366
+ return;
7367
+ }
7368
+ body.innerHTML = plans.map(p => {
7369
+ if (p.corrupt) {
7370
+ return `<div class="plan-row is-corrupt">
7371
+ <span class="plan-chip corrupt">corrupt</span>
7372
+ <span class="summary">Plan ${escapeHtml(p.plan_id || '?')} failed its fingerprint check — it was edited on disk or truncated. It cannot be executed; re-plan to replace it.</span>
7373
+ </div>`;
7374
+ }
7375
+ const executed = p.executed_at
7376
+ ? `<span class="plan-chip executed" title="${escapeHtml(p.executed_at)}">executed</span>` : '';
7377
+ return `<div class="plan-row" data-plan-id="${escapeHtml(p.plan_id)}" tabindex="0" role="button" aria-label="Open plan ${escapeHtml(p.plan_id)}">
7378
+ <span class="plan-chip kind-${escapeHtml(p.kind || '')}">${escapeHtml(PLAN_KIND_LABELS[p.kind] || p.kind || '?')}</span>
7379
+ <span class="summary" title="${escapeHtml(p.summary || '')}">${escapeHtml(p.summary || p.plan_id)}</span>
7380
+ ${executed}
7381
+ <span class="saved-at">${escapeHtml(formatVersionDate(p.saved_at))}</span>
7382
+ </div>`;
7383
+ }).join('');
7384
+ body.querySelectorAll('.plan-row[data-plan-id]').forEach(row => {
7385
+ const open = () => openEditPlan(row.dataset.planId).catch(alertError);
7386
+ row.addEventListener('click', open);
7387
+ row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
7388
+ });
7389
+ }
7390
+
7391
+ async function openEditPlan(planId, opts = {}) {
7392
+ state.plans.currentPlanId = planId;
7393
+ reviewSetView('plan', opts);
7394
+ const body = $('planDetailBody');
7395
+ const header = $('planDetailHeader');
7396
+ if (body) body.innerHTML = '<div class="empty">Loading…</div>';
7397
+ if (header) header.innerHTML = '';
7398
+ const data = await api(`/api/edit_plans/${encodeURIComponent(planId)}`)
7399
+ .catch(err => ({ success: false, error: String(err) }));
7400
+ if (!data || !data.success) {
7401
+ if (body) body.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
7402
+ return;
7403
+ }
7404
+ state.plans.payload = data;
7405
+ renderPlanDetail(data);
7406
+ }
7407
+
7408
+ function renderPlanDetail(data) {
7409
+ const header = $('planDetailHeader');
7410
+ const body = $('planDetailBody');
7411
+ if (!header || !body) return;
7412
+ if (data.corrupt) {
7413
+ header.innerHTML = `<span class="plan-chip corrupt">corrupt</span><span class="timeline-name">Plan ${escapeHtml(data.plan_id || '?')}</span>`;
7414
+ body.innerHTML = '<div class="empty">This plan failed its fingerprint check — it was edited on disk or truncated. It cannot be executed; re-plan to replace it.</div>';
7415
+ return;
7416
+ }
7417
+ const plan = data.plan || {};
7418
+ const executed = plan.executed_at
7419
+ ? `<span class="plan-chip executed" title="${escapeHtml(plan.executed_at)}">executed ${escapeHtml(formatVersionDate(plan.executed_at))}</span>` : '';
7420
+ header.innerHTML = `
7421
+ <span class="plan-chip kind-${escapeHtml(plan.kind || '')}">${escapeHtml(PLAN_KIND_LABELS[plan.kind] || plan.kind || '?')}</span>
7422
+ <span class="timeline-name">${escapeHtml(plan.timeline_name || plan.plan_id || '')}</span>
7423
+ ${executed}
7424
+ <span class="saved-at" style="color:var(--text-muted);font-size:var(--text-xs)">saved ${escapeHtml(formatVersionDate(plan.saved_at))} · plan ${escapeHtml(plan.plan_id || '')}</span>`;
7425
+
7426
+ const sections = [];
7427
+ if (plan.summary) {
7428
+ sections.push(`<div class="plan-section"><strong>Summary</strong><div>${escapeHtml(plan.summary)}</div></div>`);
7429
+ }
7430
+ // Chat-first execute affordance: the panel NEVER executes. Executed
7431
+ // plans keep the card (re-execution of selects is harmless and swap
7432
+ // may target another alternate) — the chip above signals state.
7433
+ sections.push(`<div class="plan-section"><strong>Execute (from chat)</strong>${chatPromptCard(
7434
+ plan.kind === 'swap'
7435
+ ? 'Pick an alternate below, then paste this in your chat session (fill in <N>):'
7436
+ : 'Paste this in your chat session to execute the plan:',
7437
+ planExecutePrompt(plan))}</div>`);
7438
+ if (plan.execution_summary) {
7439
+ sections.push(`<div class="plan-section"><strong>Execution readback</strong><pre style="margin:0;white-space:pre-wrap;font-size:var(--text-xs)">${escapeHtml(JSON.stringify(plan.execution_summary, null, 2))}</pre></div>`);
7440
+ }
7441
+ if (plan.settings) {
7442
+ const settings = Object.entries(plan.settings)
7443
+ .map(([k, v]) => `${escapeHtml(k)}=${escapeHtml(v == null ? '—' : String(v))}`).join(' · ');
7444
+ sections.push(`<div class="plan-section"><strong>Settings</strong><div style="font-size:var(--text-xs);color:var(--text-secondary)">${settings}</div></div>`);
7445
+ }
7446
+ if (plan.kind === 'selects') {
7447
+ sections.push(renderSelectsDecisions(plan));
7448
+ } else if (plan.kind === 'tighten') {
7449
+ sections.push(renderTightenLifts(plan));
7450
+ } else if (plan.kind === 'swap') {
7451
+ sections.push(renderSwapAlternates(plan));
7452
+ }
7453
+ body.innerHTML = sections.join('');
7454
+ body.querySelectorAll('[data-open-shot-clip]').forEach(btn => {
7455
+ btn.addEventListener('click', () => {
7456
+ const clipId = btn.dataset.openShotClip;
7457
+ const shotIndex = Number(btn.dataset.openShotIndex);
7458
+ openClipDetail(clipId)
7459
+ .then(() => Number.isFinite(shotIndex) ? openShotDetail(shotIndex) : null)
7460
+ .catch(alertError);
7461
+ });
7462
+ });
7463
+ }
7464
+
7465
+ function renderSelectsDecisions(plan) {
7466
+ const decisions = plan.decisions || [];
7467
+ const rankLabel = { 3: 'high', 2: 'medium', 1: 'low' };
7468
+ const cards = decisions.map((d, i) => {
7469
+ const range = Array.isArray(d.source_frame_range) ? `frames ${d.source_frame_range[0]}–${d.source_frame_range[1]}` : '';
7470
+ const shotLink = d.resolve_clip_id != null && d.shot_index != null
7471
+ ? `<button class="secondary" style="font-size:var(--text-xs);padding:1px 8px" data-open-shot-clip="${escapeHtml(d.resolve_clip_id)}" data-open-shot-index="${d.shot_index}">Open shot page</button>` : '';
7472
+ return `<div class="plan-decision-card">
7473
+ ${planThumb(d, `decision ${i + 1}`)}
7474
+ <div class="body">
7475
+ <div class="title-row">
7476
+ <strong>${i + 1}. ${escapeHtml(d.clip_name || d.clip_uuid || '?')} · shot ${d.shot_index ?? '?'}</strong>
7477
+ <span class="plan-chip">${escapeHtml(rankLabel[d.rank] || `rank ${d.rank}`)}</span>
7478
+ <span class="meta">${(d.duration_seconds ?? '?')}s${range ? ` · ${range}` : ''}</span>
7479
+ ${shotLink}
7480
+ </div>
7481
+ ${d.description ? `<div class="meta">${escapeHtml(d.description)}</div>` : ''}
7482
+ <div class="rationale">${escapeHtml(d.rationale || '')}</div>
7483
+ </div>
7484
+ </div>`;
7485
+ });
7486
+ return `<div class="plan-section"><strong>Decisions (${decisions.length} shots, ~${plan.estimated_duration_seconds ?? '?'}s)</strong>${cards.join('') || '<div class="empty">No decisions.</div>'}</div>`;
7487
+ }
7488
+
7489
+ function renderTightenLifts(plan) {
7490
+ const lifts = plan.lifts || [];
7491
+ const rows = lifts.map(l => {
7492
+ const gap = l.evidence?.source_gap_seconds;
7493
+ return `<tr>
7494
+ <td class="edit-type">${escapeHtml(l.kind || 'lift')}</td>
7495
+ <td>${escapeHtml(l.item_name || '—')}</td>
7496
+ <td>${l.timeline_start_frame}–${l.timeline_end_frame}</td>
7497
+ <td>${l.duration_seconds ?? '—'}s</td>
7498
+ <td>${escapeHtml(l.rationale || '')}${gap ? ` <span style="color:var(--text-muted)">(gap ${gap[0]}–${gap[1]}s)</span>` : ''}</td>
7499
+ </tr>`;
7500
+ }).join('');
7501
+ const skipped = (plan.skipped || []).map(s =>
7502
+ `<div class="plan-decision-card"><div class="body"><div class="meta">skipped ${escapeHtml(s.item || '?')}: ${escapeHtml(s.reason || '')}</div></div></div>`).join('');
7503
+ return `<div class="plan-section">
7504
+ <strong>Lifts (${lifts.length} · ${plan.keep_ranges ? plan.keep_ranges.length : 0} keep ranges)</strong>
7505
+ <table class="history-edits-table">
7506
+ <thead><tr><th>Kind</th><th>Item</th><th>Timeline frames</th><th>Removes</th><th>Rationale</th></tr></thead>
7507
+ <tbody>${rows || '<tr><td colspan="5">No lifts.</td></tr>'}</tbody>
7508
+ </table>
7509
+ ${skipped}
7510
+ </div>`;
7511
+ }
7512
+
7513
+ function renderSwapAlternates(plan) {
7514
+ const alternates = plan.alternates || [];
7515
+ const item = plan.item || {};
7516
+ const current = `<div class="plan-decision-card"><div class="body">
7517
+ <div class="title-row"><strong>Current: ${escapeHtml(item.item_name || 'item')}</strong>
7518
+ <span class="meta">slot ${item.timeline_start_frame}–${item.timeline_end_frame} · track ${item.track_index ?? 1}</span></div>
7519
+ ${item.current_description ? `<div class="meta">${escapeHtml(item.current_description)}</div>` : ''}
7520
+ </div></div>`;
7521
+ const cards = alternates.map((a, i) => {
7522
+ const range = Array.isArray(a.source_frame_range) ? `frames ${a.source_frame_range[0]}–${a.source_frame_range[1]}` : '';
7523
+ const shotLink = a.resolve_clip_id != null && a.shot_index != null
7524
+ ? `<button class="secondary" style="font-size:var(--text-xs);padding:1px 8px" data-open-shot-clip="${escapeHtml(a.resolve_clip_id)}" data-open-shot-index="${a.shot_index}">Open shot page</button>` : '';
7525
+ return `<div class="plan-decision-card">
7526
+ ${planThumb(a, `alternate ${i}`)}
7527
+ <div class="body">
7528
+ <div class="title-row">
7529
+ <strong>alternate_index ${i} · ${escapeHtml(a.clip_name || '?')} · shot ${a.shot_index ?? '?'}</strong>
7530
+ <span class="plan-chip">score ${a.score ?? '?'}</span>
7531
+ <span class="meta">${range}</span>
7532
+ ${shotLink}
7533
+ </div>
7534
+ ${a.description ? `<div class="meta">${escapeHtml(a.description)}</div>` : ''}
7535
+ <div class="rationale">${escapeHtml(a.rationale || '')}</div>
7536
+ </div>
7537
+ </div>`;
7538
+ });
7539
+ return `<div class="plan-section"><strong>Alternates (${alternates.length})</strong>${current}${cards.join('') || '<div class="empty">No alternates.</div>'}</div>`;
7540
+ }
7541
+
7213
7542
  // ─── Run scoping controls ──────────────────────────────────────────
7214
7543
  state.runScope = state.runScope || { current: null, recent: [] };
7215
7544
 
@@ -8088,6 +8417,10 @@ HTML = r"""<!doctype html>
8088
8417
  if (combinedEl) combinedEl.style.display = view === 'combined' ? '' : 'none';
8089
8418
  const historyEl = $('reviewHistoryView');
8090
8419
  if (historyEl) historyEl.style.display = view === 'history' ? '' : 'none';
8420
+ const plansEl = $('reviewPlansView');
8421
+ if (plansEl) plansEl.style.display = view === 'plans' ? '' : 'none';
8422
+ const planEl = $('reviewPlanView');
8423
+ if (planEl) planEl.style.display = view === 'plan' ? '' : 'none';
8091
8424
  const back = $('reviewBackBtn');
8092
8425
  if (back) back.style.display = view === 'bin' ? 'none' : '';
8093
8426
  const meta = $('reviewMeta');
@@ -8098,6 +8431,8 @@ HTML = r"""<!doctype html>
8098
8431
  else if (view === 'transcript') meta.textContent = `Transcript · ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
8099
8432
  else if (view === 'combined') meta.textContent = `Combined review · ${state.review.combinedData?.clip_count || '?'} clips`;
8100
8433
  else if (view === 'history') meta.textContent = 'Timeline history · archived versions and brain edits per timeline';
8434
+ else if (view === 'plans') meta.textContent = 'Edit plans · dry-run plans saved by the edit engine';
8435
+ else if (view === 'plan') meta.textContent = `Plan ${state.plans?.currentPlanId || ''} · review here, execute from chat`;
8101
8436
  }
8102
8437
  if (opts.writePanelState !== false) {
8103
8438
  writePanelStateAsync({
@@ -10470,6 +10805,12 @@ HTML = r"""<!doctype html>
10470
10805
  if (window.location.hash !== '#analysis/review/history') {
10471
10806
  window.history.replaceState(null, '', '#analysis/review/history');
10472
10807
  }
10808
+ } else if (control.dataset.reviewView === 'plans') {
10809
+ reviewSetView('plans');
10810
+ refreshEditPlans().catch(alertError);
10811
+ if (window.location.hash !== '#analysis/review/plans') {
10812
+ window.history.replaceState(null, '', '#analysis/review/plans');
10813
+ }
10473
10814
  }
10474
10815
  });
10475
10816
  });
@@ -10588,6 +10929,10 @@ HTML = r"""<!doctype html>
10588
10929
  if (historyRefreshEl) {
10589
10930
  historyRefreshEl.onclick = () => refreshHistoryTimelines().catch(alertError);
10590
10931
  }
10932
+ const plansRefreshEl = $('plansRefreshBtn');
10933
+ if (plansRefreshEl) {
10934
+ plansRefreshEl.onclick = () => refreshEditPlans().catch(alertError);
10935
+ }
10591
10936
  const historyArchiveBtnEl = $('historyArchiveCurrentBtn');
10592
10937
  if (historyArchiveBtnEl) {
10593
10938
  historyArchiveBtnEl.onclick = () => {
@@ -10707,6 +11052,13 @@ HTML = r"""<!doctype html>
10707
11052
  reviewSetView('bin');
10708
11053
  } else if (state.review.view === 'history') {
10709
11054
  reviewSetView('bin');
11055
+ } else if (state.review.view === 'plan') {
11056
+ state.plans.currentPlanId = null;
11057
+ state.plans.payload = null;
11058
+ reviewSetView('plans');
11059
+ refreshEditPlans().catch(alertError);
11060
+ } else if (state.review.view === 'plans') {
11061
+ reviewSetView('bin');
10710
11062
  }
10711
11063
  };
10712
11064
  $('reviewBinGrid').addEventListener('click', event => {
@@ -13361,6 +13713,71 @@ def get_timeline_history_payload(
13361
13713
  }
13362
13714
 
13363
13715
 
13716
+ def list_edit_plans_payload(project_root: str) -> Dict[str, Any]:
13717
+ """Edit-engine plan list for the panel browser (DB/file only, no Resolve).
13718
+
13719
+ Fingerprint-corrupt plans surface as {"plan_id", "corrupt": True} warning
13720
+ rows rather than being silently hidden.
13721
+ """
13722
+ try:
13723
+ from src.utils import edit_engine as _edit_engine
13724
+ return _edit_engine.list_plans(project_root, limit=50, include_corrupt=True)
13725
+ except Exception as exc: # noqa: BLE001 — panel reads fail soft
13726
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}", "plans": []}
13727
+
13728
+
13729
+ def get_edit_plan_payload(project_root: str, plan_id: str) -> Dict[str, Any]:
13730
+ """Full plan detail for the panel, enriched for rendering: selects
13731
+ decisions and swap alternates gain a `thumb_frame_index` (the shot's first
13732
+ sampled frame, for the existing /api/clips/<id>/frames/<idx> route) and a
13733
+ `resolve_clip_id` fallback mapped from clip_uuid. Enrichment is best-effort
13734
+ — the plan still renders without thumbnails when the DB is unavailable.
13735
+ """
13736
+ try:
13737
+ from src.utils import edit_engine as _edit_engine
13738
+ plan = _edit_engine.load_plan(project_root, plan_id)
13739
+ except Exception as exc: # noqa: BLE001
13740
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
13741
+ if plan is None:
13742
+ return {"success": False, "error": f"Plan {plan_id} not found"}
13743
+ if plan.get("_corrupt"):
13744
+ return {"success": True, "plan_id": plan_id, "corrupt": True}
13745
+ plan = json.loads(json.dumps(plan, default=str)) # detach a plain copy
13746
+ try:
13747
+ conn = _timeline_brain_db.connect(project_root)
13748
+ clip_id_cache: Dict[str, Any] = {}
13749
+
13750
+ def _enrich(row: Dict[str, Any]) -> None:
13751
+ clip_uuid = str(row.get("clip_uuid") or "")
13752
+ if not row.get("resolve_clip_id") and clip_uuid:
13753
+ if clip_uuid not in clip_id_cache:
13754
+ hit = conn.execute(
13755
+ "SELECT resolve_clip_id FROM clips WHERE clip_uuid = ?",
13756
+ (clip_uuid,),
13757
+ ).fetchone()
13758
+ clip_id_cache[clip_uuid] = hit["resolve_clip_id"] if hit else None
13759
+ if clip_id_cache[clip_uuid]:
13760
+ row["resolve_clip_id"] = clip_id_cache[clip_uuid]
13761
+ shot_uuid = row.get("shot_uuid")
13762
+ if shot_uuid and row.get("thumb_frame_index") is None:
13763
+ hit = conn.execute(
13764
+ "SELECT MIN(frame_index) AS frame_index FROM frames WHERE shot_uuid = ?",
13765
+ (str(shot_uuid),),
13766
+ ).fetchone()
13767
+ if hit and hit["frame_index"] is not None:
13768
+ row["thumb_frame_index"] = int(hit["frame_index"])
13769
+
13770
+ for decision in plan.get("decisions") or []:
13771
+ if isinstance(decision, dict):
13772
+ _enrich(decision)
13773
+ for alternate in plan.get("alternates") or []:
13774
+ if isinstance(alternate, dict):
13775
+ _enrich(alternate)
13776
+ except Exception: # noqa: BLE001 — thumbnails are progressive enhancement
13777
+ pass
13778
+ return {"success": True, "corrupt": False, "plan": plan}
13779
+
13780
+
13364
13781
  def proxy_timeline_versioning_action(body: Dict[str, Any]) -> Dict[str, Any]:
13365
13782
  """Bridge dashboard → MCP server timeline_versioning tool.
13366
13783
 
@@ -14473,6 +14890,21 @@ class Handler(BaseHTTPRequestHandler):
14473
14890
  return
14474
14891
  self._json(get_timeline_history_payload(self.state.project_root, timeline_name))
14475
14892
  return
14893
+ # ─── Edit-engine plan browser (DB/file only — no Resolve) ───────
14894
+ if path == "/api/edit_plans":
14895
+ self._json(list_edit_plans_payload(self.state.project_root))
14896
+ return
14897
+ if path.startswith("/api/edit_plans/"):
14898
+ plan_id = unquote(path[len("/api/edit_plans/"):])
14899
+ if not plan_id:
14900
+ self._json({"success": False, "error": "plan_id required"}, HTTPStatus.BAD_REQUEST)
14901
+ return
14902
+ payload = get_edit_plan_payload(self.state.project_root, plan_id)
14903
+ if not payload.get("success") and "not found" in str(payload.get("error", "")):
14904
+ self._json(payload, HTTPStatus.NOT_FOUND)
14905
+ return
14906
+ self._json(payload)
14907
+ return
14476
14908
  if path == "/api/brain_edits/registry":
14477
14909
  self._json({"success": True, **_brain_edits.read_brain_edits_registry(self.state.project_root)})
14478
14910
  return
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.46.0"
83
+ VERSION = "2.47.0"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.46.0"
14
+ VERSION = "2.47.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -81,7 +81,13 @@ def load_plan(project_root: str, plan_id: str) -> Optional[Dict[str, Any]]:
81
81
  return plan
82
82
 
83
83
 
84
- def list_plans(project_root: str, *, limit: int = 20) -> Dict[str, Any]:
84
+ def list_plans(project_root: str, *, limit: int = 20, include_corrupt: bool = False) -> Dict[str, Any]:
85
+ """List saved plans, newest first.
86
+
87
+ `include_corrupt=True` (panel browser) surfaces fingerprint-mismatched
88
+ plans as ``{"plan_id", "corrupt": True}`` warning rows instead of hiding
89
+ them; the default keeps the MCP-action shape unchanged.
90
+ """
85
91
  directory = _plan_dir(project_root)
86
92
  rows: List[Dict[str, Any]] = []
87
93
  if os.path.isdir(directory):
@@ -90,6 +96,8 @@ def list_plans(project_root: str, *, limit: int = 20) -> Dict[str, Any]:
90
96
  continue
91
97
  plan = load_plan(project_root, name[:-5])
92
98
  if not plan or plan.get("_corrupt"):
99
+ if include_corrupt:
100
+ rows.append({"plan_id": name[:-5], "corrupt": True})
93
101
  continue
94
102
  rows.append({
95
103
  "plan_id": plan.get("plan_id"),
@@ -0,0 +1,73 @@
1
+ """Best-effort deletion of disposable Resolve projects.
2
+
3
+ DeleteProject is flaky on some Resolve builds: it silently returns False when
4
+ the target is (or was very recently) the current project, and occasionally on
5
+ the first attempt even when it isn't. Disposable test projects then linger in
6
+ the project library. This helper centralizes the mitigation so every
7
+ disposable-project flow gets it for free:
8
+
9
+ 1. make sure the target is not the current project (load a fallback project,
10
+ or close the target if no fallback is available),
11
+ 2. retry the delete once after a short pause,
12
+ 3. report the leftover by name when it still fails, so callers can surface it
13
+ instead of silently leaking.
14
+ """
15
+
16
+ import time
17
+ from typing import Any, Dict, Optional
18
+
19
+
20
+ def delete_project_safely(
21
+ pm: Any,
22
+ name: str,
23
+ *,
24
+ switch_to: Optional[str] = None,
25
+ retries: int = 1,
26
+ delay_seconds: float = 1.0,
27
+ ) -> Dict[str, Any]:
28
+ """Delete project `name` via project-manager handle `pm`, working around
29
+ DeleteProject flakiness. Returns {success, attempts, leftover, detail}.
30
+
31
+ `switch_to`: project to load first when `name` is current (e.g. the
32
+ project that was open before the disposable one was created). Without it,
33
+ the current project is closed instead.
34
+ """
35
+ attempts = 0
36
+ detail = ""
37
+ try:
38
+ current = None
39
+ try:
40
+ project = pm.GetCurrentProject()
41
+ current = project.GetName() if project else None
42
+ except Exception:
43
+ current = None
44
+ if current == name:
45
+ switched = False
46
+ if switch_to and switch_to != name:
47
+ try:
48
+ switched = bool(pm.LoadProject(switch_to))
49
+ except Exception:
50
+ switched = False
51
+ if not switched:
52
+ try:
53
+ project = pm.GetCurrentProject()
54
+ if project is not None:
55
+ pm.CloseProject(project)
56
+ except Exception:
57
+ pass
58
+
59
+ last_error = None
60
+ for attempt in range(1 + max(0, int(retries))):
61
+ attempts = attempt + 1
62
+ try:
63
+ if bool(pm.DeleteProject(name)):
64
+ return {"success": True, "attempts": attempts, "leftover": None, "detail": ""}
65
+ last_error = "DeleteProject returned False"
66
+ except Exception as exc:
67
+ last_error = str(exc)
68
+ if attempt < retries:
69
+ time.sleep(max(0.0, delay_seconds))
70
+ detail = last_error or "DeleteProject failed"
71
+ except Exception as exc:
72
+ detail = str(exc)
73
+ return {"success": False, "attempts": attempts, "leftover": name, "detail": detail}