davinci-resolve-mcp 2.46.1 → 2.48.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,57 @@
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.48.0
6
+
7
+ Edit-engine hardening: trustworthy execute readback ahead of the first
8
+ real-cut pilot. Live-validated end-to-end on a disposable synthetic-media
9
+ project (24/24 checks).
10
+
11
+ - **Added** `timeline_versioning(action="diff_timelines", params={from_timeline,
12
+ to_timeline})`: structural diff (added/removed/moved/trimmed + summary)
13
+ between two LIVE timelines by name — read-only, no archived snapshots
14
+ needed. Built for edit-engine variants, which are new-name timelines with
15
+ no shared version chain. The item walk and the snapshot comparison were
16
+ factored out of the version-snapshot path
17
+ (`capture_timeline_clip_usage` / `compare_usage_snapshots`) and reused.
18
+ - **Fixed** `execute_swap` audio accounting: the lift was video-only while
19
+ the replacement appended linked video+audio, so item counts drifted on
20
+ every swap. The lift is now scoped to the target's video track plus its
21
+ linked audio tracks (`GetLinkedItems`, with a media-id track-scan fallback;
22
+ items with no linked audio and audio-only timelines are handled
23
+ gracefully), and readback reports per-track-type `track_counts`
24
+ before/after plus an `audio_accounting` block.
25
+ - **Added** `execute_tighten` readback now includes `structural_diff`
26
+ (source vs variant); `execute_selects` readback includes a
27
+ `usage_summary` (per-track-type item counts — a diff against a source
28
+ timeline is meaningless for a fresh assembly).
29
+ - **Changed** the live edit-engine validation harness asserts the tighten
30
+ structural diff, `diff_timelines` agreement, and swap track-count
31
+ symmetry (24 checks, up from 20).
32
+
33
+ ## What's New in v2.47.0
34
+
35
+ Edit-engine plan browser in the control panel (Media → Edit Plans) — the
36
+ panel UX pass for the v2.45.0 edit engine. Chat-first: the panel surfaces
37
+ plans and evidence; execution stays in chat behind the confirm-token gate.
38
+
39
+ - **Added** a plan browser at `#analysis/review/plans`: every saved
40
+ `edit_engine` plan with kind, summary, save time, and an `executed` chip;
41
+ fingerprint-corrupt plans surface as warning rows instead of being hidden.
42
+ - **Added** plan detail views per kind: selects decisions render with shot
43
+ thumbnails, rank, duration, rationale, and a deep link to the shot page;
44
+ tighten plans list each dead-air lift with its transcript-gap evidence and
45
+ skipped items; swap plans show the current item plus numbered alternates
46
+ with similarity scores. Executed plans show their execution readback.
47
+ - **Added** a copyable per-kind execute chat prompt on each plan (the panel
48
+ never executes; swaps include an `alternate_index` placeholder).
49
+ - **Added** `/api/edit_plans` + `/api/edit_plans/<plan_id>` panel endpoints
50
+ (DB/file only — no Resolve round-trips; decisions are enriched server-side
51
+ with `resolve_clip_id` and a `thumb_frame_index` for the existing frames
52
+ route).
53
+ - **Changed** `edit_engine.list_plans` gained `include_corrupt` (default off;
54
+ the MCP action shape is unchanged).
55
+
5
56
  ## What's New in v2.46.1
6
57
 
7
58
  Test and hygiene hardening; no public tool surface changes.
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.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.48.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
@@ -699,13 +699,25 @@ clip-count readback plus `brain_edits` rationale rows.
699
699
  filtered to shots long enough to fill the slot exactly.
700
700
  `execute_swap(plan_id, alternate_index)` replaces the item in place
701
701
  (lift + positioned append at the same record frame) on the
702
- version-archived timeline.
702
+ version-archived timeline. v2.48.0+: the lift is scoped to the target's
703
+ video track plus its linked audio tracks (GetLinkedItems with a
704
+ media-id fallback), and `readback` carries per-track-type
705
+ `track_counts` plus an `audio_accounting` block so swap symmetry is
706
+ verifiable. `execute_tighten` readback gains `structural_diff` (source
707
+ vs variant, via the same engine as `diff_timelines`); `execute_selects`
708
+ readback gains a `usage_summary`.
703
709
  - `list_plans(limit?)` / `get_plan(plan_id)`.
