davinci-resolve-mcp 2.29.0 → 2.30.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,38 @@
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.30.0
6
+
7
+ Adds the **Resolve 21 AI-ops ledger** — usage/time/file accounting for the
8
+ Resolve-local AI operations added in v2.29.0 (audio classification, IntelliSearch,
9
+ slate, motion-deblur, speech generation). These run on Resolve's own GPU/AI engine
10
+ and do **not** consume the Claude-side analysis token budget, so they get their
11
+ own ledger instead of being metered by the analysis-caps layer.
12
+
13
+ **What's tracked.** Every run of the five 21.0 ops records: op name, op class
14
+ (`analysis` vs `render`), clip id, success/failure, wall-clock time, and — for the
15
+ two media-creating ops (`remove_motion_blur`, `generate_speech`) — the output
16
+ file path and byte size. The reliable signal is invocation counts + the
17
+ file/disk accounting for the creators; durations for the bool-returning analysis
18
+ ops reflect the script-call time (some queue work inside Resolve).
19
+
20
+ - **New table** `resolve_ai_op_usage` (timeline_brain DB schema v7).
21
+ - **New module** `src/utils/resolve_ai_ledger.py` — `timed()` context manager +
22
+ `record_op` / `get_usage` / `get_summary`. All writes are best-effort and never
23
+ block or mask the underlying Resolve op.
24
+ - **Instrumentation** wraps the consolidated `folder` / `media_pool_item`
25
+ `perform_audio_classification` / `clear_audio_classification` /
26
+ `analyze_for_intellisearch` / `analyze_for_slate` / `remove_motion_blur`
27
+ handlers and `project_settings.generate_speech`.
28
+ - **New MCP action** `media_analysis(action="get_resolve_ai_usage", session_only?, op?, limit?)`
29
+ returns the per-op summary + recent runs.
30
+ - **Control panel**: a read-only "Resolve 21 AI ops" card (`/api/resolve_ai_usage`)
31
+ shows runs, success/fail, total time, and files/bytes created.
32
+
33
+ Phase 1 of a staged build (ledger → interactive console → governance). Granular
34
+ `--full` server instrumentation is deferred — the ledger covers the consolidated
35
+ server, which is the default surface. Validated live against Resolve Studio 21.0.0.47.
36
+
5
37
  ## What's New in v2.29.0
6
38
 
7
39
  Adds the **DaVinci Resolve 21.0** scripting-API additions. Every new method is
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.29.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.30.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-32%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -668,6 +668,15 @@ effective values + a usage rollup (clip / job / day) with percent-consumed.
668
668
  counts for one scope. Usage is tracked in
669
669
  `<project>/_soul/timeline_brain.sqlite` (`analysis_token_usage` table).
670
670
 
671
+ Resolve 21's local AI ops (audio classification, IntelliSearch, slate,
672
+ motion-deblur, speech generation) run on Resolve's own GPU/AI engine and do NOT
673
+ spend the Claude analysis token budget — they are tracked separately in the
674
+ `resolve_ai_op_usage` table. Inspect with
675
+ `media_analysis(action="get_resolve_ai_usage", session_only?, op?, limit?)` →
676
+ `{summary, recent}` (invocation counts, wall-clock, and files/bytes created by
677
+ `remove_motion_blur` / `generate_speech`). The control panel shows the same as a
678
+ read-only "Resolve 21 AI ops" card.
679
+
671
680
  The caps layer:
672
681
  - Slices `frame_paths` to `frames_per_clip` before the host LLM sees them.
673
682
  - Downscales each sampled frame in place to `max_frame_dim_pixels` (Pillow;
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.29.0"
38
+ VERSION = "2.30.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.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -4370,6 +4370,24 @@ HTML = r"""<!doctype html>
4370
4370
  </div>
4371
4371
  </div>
4372
4372
 
4373
+ <div class="caps-section">
4374
+ <div class="caps-section-head">
4375
+ <div class="caps-section-title">Resolve 21 AI ops</div>
4376
+ <div class="caps-section-hint">Local Resolve AI operations (audio classification, IntelliSearch, slate, motion-deblur, speech). These run on Resolve's GPU/AI engine and do <strong>not</strong> consume the Claude analysis token budget above — tracked here for invocations, time, and files created.</div>
4377
+ </div>
4378
+ <div id="resolveAiOpsBlock" class="caps-usage-block">
4379
+ <div id="resolveAiOpsSummary" class="caps-section-hint">loading…</div>
4380
+ <table id="resolveAiOpsTable" class="resolve-ai-ops-table" style="display:none; width:100%; border-collapse:collapse; margin-top:8px; font-size:12px;">
4381
+ <thead><tr style="text-align:left; opacity:0.7;">
4382
+ <th style="padding:4px 6px;">Op</th><th style="padding:4px 6px;">Runs</th>
4383
+ <th style="padding:4px 6px;">OK</th><th style="padding:4px 6px;">Time</th>
4384
+ <th style="padding:4px 6px;">Files</th><th style="padding:4px 6px;">Created</th>
4385
+ </tr></thead>
4386
+ <tbody id="resolveAiOpsRows"></tbody>
4387
+ </table>
4388
+ </div>
4389
+ </div>
4390
+
4373
4391
  <div class="caps-section">
4374
4392
  <div class="caps-section-head">
4375
4393
  <div class="caps-section-title">Safety</div>
@@ -7040,6 +7058,59 @@ HTML = r"""<!doctype html>
7040
7058
  </svg>`;
7041
7059
  }
7042
7060
 
7061
+ // ─── Resolve 21 AI ops ledger (read-only) ───────────────────────
7062
+ function fmtMs(ms) {
7063
+ ms = ms || 0;
7064
+ if (ms < 1000) return ms + 'ms';
7065
+ const s = ms / 1000;
7066
+ return s < 60 ? s.toFixed(1) + 's' : (s / 60).toFixed(1) + 'm';
7067
+ }
7068
+ function fmtBytes(n) {
7069
+ n = n || 0;
7070
+ if (!n) return '—';
7071
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
7072
+ let i = 0; let v = n;
7073
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
7074
+ return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
7075
+ }
7076
+ async function refreshResolveAiOps() {
7077
+ const summaryEl = $('resolveAiOpsSummary');
7078
+ const tableEl = $('resolveAiOpsTable');
7079
+ const rowsEl = $('resolveAiOpsRows');
7080
+ if (!summaryEl || !tableEl || !rowsEl) return;
7081
+ const data = await api('/api/resolve_ai_usage').catch(() => ({ success: false }));
7082
+ if (!data || !data.success) {
7083
+ summaryEl.textContent = 'ledger unavailable';
7084
+ tableEl.style.display = 'none';
7085
+ return;
7086
+ }
7087
+ const totals = (data.summary && data.summary.totals) || {};
7088
+ const byOp = (data.summary && data.summary.by_op) || {};
7089
+ const ops = Object.keys(byOp).sort();
7090
+ if (!ops.length) {
7091
+ summaryEl.textContent = 'No Resolve AI ops recorded yet for this project.';
7092
+ tableEl.style.display = 'none';
7093
+ return;
7094
+ }
7095
+ summaryEl.innerHTML = `<strong>${totals.runs || 0}</strong> runs · `
7096
+ + `<strong>${totals.successes || 0}</strong> ok / ${totals.failures || 0} failed · `
7097
+ + `${fmtMs(totals.wall_clock_ms)} total · `
7098
+ + `<strong>${totals.files_created || 0}</strong> files (${fmtBytes(totals.bytes_created)}) created`;
7099
+ rowsEl.innerHTML = ops.map(op => {
7100
+ const b = byOp[op];
7101
+ const isRender = b.op_class === 'render';
7102
+ return `<tr style="border-top:1px solid rgba(255,255,255,0.06);">
7103
+ <td style="padding:4px 6px;">${escapeHtml(op)}${isRender ? ' <span style="opacity:0.6;">(media)</span>' : ''}</td>
7104
+ <td style="padding:4px 6px;">${b.runs}</td>
7105
+ <td style="padding:4px 6px;">${b.successes}</td>
7106
+ <td style="padding:4px 6px;">${fmtMs(b.wall_clock_ms)}</td>
7107
+ <td style="padding:4px 6px;">${b.files_created || '—'}</td>
7108
+ <td style="padding:4px 6px;">${b.bytes_created ? fmtBytes(b.bytes_created) : '—'}</td>
7109
+ </tr>`;
7110
+ }).join('');
7111
+ tableEl.style.display = '';
7112
+ }
7113
+
7043
7114
  // ─── Caps inspector + refusals + reset ──────────────────────────
7044
7115
  async function inspectCapsFromUI() {
7045
7116
  const clipId = ($('capsInspectClipId')?.value || '').trim();
@@ -9916,6 +9987,7 @@ HTML = r"""<!doctype html>
9916
9987
  refreshCapsWidget().catch(() => {});
9917
9988
  refreshCapsHistory().catch(() => {});
9918
9989
  refreshCapsRefusals().catch(() => {});
9990
+ refreshResolveAiOps().catch(() => {});
9919
9991
 
9920
9992
  // Caps inspector + reset
9921
9993
  $('capsInspectBtn')?.addEventListener('click', () => inspectCapsFromUI().catch(alertError));
@@ -13613,6 +13685,20 @@ class Handler(BaseHTTPRequestHandler):
13613
13685
  except Exception as exc:
13614
13686
  self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13615
13687
  return
13688
+ if path == "/api/resolve_ai_usage":
13689
+ # Ledger of Resolve-local 21.0 AI ops (read straight from this
13690
+ # project's brain DB — no Resolve round-trip needed).
13691
+ try:
13692
+ from src.utils import resolve_ai_ledger as _ledger
13693
+ root = self.state.project_root
13694
+ self._json({
13695
+ "success": True,
13696
+ "summary": _ledger.get_summary(project_root=root),
13697
+ "recent": _ledger.get_usage(project_root=root, limit=50),
13698
+ })
13699
+ except Exception as exc:
13700
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13701
+ return
13616
13702
  if path.startswith("/api/timeline_thumbnail/"):
13617
13703
  rel = unquote(path[len("/api/timeline_thumbnail/"):])
13618
13704
  # Path is <slug>/<vNN.png>; constrain it to live under _soul/timeline_versions
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.29.0"
83
+ VERSION = "2.30.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.29.0"
14
+ VERSION = "2.30.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -639,6 +639,42 @@ def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional
639
639
  _destructive_hook.register_project_root_provider(_destructive_versioning_provider)
640
640
 
641
641
 
642
+ # ─── Resolve 21 AI-ops ledger plumbing ────────────────────────────────────────
643
+
644
+ import uuid as _ledger_uuid
645
+ from src.utils import resolve_ai_ledger as _resolve_ai_ledger
646
+
647
+ # One id per server process so the ledger / dashboard can scope "this session".
648
+ _AI_LEDGER_SESSION_ID = _ledger_uuid.uuid4().hex
649
+
650
+
651
+ def _ai_ledger_root() -> Optional[str]:
652
+ """Best-effort project_root for the AI-ops ledger. None disables recording."""
653
+ try:
654
+ provider = _destructive_versioning_provider()
655
+ return provider[2] if provider else None
656
+ except Exception:
657
+ return None
658
+
659
+
660
+ def _clip_file_size(item: Any) -> Tuple[Optional[str], Optional[int]]:
661
+ """(file_path, size_bytes) for a MediaPoolItem, or (path|None, None)."""
662
+ try:
663
+ path = item.GetClipProperty("File Path")
664
+ if isinstance(path, str) and path and os.path.exists(path):
665
+ return path, os.path.getsize(path)
666
+ return (path if isinstance(path, str) and path else None), None
667
+ except Exception:
668
+ return None, None
669
+
670
+
671
+ def _ai_ledger_timed(op: str, *, clip_id: Optional[str] = None):
672
+ """Ledger context manager bound to the current project_root + session."""
673
+ return _resolve_ai_ledger.timed(
674
+ _ai_ledger_root(), op, clip_id=clip_id, session_id=_AI_LEDGER_SESSION_ID
675
+ )
676
+
677
+
642
678
  def _destructive_preference_provider(key: str) -> Any:
643
679
  """Reader for C6 preferences out of the existing media-analysis prefs file."""
644
680
  try:
@@ -12427,10 +12463,17 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12427
12463
  blocked = _consume_confirm_token(action="project_settings.generate_speech", params=p)
12428
12464
  if blocked:
12429
12465
  return blocked
12430
- new_item = proj.GenerateSpeech(settings, timecode)
12466
+ with _ai_ledger_timed("generate_speech") as _rec:
12467
+ new_item = proj.GenerateSpeech(settings, timecode)
12468
+ _rec.success = bool(new_item)
12469
+ if new_item:
12470
+ path, nbytes = _clip_file_size(new_item)
12471
+ _rec.output_path = path
12472
+ _rec.output_bytes = nbytes
12431
12473
  if not new_item:
12432
12474
  return {"success": False}
12433
- return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId()}
12475
+ return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(),
12476
+ "output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
12434
12477
  return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