704
710
 
705
711
  The engine needs the analysis substrate: analyzed clips in the DB (run
706
712
  `db_ingest` on older roots), transcripts for tighten, and visual embeddings
707
713
  (`build_embeddings(kinds=['visual'])`) for swap.
708
714
 
715
+ Plans are reviewable in the control panel (Media → Edit Plans, v2.47.0+):
716
+ decisions/lifts/alternates with thumbnails and rationale, deep links to shot
717
+ pages, and a copyable per-kind execute prompt. The panel never executes —
718
+ when the user says they reviewed a plan there, execution still comes back
719
+ through chat with the confirm-token gate.
720
+
709
721
  **`timeline_versioning`** — Version-on-mutate, archive, rollback, brain-edit history (C6).
710
722
 
711
723
  Every destructive timeline op (compound, captions, ripple delete, gap close,
@@ -734,6 +746,11 @@ Key actions:
734
746
  lists clips kept in place but re-trimmed (carries `out_frame_before`); `summary`
735
747
  has per-bucket counts plus `before_clip_count`/`after_clip_count`. Clips are
736
748
  keyed by media_pool_item_id and timeline position.
749
+ - `diff_timelines(from_timeline, to_timeline)` (v2.48.0+) — the same
750
+ structural diff between two LIVE timelines by NAME, read-only, no archived
751
+ snapshots needed. Built for edit-engine variants (tighten/selects produce
752
+ new-name timelines with no shared version chain). For unrelated timelines
753
+ everything reports as added/removed.
737
754
  - `get_history(timeline_name?, analysis_run_id?, limit?)` — brain-edit rows
738
755
  with `edit_type`, `target_metric`, `before_value`, `after_value`, `delta`,
739
756
  `rationale`, and `initiator`. Filter by timeline or run; defaults to 50.
@@ -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.1"
38
+ VERSION = "2.48.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.1",
3
+ "version": "2.48.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.1"
83
+ VERSION = "2.48.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.1"
14
+ VERSION = "2.48.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -16386,6 +16386,10 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16386
16386
  list_versions(timeline_name) -> [{version, archived_timeline_name, created_at, ...}]
16387
16387
  Version chain for `timeline_name`, oldest first. Includes any retention-
16388
16388
  collapsed versions (drt_export_path populated).
16389
+ diff_timelines(from_timeline, to_timeline) -> {added, removed, moved, trimmed, summary}
16390
+ Structural diff between two LIVE timelines by name (read-only; no
16391
+ archived versions needed). Built for edit-engine variants — tighten/
16392
+ selects produce new-name timelines with no shared version chain.
16389
16393
  get_history(timeline_name?, analysis_run_id?, limit?) -> [{edit_type, target_metric, before_value, after_value, delta, ...}]
16390
16394
  Brain-edit history. Filter by timeline_name or analysis_run_id; defaults
16391
16395
  to the most recent 50 across the project.
@@ -16453,6 +16457,14 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
16453
16457
  to_version=int(p["to_version"]),
16454
16458
  ),
16455
16459
  }
16460
+ if action == "diff_timelines":
16461
+ if not p.get("from_timeline") or not p.get("to_timeline"):
16462
+ return _err("from_timeline and to_timeline required")
16463
+ return _timeline_versioning.diff_timelines(
16464
+ project=project_h,
16465
+ from_timeline=str(p["from_timeline"]),
16466
+ to_timeline=str(p["to_timeline"]),
16467
+ )
16456
16468
  if action == "get_history":