12435
12478
 
12436
12479
 
@@ -13414,19 +13457,28 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
13414
13457
  missing = _requires_method(f, "PerformAudioClassification", "21.0")
13415
13458
  if missing:
13416
13459
  return missing
13417
- return {"success": bool(f.PerformAudioClassification())}
13460
+ with _ai_ledger_timed("perform_audio_classification") as _rec:
13461
+ ok = bool(f.PerformAudioClassification())
13462
+ _rec.success = ok
13463
+ return {"success": ok}
13418
13464
  elif action == "clear_audio_classification":
13419
13465
  missing = _requires_method(f, "ClearAudioClassification", "21.0")
13420
13466
  if missing:
13421
13467
  return missing
13422
- return {"success": bool(f.ClearAudioClassification())}
13468
+ with _ai_ledger_timed("clear_audio_classification") as _rec:
13469
+ ok = bool(f.ClearAudioClassification())
13470
+ _rec.success = ok
13471
+ return {"success": ok}
13423
13472
  elif action == "analyze_for_intellisearch":
13424
13473
  missing = _requires_method(f, "AnalyzeForIntellisearch", "21.0")
13425
13474
  if missing:
13426
13475
  return missing
13427
13476
  identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
13428
13477
  is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
13429
- return {"success": bool(f.AnalyzeForIntellisearch(identify_faces, is_better_mode))}
13478
+ with _ai_ledger_timed("analyze_for_intellisearch") as _rec:
13479
+ ok = bool(f.AnalyzeForIntellisearch(identify_faces, is_better_mode))
13480
+ _rec.success = ok
13481
+ return {"success": ok}
13430
13482
  elif action == "analyze_for_slate":
13431
13483
  missing = _requires_method(f, "AnalyzeForSlate", "21.0")
13432
13484
  if missing:
@@ -13434,7 +13486,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
13434
13486
  marker_color = _first_param(p, "marker_color", "markerColor", default="Blue")
13435
13487
  if marker_color not in _MARKER_COLORS:
13436
13488
  return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
13437
- return {"success": bool(f.AnalyzeForSlate(marker_color))}
13489
+ with _ai_ledger_timed("analyze_for_slate") as _rec:
13490
+ ok = bool(f.AnalyzeForSlate(marker_color))
13491
+ _rec.success = ok
13492
+ return {"success": ok}
13438
13493
  elif action == "remove_motion_blur":
13439
13494
  missing = _requires_method(f, "RemoveMotionBlur", "21.0")
13440
13495
  if missing:
@@ -13454,14 +13509,25 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
13454
13509
  blocked = _consume_confirm_token(action="folder.remove_motion_blur", params=p)
13455
13510
  if blocked:
13456
13511
  return blocked
13457
- result = f.RemoveMotionBlur(deblur)
13458
- created = []
13459
- for pair in (result or []):
13460
- try:
13461
- orig, new = pair
13462
- created.append({"original": orig.GetName(), "new": new.GetName(), "new_id": new.GetUniqueId()})
13463
- except Exception:
13464
- continue
13512
+ with _ai_ledger_timed("remove_motion_blur") as _rec:
13513
+ result = f.RemoveMotionBlur(deblur)
13514
+ _rec.success = bool(result)
13515
+ created = []
13516
+ total_bytes = 0
13517
+ for pair in (result or []):
13518
+ try:
13519
+ orig, new = pair
13520
+ path, nbytes = _clip_file_size(new)
13521
+ if nbytes:
13522
+ total_bytes += nbytes
13523
+ created.append({"original": orig.GetName(), "new": new.GetName(),
13524
+ "new_id": new.GetUniqueId(), "output_path": path, "output_bytes": nbytes})
13525
+ except Exception:
13526
+ continue
13527
+ # Folder deblur creates many files; record the first path + summed bytes.
13528
+ if created:
13529
+ _rec.output_path = created[0].get("output_path")
13530
+ _rec.output_bytes = total_bytes or None
13465
13531
  return {"success": bool(result), "created": created}
13466
13532
  return _unknown(action, ["get_clips","get_name","get_subfolders","is_stale","get_unique_id","export","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur"])
13467
13533
 
@@ -13701,19 +13767,28 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13701
13767
  missing = _requires_method(clip, "PerformAudioClassification", "21.0")
13702
13768
  if missing:
13703
13769
  return missing
13704
- return {"success": bool(clip.PerformAudioClassification())}
13770
+ with _ai_ledger_timed("perform_audio_classification", clip_id=p.get("clip_id")) as _rec:
13771
+ ok = bool(clip.PerformAudioClassification())
13772
+ _rec.success = ok
13773
+ return {"success": ok}
13705
13774
  elif action == "clear_audio_classification":
13706
13775
  missing = _requires_method(clip, "ClearAudioClassification", "21.0")
13707
13776
  if missing:
13708
13777
  return missing
13709
- return {"success": bool(clip.ClearAudioClassification())}
13778
+ with _ai_ledger_timed("clear_audio_classification", clip_id=p.get("clip_id")) as _rec:
13779
+ ok = bool(clip.ClearAudioClassification())
13780
+ _rec.success = ok
13781
+ return {"success": ok}
13710
13782
  elif action == "analyze_for_intellisearch":
13711
13783
  missing = _requires_method(clip, "AnalyzeForIntellisearch", "21.0")