16457
16469
  rows = _brain_edits.get_brain_edit_history(
16458
16470
  project_root=project_root,
@@ -16562,6 +16574,105 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
16562
16574
  return out
16563
16575
 
16564
16576
 
16577
+ def _edit_engine_track_counts(tl) -> Dict[str, int]:
16578
+ """Per-track-type item counts — the swap symmetry signal in readback."""
16579
+ counts: Dict[str, int] = {}
16580
+ for tt in ("video", "audio"):
16581
+ total = 0
16582
+ try:
16583
+ n = int(tl.GetTrackCount(tt) or 0)
16584
+ except Exception:
16585
+ n = 0
16586
+ for ti in range(1, n + 1):
16587
+ try:
16588
+ total += len(tl.GetItemListInTrack(tt, ti) or [])
16589
+ except Exception:
16590
+ continue
16591
+ counts[tt] = total
16592
+ return counts
16593
+
16594
+
16595
+ def _edit_engine_find_slot_item(tl, track_index: int, slot_start: int, slot_end: int):
16596
+ """The video item occupying exactly [slot_start, slot_end] on a track."""
16597
+ try:
16598
+ for cand in (tl.GetItemListInTrack("video", int(track_index)) or []):
16599
+ if _frame_int(cand.GetStart()) == slot_start and _frame_int(cand.GetEnd()) == slot_end:
16600
+ return cand
16601
+ except Exception:
16602
+ pass
16603
+ return None
16604
+
16605
+
16606
+ def _edit_engine_linked_audio_tracks(
16607
+ tl, target_item, slot_start: int, slot_end: int,
16608
+ ) -> Tuple[List[int], str]:
16609
+ """Audio track indices carrying the target item's linked audio.
16610
+
16611
+ Prefers GetLinkedItems; falls back to matching slot-overlapping audio
16612
+ items that share the target's media-pool source. ([], note) when there is
16613
+ no linked audio (audio-only handling stays untouched) or no target item.
16614
+ """
16615
+ if target_item is None:
16616
+ return [], "target video item not found on its track; audio left untouched"
16617
+ linked_ids: set = set()
16618
+ get_linked = getattr(target_item, "GetLinkedItems", None)
16619
+ if callable(get_linked):
16620
+ try:
16621
+ for linked_item in (get_linked() or []):
16622
+ uid = getattr(linked_item, "GetUniqueId", None)
16623
+ if callable(uid):
16624
+ linked_ids.add(str(uid()))
16625
+ except Exception:
16626
+ linked_ids = set()
16627
+ target_media_id = None
16628
+ try:
16629
+ mpi = target_item.GetMediaPoolItem()
16630
+ if mpi is not None:
16631
+ target_media_id = str(mpi.GetUniqueId())
16632
+ except Exception:
16633
+ target_media_id = None
16634
+ if not linked_ids and not target_media_id:
16635
+ return [], "GetLinkedItems unavailable and no media id to match; audio left untouched"
16636
+
16637
+ indices: List[int] = []
16638
+ try:
16639
+ audio_tracks = int(tl.GetTrackCount("audio") or 0)
16640
+ except Exception:
16641
+ audio_tracks = 0
16642
+ for ti in range(1, audio_tracks + 1):
16643
+ try:
16644
+ items = tl.GetItemListInTrack("audio", ti) or []
16645
+ except Exception:
16646
+ continue
16647
+ for cand in items:
16648
+ try:
16649
+ c_start = _frame_int(cand.GetStart())
16650
+ c_end = _frame_int(cand.GetEnd())
16651
+ except Exception:
16652
+ continue
16653
+ if c_start is None or c_end is None or c_start >= slot_end or c_end <= slot_start:
16654
+ continue
16655
+ is_linked = False
16656
+ if linked_ids:
16657
+ uid = getattr(cand, "GetUniqueId", None)
16658
+ if callable(uid):
16659
+ try:
16660
+ is_linked = str(uid()) in linked_ids
16661
+ except Exception:
16662
+ is_linked = False
16663
+ if not is_linked and target_media_id:
16664
+ try:
16665
+ c_mpi = cand.GetMediaPoolItem()
16666
+ is_linked = c_mpi is not None and str(c_mpi.GetUniqueId()) == target_media_id
16667
+ except Exception:
16668
+ is_linked = False
16669
+ if is_linked and ti not in indices:
16670
+ indices.append(ti)
16671
+ if not indices:
16672
+ return [], "no linked audio found for the target item"
16673
+ return indices, ""
16674
+
16675
+
16565
16676
  @mcp.tool()
16566
16677
  @_destructive_op("edit_engine")
16567
16678
  def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -16743,6 +16854,16 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16743
16854
  record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
16744
16855
  appended = mp.AppendToTimeline(built) if built else None
16745
16856
  readback = _edit_engine_capture(tl)
16857
+ # A diff against a source timeline is meaningless for a fresh assembly;
16858
+ # a usage-snapshot summary is the structural readback that fits.
16859
+ try:
16860
+ usage = _timeline_versioning.capture_timeline_clip_usage(tl)
16861
+ by_type: Dict[str, int] = {}
16862
+ for usage_row in usage:
16863
+ by_type[usage_row["track_type"]] = by_type.get(usage_row["track_type"], 0) + 1
16864
+ readback["usage_summary"] = {"items": len(usage), "by_track_type": by_type}
16865
+ except Exception:
16866
+ pass
16746
16867
  run_id = _analysis_runs.current_run_id()
16747
16868
  try:
16748
16869
  _brain_edits.log_brain_edit(
@@ -16820,6 +16941,17 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16820
16941
  return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
16821
16942
  new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
16822
16943
  after = _edit_engine_capture(new_tl) if new_tl else {}
16944
+ # Cross-name structural diff (source vs variant): trustworthy readback
16945
+ # without archived version rows — variants are new-name timelines.
16946
+ structural_diff = None
16947
+ if new_tl is not None:
16948
+ try:
16949
+ structural_diff = _timeline_versioning.compare_usage_snapshots(
16950
+ _timeline_versioning.capture_timeline_clip_usage(source_tl),
16951
+ _timeline_versioning.capture_timeline_clip_usage(new_tl),
16952
+ )
16953
+ except Exception as diff_exc:
16954
+ structural_diff = {"error": f"{type(diff_exc).__name__}: {diff_exc}"}
16823
16955
  run_id = _analysis_runs.current_run_id()
16824
16956
  try:
16825
16957
  _brain_edits.log_brain_edit(
@@ -16865,6 +16997,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16865
16997
  if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
16866
16998
  else None
16867
16999
  ),
17000
+ "structural_diff": structural_diff,
16868
17001
  },
16869
17002
  "plan_id": plan.get("plan_id"),
16870
17003
  }