13712
13784
  if missing:
13713
13785
  return missing
13714
13786
  identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
13715
13787
  is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
13716
- return {"success": bool(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode))}
13788
+ with _ai_ledger_timed("analyze_for_intellisearch", clip_id=p.get("clip_id")) as _rec:
13789
+ ok = bool(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode))
13790
+ _rec.success = ok
13791
+ return {"success": ok}
13717
13792
  elif action == "analyze_for_slate":
13718
13793
  missing = _requires_method(clip, "AnalyzeForSlate", "21.0")
13719
13794
  if missing:
@@ -13721,7 +13796,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13721
13796
  marker_color = _first_param(p, "marker_color", "markerColor", default="Blue")
13722
13797
  if marker_color not in _MARKER_COLORS:
13723
13798
  return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
13724
- return {"success": bool(clip.AnalyzeForSlate(marker_color))}
13799
+ with _ai_ledger_timed("analyze_for_slate", clip_id=p.get("clip_id")) as _rec:
13800
+ ok = bool(clip.AnalyzeForSlate(marker_color))
13801
+ _rec.success = ok
13802
+ return {"success": ok}
13725
13803
  elif action == "remove_motion_blur":
13726
13804
  missing = _requires_method(clip, "RemoveMotionBlur", "21.0")
13727
13805
  if missing:
@@ -13741,10 +13819,17 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13741
13819
  blocked = _consume_confirm_token(action="media_pool_item.remove_motion_blur", params=p)
13742
13820
  if blocked:
13743
13821
  return blocked
13744
- new_clip = clip.RemoveMotionBlur(deblur)
13822
+ with _ai_ledger_timed("remove_motion_blur", clip_id=p.get("clip_id")) as _rec:
13823
+ new_clip = clip.RemoveMotionBlur(deblur)
13824
+ _rec.success = bool(new_clip)
13825
+ if new_clip:
13826
+ path, nbytes = _clip_file_size(new_clip)
13827
+ _rec.output_path = path
13828
+ _rec.output_bytes = nbytes
13745
13829
  if not new_clip:
13746
13830
  return {"success": False}
13747
- return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId()}
13831
+ return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId(),
13832
+ "output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
13748
13833
  elif action == "get_audio_mapping":
13749
13834
  return {"mapping": clip.GetAudioMapping()}
13750
13835
  elif action == "get_mark_in_out":
@@ -13893,6 +13978,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
13893
13978
  get_caps(clip_id?, job_id?) -> {preset, caps, presets_available, usage?} — effective caps + usage rollup (vision tokens consumed per scope, percent of budget).
13894
13979
  set_caps_preset(preset, overrides?) -> {success, preset, overrides} — preset is minimal | standard | generous | unlimited. Overrides is a dict of {field: int|"unlimited"} that wins over the preset.
13895
13980
  get_usage(scope?, scope_key?, clip_id?, job_id?) -> {scope, usage} — raw usage rollup for one scope (clip | job | day).
13981
+ get_resolve_ai_usage(session_only?, op?, limit?) -> {summary, recent} — ledger of Resolve 21 local AI ops (audio classification, IntelliSearch, slate, motion-deblur, speech). Tracks invocations, wall-clock, and files/bytes created by the two media-creating ops. Separate from get_caps/get_usage (those meter Claude-side tokens; these ops don't spend them).
13896
13982
  resolve_output_root(analysis_root?, source_paths?) -> {project_root}
13897
13983
  plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts}
13898
13984
  analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest}
@@ -14045,6 +14131,27 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14045
14131
  except Exception as exc:
14046
14132
  out["usage_error"] = f"{type(exc).__name__}: {exc}"
14047
14133
  return out
14134
+ if action == "get_resolve_ai_usage":
14135
+ # Ledger of Resolve-local 21.0 AI ops (audio classification, IntelliSearch,
14136
+ # slate, motion-deblur, speech generation). Distinct from get_caps/get_usage,
14137
+ # which meter Claude-side analysis tokens — these ops don't spend those.
14138
+ try:
14139
+ vctx = _destructive_versioning_provider()
14140
+ if vctx is None:
14141
+ return _err("No project context — open a Resolve project first")
14142
+ _r, _proj, project_root, _name = vctx
14143
+ session_only = bool(p.get("session_only", False))
14144
+ session_id = _AI_LEDGER_SESSION_ID if session_only else None
14145
+ limit = int(p.get("limit", 50))
14146
+ return {
14147
+ "success": True,
14148
+ "session_id": _AI_LEDGER_SESSION_ID,
14149
+ "scope": "session" if session_only else "project",
14150
+ "summary": _resolve_ai_ledger.get_summary(project_root=project_root, session_id=session_id),
14151
+ "recent": _resolve_ai_ledger.get_usage(project_root=project_root, session_id=session_id, op=p.get("op"), limit=limit),
14152
+ }
14153
+ except Exception as exc:
14154
+ return _err(f"{type(exc).__name__}: {exc}")
14048
14155
  if action == "set_caps_preset":