@@ -16916,14 +17049,44 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16916
17049
  except Exception:
16917
17050
  pass
16918
17051
  before = _edit_engine_capture(tl)
17052
+ before["track_counts"] = _edit_engine_track_counts(tl)
17053
+ slot_start = int(item_block.get("timeline_start_frame"))
17054
+ slot_end = int(item_block.get("timeline_end_frame"))
17055
+ target_track = int(item_block.get("track_index") or 1)
17056
+ # Capture the target's linked audio BEFORE the lift so the audio lift
17057
+ # is scoped to exactly those tracks (the replacement appends linked
17058
+ # video+audio, so both sides of the swap stay symmetric).
17059
+ target_item = _edit_engine_find_slot_item(tl, target_track, slot_start, slot_end)
17060
+ linked_audio_indices, audio_note = _edit_engine_linked_audio_tracks(
17061
+ tl, target_item, slot_start, slot_end,
17062
+ )
16919
17063
  lift = _timeline_lift_range_impl(tl, {
16920
- "start_frame": item_block.get("timeline_start_frame"),
16921
- "end_frame": item_block.get("timeline_end_frame"),
17064
+ "start_frame": slot_start,
17065
+ "end_frame": slot_end,
17066
+ "track_types": ["video"],
17067
+ "track_indices": [target_track],
16922
17068
  "allow_partial_item_delete": True,
16923
17069
  "ripple": False,
16924
17070
  })
16925
17071
  if not lift.get("success"):
16926
17072
  return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
17073
+ audio_lift = None
17074
+ if linked_audio_indices:
17075
+ audio_lift = _timeline_lift_range_impl(tl, {
17076
+ "start_frame": slot_start,
17077
+ "end_frame": slot_end,
17078
+ "track_types": ["audio"],
17079
+ "track_indices": linked_audio_indices,
17080
+ "allow_partial_item_delete": True,
17081
+ "ripple": False,
17082
+ })
17083
+ if not audio_lift.get("success"):
17084
+ return {
17085
+ "success": False,
17086
+ "error": f"linked-audio lift failed: {audio_lift.get('error')}",
17087
+ "lift": lift,
17088
+ "audio_lift": audio_lift,
17089
+ }
16927
17090
  mp = proj.GetMediaPool()
16928
17091
  root_folder = mp.GetRootFolder()
16929
17092
  timeline_start = _timeline_start_frame(tl)
@@ -16940,8 +17103,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16940
17103
  row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
16941
17104
  if row_err:
16942
17105
  return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
17106
+ # No mediaType on the clip info: the replacement appends video+audio
17107
+ # linked, mirroring the video + linked-audio lift above.
16943
17108
  appended = mp.AppendToTimeline([row])
16944
17109
  after = _edit_engine_capture(tl)
17110
+ after["track_counts"] = _edit_engine_track_counts(tl)
16945
17111
  run_id = _analysis_runs.current_run_id()
16946
17112
  try:
16947
17113
  _brain_edits.log_brain_edit(
@@ -16972,6 +17138,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16972
17138
  "before": before,
16973
17139
  "after": after,
16974
17140
  })
17141
+ readback: Dict[str, Any] = {"before": before, "after": after}
17142
+ readback["audio_accounting"] = {
17143
+ "linked_audio_tracks_lifted": linked_audio_indices,
17144
+ "audio_items_lifted": (audio_lift or {}).get("deleted", 0),
17145
+ "video_items_lifted": lift.get("deleted", 0),
17146
+ **({"note": audio_note} if audio_note else {}),
17147
+ }
16975
17148
  return {
16976
17149
  "success": bool(appended),
16977
17150
  "timeline_name": tl.GetName(),
@@ -16981,7 +17154,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16981
17154
  "shot_index": alternate.get("shot_index"),
16982
17155
  "score": alternate.get("score"),
16983
17156
  },
16984
- "readback": {"before": before, "after": after},
17157
+ "readback": readback,
16985
17158
  "plan_id": plan.get("plan_id"),
16986
17159
  }
16987
17160
 
@@ -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"),
@@ -261,22 +261,11 @@ def _resolve_media_pool_item_id(item: Any) -> Optional[str]:
261
261
  return None
262
262
 
263
263
 
264
- def _snapshot_timeline_clip_usage(
265
- *,
266
- project_root: str,
267
- timeline: Any,
268
- timeline_name: str,
269
- timeline_version: int,
270
- analysis_run_id: Optional[str],
271
- ) -> int:
272
- """Walk every track/item on the timeline and INSERT a row per clip.
273
-
274
- Returns the number of rows written. Safe to call repeatedly for the same
275
- (timeline, version) — version is part of the row, not unique, so diffs
276
- between two versions are a SQL JOIN on media_pool_item_id.
277
- """
278
- observed_at = _now_iso()
279
- rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = []
264
+ def capture_timeline_clip_usage(timeline: Any) -> List[Dict[str, Any]]:
265
+ """Walk every track/item on a LIVE timeline into structural-usage rows
266
+ (no DB writes). Shared by the version snapshot writer and the cross-name
267
+ live diff."""
268
+ rows: List[Dict[str, Any]] = []
280
269
  for tt in ("video", "audio", "subtitle"):
281
270
  try:
282
271
  count = timeline.GetTrackCount(tt)