14049
14156
  preset = (p.get("preset") or "").strip().lower()
14050
14157
  from src.utils import analysis_caps as _ac
@@ -0,0 +1,242 @@
1
+ """Resolve 21 AI-ops ledger.
2
+
3
+ Records each Resolve-local AI scripting op (audio classification, IntelliSearch,
4
+ slate analysis, motion-deblur, speech generation). These run on Resolve's own
5
+ GPU/AI engine and do NOT consume the Claude-side analysis token budget tracked
6
+ in `analysis_caps`, so they get their own ledger.
7
+
8
+ The value of the ledger is mostly the **wall-clock + file/byte accounting** for
9
+ the two media-creating ops (`remove_motion_blur`, `generate_speech`). For the
10
+ bool-returning analysis ops, an invocation counter + duration is what is
11
+ reliable (some may queue work asynchronously inside Resolve, so the recorded
12
+ duration is the script-call duration, not necessarily the engine completion).
13
+
14
+ Persistence reuses `timeline_brain_db` (table `resolve_ai_op_usage`, schema v7).
15
+ Every write is best-effort: a ledger failure must never block or corrupt the
16
+ underlying Resolve op.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from src.utils import timeline_brain_db
26
+
27
+ logger = logging.getLogger("resolve-mcp.resolve-ai-ledger")
28
+
29
+ OP_CLASS_ANALYSIS = "analysis"
30
+ OP_CLASS_RENDER = "render" # produces a new media file
31
+
32
+ # op name -> (op_class, extra_required or None). The op names match the
33
+ # consolidated-server action names.
34
+ OP_META: Dict[str, Dict[str, Optional[str]]] = {
35
+ "perform_audio_classification": {"op_class": OP_CLASS_ANALYSIS, "extra_required": None},
36
+ "clear_audio_classification": {"op_class": OP_CLASS_ANALYSIS, "extra_required": None},
37
+ "analyze_for_intellisearch": {"op_class": OP_CLASS_ANALYSIS, "extra_required": "AI IntelliSearch"},
38
+ "analyze_for_slate": {"op_class": OP_CLASS_ANALYSIS, "extra_required": "AI Slate ID"},
39
+ "remove_motion_blur": {"op_class": OP_CLASS_RENDER, "extra_required": None},
40
+ "generate_speech": {"op_class": OP_CLASS_RENDER, "extra_required": "AI Speech Generator"},
41
+ }
42
+
43
+
44
+ def op_meta(op: str) -> Dict[str, Optional[str]]:
45
+ return OP_META.get(op, {"op_class": OP_CLASS_ANALYSIS, "extra_required": None})
46
+
47
+
48
+ def _iso(when: Optional[float] = None) -> str:
49
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(when if when is not None else time.time()))
50
+
51
+
52
+ def _day_bucket(when: Optional[float] = None) -> str:
53
+ return time.strftime("%Y-%m-%d", time.gmtime(when if when is not None else time.time()))
54
+
55
+
56
+ def record_op(
57
+ *,
58
+ project_root: str,
59
+ op: str,
60
+ clip_id: Optional[str] = None,
61
+ session_id: Optional[str] = None,
62
+ success: bool = False,
63
+ wall_clock_ms: int = 0,
64
+ output_path: Optional[str] = None,
65
+ output_bytes: Optional[int] = None,
66
+ error: Optional[str] = None,
67
+ ) -> Optional[int]:
68
+ """Persist one ledger row. Returns the row id, or None on any failure.
69
+
70
+ Best-effort: never raises. Callers run this after the Resolve op so a ledger
71
+ problem cannot affect the op itself.
72
+ """
73
+ if not project_root:
74
+ return None
75
+ meta = op_meta(op)
76
+ now = time.time()
77
+ try:
78
+ with timeline_brain_db.transaction(project_root) as txn:
79
+ cursor = txn.execute(
80
+ """
81
+ INSERT INTO resolve_ai_op_usage(
82
+ op, op_class, clip_id, session_id, success, wall_clock_ms,
83
+ output_path, output_bytes, extra_required, error,
84
+ occurred_at, day_bucket
85
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
+ """,
87
+ (
88
+ op, meta["op_class"], clip_id, session_id, 1 if success else 0,
89
+ int(wall_clock_ms), output_path,
90
+ int(output_bytes) if output_bytes is not None else None,
91
+ meta["extra_required"], error,
92
+ _iso(now), _day_bucket(now),
93
+ ),
94
+ )
95
+ return cursor.lastrowid
96
+ except Exception as exc: # pragma: no cover - defensive
97
+ logger.debug("resolve_ai_ledger.record_op failed: %s", exc)
98
+ return None
99
+
100
+
101
+ class timed:
102
+ """Context manager that times a Resolve AI op and records it on exit.
103
+
104
+ Usage:
105
+ with resolve_ai_ledger.timed(project_root, "analyze_for_slate", clip_id=cid) as rec:
106
+ ok = bool(clip.AnalyzeForSlate(color))
107
+ rec.success = ok
108
+ # row written automatically; exceptions are recorded then re-raised.
109
+
110
+ For media-creating ops, set rec.output_path / rec.output_bytes before exit.
111
+ All recording is best-effort and never masks the op's own exception.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ project_root: Optional[str],
117
+ op: str,
118
+ *,
119
+ clip_id: Optional[str] = None,
120
+ session_id: Optional[str] = None,
121
+ ) -> None:
122
+ self.project_root = project_root
123
+ self.op = op
124
+ self.clip_id = clip_id
125
+ self.session_id = session_id
126
+ self.success: bool = False
127
+ self.output_path: Optional[str] = None
128
+ self.output_bytes: Optional[int] = None
129
+ self.error: Optional[str] = None
130
+ self.row_id: Optional[int] = None
131
+ self._start: float = 0.0
132
+
133
+ def __enter__(self) -> "timed":
134
+ self._start = time.time()
135
+ return self
136
+
137
+ def __exit__(self, exc_type, exc, tb) -> bool:
138
+ wall_clock_ms = int((time.time() - self._start) * 1000)
139
+ if exc is not None and self.error is None:
140
+ self.error = f"{exc_type.__name__}: {exc}" if exc_type else str(exc)
141
+ if self.project_root:
142
+ self.row_id = record_op(
143
+ project_root=self.project_root,
144
+ op=self.op,
145
+ clip_id=self.clip_id,
146
+ session_id=self.session_id,
147
+ success=self.success,
148
+ wall_clock_ms=wall_clock_ms,
149
+ output_path=self.output_path,
150
+ output_bytes=self.output_bytes,
151
+ error=self.error,
152
+ )
153
+ return False # never suppress the op's own exception
154
+
155
+
156
+ def get_usage(
157
+ *,
158
+ project_root: str,
159
+ session_id: Optional[str] = None,
160
+ op: Optional[str] = None,
161
+ limit: int = 100,
162
+ ) -> List[Dict[str, Any]]:
163
+ """Return recent ledger rows, newest first."""
164
+ if not project_root:
165
+ return []
166
+ clauses: List[str] = []
167
+ args: List[Any] = []
168
+ if session_id:
169
+ clauses.append("session_id = ?")
170
+ args.append(session_id)
171
+ if op:
172
+ clauses.append("op = ?")
173
+ args.append(op)
174
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
175
+ try:
176
+ conn = timeline_brain_db.connect(project_root)
177
+ rows = conn.execute(
178
+ f"""
179
+ SELECT op, op_class, clip_id, session_id, success, wall_clock_ms,
180
+ output_path, output_bytes, extra_required, error, occurred_at
181
+ FROM resolve_ai_op_usage{where}
182
+ ORDER BY id DESC LIMIT ?
183
+ """,
184
+ (*args, int(limit)),
185
+ ).fetchall()
186
+ except Exception as exc: # pragma: no cover - defensive
187
+ logger.debug("resolve_ai_ledger.get_usage failed: %s", exc)
188
+ return []
189
+ return [dict(r) for r in rows]
190
+
191
+
192
+ def get_summary(*, project_root: str, session_id: Optional[str] = None) -> Dict[str, Any]:
193
+ """Aggregate ledger rows into per-op and overall totals.
194
+
195
+ Returns counts, successes, total wall-clock, and total files/bytes created
196
+ (the latter meaningful only for render-class ops).
197
+ """
198
+ empty = {"by_op": {}, "totals": {"runs": 0, "successes": 0, "failures": 0,
199
+ "wall_clock_ms": 0, "files_created": 0, "bytes_created": 0}}
200
+ if not project_root:
201
+ return empty
202
+ clause = " WHERE session_id = ?" if session_id else ""
203
+ args = (session_id,) if session_id else ()
204
+ try:
205
+ conn = timeline_brain_db.connect(project_root)
206
+ rows = conn.execute(
207
+ f"""
208
+ SELECT op, op_class, success, wall_clock_ms, output_path, output_bytes
209
+ FROM resolve_ai_op_usage{clause}
210
+ """,
211
+ args,
212
+ ).fetchall()
213
+ except Exception as exc: # pragma: no cover - defensive
214
+ logger.debug("resolve_ai_ledger.get_summary failed: %s", exc)
215
+ return empty
216
+
217
+ by_op: Dict[str, Dict[str, Any]] = {}
218
+ totals = {"runs": 0, "successes": 0, "failures": 0, "wall_clock_ms": 0,
219
+ "files_created": 0, "bytes_created": 0}
220
+ for r in rows:
221
+ op = r["op"]
222
+ bucket = by_op.setdefault(op, {
223
+ "op_class": r["op_class"], "runs": 0, "successes": 0, "failures": 0,
224
+ "wall_clock_ms": 0, "files_created": 0, "bytes_created": 0,
225
+ })
226
+ bucket["runs"] += 1
227
+ totals["runs"] += 1
228
+ if r["success"]:
229
+ bucket["successes"] += 1
230
+ totals["successes"] += 1
231
+ else:
232
+ bucket["failures"] += 1
233
+ totals["failures"] += 1
234
+ bucket["wall_clock_ms"] += r["wall_clock_ms"] or 0
235
+ totals["wall_clock_ms"] += r["wall_clock_ms"] or 0
236
+ if r["output_path"]:
237
+ bucket["files_created"] += 1
238
+ totals["files_created"] += 1
239
+ if r["output_bytes"]:
240
+ bucket["bytes_created"] += r["output_bytes"]
241
+ totals["bytes_created"] += r["output_bytes"]
242
+ return {"by_op": by_op, "totals": totals}
@@ -29,7 +29,7 @@ from typing import Callable, Dict, Iterator, Optional, Tuple
29
29
 