@@ -298,10 +287,39 @@ def _snapshot_timeline_clip_usage(
298
287
  out_frame = int(item.GetEnd())
299
288
  except Exception:
300
289
  continue
301
- rows.append((
302
- mpi_id, timeline_name, timeline_version,
303
- tt, ti, in_frame, out_frame, analysis_run_id, observed_at,
304
- ))
290
+ rows.append({
291
+ "media_pool_item_id": mpi_id,
292
+ "track_type": tt,
293
+ "track_index": ti,
294
+ "in_frame": in_frame,
295
+ "out_frame": out_frame,
296
+ })
297
+ return rows
298
+
299
+
300
+ def _snapshot_timeline_clip_usage(
301
+ *,
302
+ project_root: str,
303
+ timeline: Any,
304
+ timeline_name: str,
305
+ timeline_version: int,
306
+ analysis_run_id: Optional[str],
307
+ ) -> int:
308
+ """Walk every track/item on the timeline and INSERT a row per clip.
309
+
310
+ Returns the number of rows written. Safe to call repeatedly for the same
311
+ (timeline, version) — version is part of the row, not unique, so diffs
312
+ between two versions are a SQL JOIN on media_pool_item_id.
313
+ """
314
+ observed_at = _now_iso()
315
+ rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = [
316
+ (
317
+ usage["media_pool_item_id"], timeline_name, timeline_version,
318
+ usage["track_type"], usage["track_index"],
319
+ usage["in_frame"], usage["out_frame"], analysis_run_id, observed_at,
320
+ )
321
+ for usage in capture_timeline_clip_usage(timeline)
322
+ ]
305
323
 
306
324
  if not rows:
307
325
  return 0
@@ -348,9 +366,22 @@ def diff_versions(
348
366
 
349
367
  before = _snapshot(from_version)
350
368
  after = _snapshot(to_version)
351
- # Position key: (media id, track type, track index, in_frame). Same key in
352
- # both versions = same placement; a differing out_frame on that same key is
353
- # a *trim*. A differing track/in_frame for the same media id is a *move*.
369
+ return {
370
+ "from_version": from_version,
371
+ "to_version": to_version,
372
+ **compare_usage_snapshots(before, after),
373
+ }
374
+
375
+
376
+ def compare_usage_snapshots(
377
+ before: List[Dict[str, Any]], after: List[Dict[str, Any]],
378
+ ) -> Dict[str, Any]:
379
+ """added/removed/moved/trimmed between two structural-usage snapshots.
380
+
381
+ Position key: (media id, track type, track index, in_frame). Same key in
382
+ both snapshots = same placement; a differing out_frame on that same key is
383
+ a *trim*. A differing track/in_frame for the same media id is a *move*.
384
+ """
354
385
  def _key(row: Dict[str, Any]) -> Tuple[str, str, int, int]:
355
386
  return (row["media_pool_item_id"], row["track_type"], row["track_index"], row["in_frame"])
356
387
 
@@ -382,8 +413,6 @@ def diff_versions(
382
413
  moved.append(r)
383
414
 
384
415
  return {
385
- "from_version": from_version,
386
- "to_version": to_version,
387
416
  "added": added,
388
417
  "removed": removed,
389
418
  "moved": moved,
@@ -399,6 +428,36 @@ def diff_versions(
399
428
  }
400
429
 
401
430
 
431
+ def diff_timelines(
432
+ *,
433
+ project: Any,
434
+ from_timeline: str,
435
+ to_timeline: str,
436
+ ) -> Dict[str, Any]:
437
+ """Structural diff between two LIVE timelines by name (read-only).
438
+
439
+ Unlike diff_versions this needs no archived snapshots and works across
440
+ timeline NAMES — built for edit-engine variants (tighten/selects produce
441
+ new-name timelines that have no shared version chain with their source).
442
+ For moved/trimmed to mean anything the timelines should share source
443
+ clips; for unrelated timelines everything reports as added/removed.
444
+ """
445
+ from_tl = _find_timeline_by_name(project, from_timeline)
446
+ if from_tl is None:
447
+ return {"success": False, "error": f"Timeline '{from_timeline}' not found"}
448
+ to_tl = _find_timeline_by_name(project, to_timeline)
449
+ if to_tl is None:
450
+ return {"success": False, "error": f"Timeline '{to_timeline}' not found"}
451
+ before = capture_timeline_clip_usage(from_tl)
452
+ after = capture_timeline_clip_usage(to_tl)
453
+ return {
454
+ "success": True,
455
+ "from_timeline": from_timeline,
456
+ "to_timeline": to_timeline,
457
+ **compare_usage_snapshots(before, after),
458
+ }
459
+
460
+
402
461
  def ensure_versioned_before_mutation(
403
462
  *,
404
463
  resolve: Any,