30
30
  logger = logging.getLogger("resolve-mcp.timeline-brain-db")
31
31
 
32
- SCHEMA_VERSION = 6
32
+ SCHEMA_VERSION = 7
33
33
  DB_FILENAME = "timeline_brain.sqlite"
34
34
  SOUL_DIRNAME = "_soul"
35
35
 
@@ -422,6 +422,48 @@ def _migrate_v6_caps_events(conn: sqlite3.Connection) -> None:
422
422
  )
423
423
 
424
424
 
425
+ # ── v7 migration: resolve_ai_op_usage ledger for Resolve 21 GPU/AI ops ──────
426
+
427
+
428
+ @register_migration(7)
429
+ def _migrate_v7_resolve_ai_op_usage(conn: sqlite3.Connection) -> None:
430
+ """Ledger for Resolve-local AI ops (audio classification, IntelliSearch,
431
+ slate, motion-deblur, speech generation).
432
+
433
+ These run on Resolve's own GPU/AI engine and do NOT consume the Claude-side
434
+ analysis token budget tracked in `analysis_token_usage`, so they get their
435
+ own ledger. The value is the wall-clock + file/byte accounting for the two
436
+ media-creating ops (remove_motion_blur, generate_speech). `op_class` is
437
+ 'analysis' (no media produced) or 'render' (new media file written).
438
+ """
439
+ conn.executescript(
440
+ """
441
+ CREATE TABLE IF NOT EXISTS resolve_ai_op_usage (
442
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
443
+ op TEXT NOT NULL,
444
+ op_class TEXT NOT NULL DEFAULT 'analysis',
445
+ clip_id TEXT,
446
+ session_id TEXT,
447
+ success INTEGER NOT NULL DEFAULT 0,
448
+ wall_clock_ms INTEGER NOT NULL DEFAULT 0,
449
+ output_path TEXT,
450
+ output_bytes INTEGER,
451
+ extra_required TEXT,
452
+ error TEXT,
453
+ occurred_at TEXT NOT NULL,
454
+ day_bucket TEXT NOT NULL
455
+ );
456
+
457
+ CREATE INDEX IF NOT EXISTS ix_resolve_ai_op_usage_op
458
+ ON resolve_ai_op_usage(op);
459
+ CREATE INDEX IF NOT EXISTS ix_resolve_ai_op_usage_session
460
+ ON resolve_ai_op_usage(session_id);
461
+ CREATE INDEX IF NOT EXISTS ix_resolve_ai_op_usage_day
462
+ ON resolve_ai_op_usage(day_bucket);
463
+ """
464
+ )
465
+
466
+
425
467
  @register_migration(5)
426
468
  def _migrate_v5_analysis_token_usage(conn: sqlite3.Connection) -> None:
427
469
  """Track real vendor token + frame upload usage so caps can enforce budgets.