davinci-resolve-mcp 2.30.0 → 2.32.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,68 @@
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.32.0
6
+
7
+ Adds **governance tiers** for the media-creating Resolve 21 AI ops (Phase 3, the
8
+ final phase of the AI-ops build: ledger → console → governance).
9
+
10
+ The two ops that render new files — `remove_motion_blur` and `generate_speech` —
11
+ now have **soft, per-session limits** chosen by a named tier:
12
+
13
+ | dimension | off | lenient | standard | strict |
14
+ |---|---|---|---|---|
15
+ | deblur runs | ∞ | 50 | 15 | 5 |
16
+ | speech runs | ∞ | 50 | 15 | 5 |
17
+ | media created | ∞ | 50 GB | 10 GB | 2 GB |
18
+ | render time | ∞ | 1 h | 20 min | 5 min |
19
+
20
+ Governance is **advisory** — it never hard-blocks (the ops are already
21
+ confirm-token gated). When you're near or over the active tier, the confirmation
22
+ dialog surfaces a warning before you proceed; the limits are computed from the
23
+ v2.30.0 AI-ops ledger for the current session.
24
+
25
+ - **New module** `src/utils/resolve_ai_governance.py` — tiers + `check()` (status
26
+ for the next run) + `status()` (session usage vs tier). Default tier: `standard`.
27
+ - **New MCP actions** `media_analysis(action="get_ai_governance")` and
28
+ `media_analysis(action="set_ai_governance", preset, overrides?)`. Override keys:
29
+ `deblur_runs`, `speech_runs`, `render_bytes`, `render_wall_clock_ms` (int or `"unlimited"`).
30
+ - **Confirm preview** for the two creators now carries a `governance` block
31
+ (current usage, projected, warnings, exceeded/near flags).
32
+ - **Control panel** — the AI Console gains a **Governance** section: a tier picker
33
+ (off/lenient/standard/strict, with each tier's thresholds) plus live
34
+ consumption gauges, and the confirm dialog shows the warning inline.
35
+
36
+ This completes the staged AI-ops build. Validated live against Resolve Studio
37
+ 21.0.0.47 (tier switching, persistence, gauges, confirm-dialog warnings).
38
+
39
+ ## What's New in v2.31.0
40
+
41
+ Adds the **AI Console** to the control panel — an interactive surface for the
42
+ Resolve 21 local AI operations (Phase 2 of the staged AI-ops build: ledger →
43
+ console → governance).
44
+
45
+ A new **AI Console** tab runs the 21.0 ops against the current Media Pool folder
46
+ or a specific clip:
47
+
48
+ - **Capability matrix** — shows which AI methods this Resolve build exposes (green
49
+ = available, grey = absent on older builds) and which Extra each gated method
50
+ needs to actually run.
51
+ - **Analysis** — Classify audio / Clear classification, IntelliSearch (with
52
+ identify-faces and Better-mode toggles), Analyze for slate (16-color marker
53
+ picker), Transcribe (with speaker-detection toggle), Clear transcription.
54
+ - **Motion deblur** and **Speech generator** — full options forms; because both
55
+ create new media files they route through a confirmation modal (the same
56
+ confirm-token gate the MCP tools use) before running.
57
+ - **Session** — Disable background tasks for the current Resolve session.
58
+ - A live result readout, and the *Resolve 21 AI ops* ledger refreshes after each
59
+ run so file/byte totals stay current.
60
+
61
+ Backend: a loopback-only `POST /api/resolve_ai/run` endpoint dispatches each op
62
+ to the consolidated `folder` / `media_pool_item` / `project_settings` /
63
+ `resolve_control` tools, relaying the confirm-token two-step. No new MCP tools or
64
+ Resolve API surface — the console reuses the existing v2.29.0 actions. Validated
65
+ live end-to-end against Resolve Studio 21.0.0.47.
66
+
5
67
  ## What's New in v2.30.0
6
68
 
7
69
  Adds the **Resolve 21 AI-ops ledger** — usage/time/file accounting for the
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.30.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.32.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
@@ -677,6 +677,14 @@ spend the Claude analysis token budget — they are tracked separately in the
677
677
  `remove_motion_blur` / `generate_speech`). The control panel shows the same as a
678
678
  read-only "Resolve 21 AI ops" card.
679
679
 
680
+ The two media-creating ops also have **soft governance tiers**
681
+ (`off`|`lenient`|`standard`|`strict`, default `standard`) capping per-session
682
+ deblur/speech runs, bytes, and render time. It is advisory — the confirm dialog
683
+ warns when near/over the tier but never blocks (the ops are confirm-gated).
684
+ Inspect/set with `media_analysis(action="get_ai_governance")` and
685
+ `media_analysis(action="set_ai_governance", preset=…, overrides={...})`; the AI
686
+ Console's Governance section offers a tier picker + consumption gauges.
687
+
680
688
  The caps layer:
681
689
  - Slices `frame_paths` to `frames_per_clip` before the host LLM sees them.
682
690
  - 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.30.0"
38
+ VERSION = "2.32.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.30.0",
3
+ "version": "2.32.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -2378,6 +2378,51 @@ HTML = r"""<!doctype html>
2378
2378
  font-size: var(--text-xs);
2379
2379
  color: var(--text-muted);
2380
2380
  }
2381
+ .ai-op-row {
2382
+ display: flex;
2383
+ flex-wrap: wrap;
2384
+ align-items: center;
2385
+ gap: var(--space-3);
2386
+ grid-column: 1 / -1;
2387
+ margin: var(--space-1) 0;
2388
+ }
2389
+ .ai-op-btn {
2390
+ padding: 6px 14px;
2391
+ border-radius: var(--radius-sm, 6px);
2392
+ border: 1px solid var(--border, rgba(255,255,255,0.18));
2393
+ background: var(--accent, #3b82f6);
2394
+ color: #fff;
2395
+ font-size: var(--text-sm, 13px);
2396
+ cursor: pointer;
2397
+ }
2398
+ .ai-op-btn:hover { filter: brightness(1.08); }
2399
+ .ai-op-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
2400
+ .ai-op-btn.ghost { background: transparent; color: var(--text, inherit); }
2401
+ .ai-op-btn.danger { background: #b4452f; }
2402
+ .ai-caps-grid {
2403
+ display: grid;
2404
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
2405
+ gap: var(--space-2);
2406
+ margin-top: var(--space-2);
2407
+ }
2408
+ .ai-caps-item {
2409
+ display: flex;
2410
+ align-items: center;
2411
+ gap: 8px;
2412
+ font-size: var(--text-xs);
2413
+ }
2414
+ .ai-caps-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
2415
+ .ai-caps-dot.on { background: #34a853; }
2416
+ .ai-caps-dot.off { background: rgba(255,255,255,0.25); }
2417
+ .ai-caps-extra { opacity: 0.6; }
2418
+ .ai-console-result {
2419
+ white-space: pre-wrap;
2420
+ font-family: var(--mono, ui-monospace, monospace);
2421
+ max-height: 240px;
2422
+ overflow: auto;
2423
+ }
2424
+ .ai-console-result.ok { color: var(--text, inherit); }
2425
+ .ai-console-result.err { color: #e06c5a; }
2381
2426
  .caps-section-subtitle {
2382
2427
  font-size: var(--text-xs);
2383
2428
  color: var(--text-muted);
@@ -3777,6 +3822,7 @@ HTML = r"""<!doctype html>
3777
3822
  <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>
3778
3823
  </div>
3779
3824
  </div>
3825
+ <button class="control-tab" data-panel-target="aiconsole">AI Console</button>
3780
3826
  <div class="control-nav-item">
3781
3827
  <button class="control-tab has-menu" data-panel-target="diagnostics">Setup <span class="tab-chevron" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg></span></button>
3782
3828
  <div class="nav-dropdown" role="menu" aria-label="Diagnostic pages">
@@ -4119,6 +4165,101 @@ HTML = r"""<!doctype html>
4119
4165
  </section>
4120
4166
  </main>
4121
4167
 
4168
+ <main id="panel-aiconsole" class="panel control-grid">
4169
+ <section class="span-12">
4170
+ <div class="section-top">
4171
+ <div>
4172
+ <h2>Resolve 21 AI Console</h2>
4173
+ <p class="section-sub">Run Resolve's local AI operations on the current Media Pool folder or a specific clip. These run on Resolve's GPU/AI engine — the analysis and slate ops are safe and reversible; <strong>motion-deblur</strong> and <strong>speech generation</strong> create new media files and ask for confirmation first. Source media is never modified. Every run is recorded in the <em>Resolve 21 AI ops</em> ledger (Preferences → Caps + Safety).</p>
4174
+ </div>
4175
+ </div>
4176
+
4177
+ <div id="aiConsoleCaps" class="caps-section" style="margin-top:12px;">
4178
+ <div class="caps-section-hint">Checking which AI methods this Resolve build exposes…</div>
4179
+ </div>
4180
+
4181
+ <div class="caps-section" style="margin-top:12px;">
4182
+ <div class="caps-section-head"><div class="caps-section-title">Governance</div>
4183
+ <div class="caps-section-hint">Soft per-session limits for the two media-creating ops (deblur, speech). Advisory only — you'll be warned in the confirm dialog, but never blocked (the ops are confirm-gated). Pick the tier that matches the job.</div></div>
4184
+ <div id="aiGovTiers" class="caps-preset-cards" role="radiogroup" aria-label="AI governance tier"></div>
4185
+ <div id="aiGovUsage" class="caps-usage-gauges" style="margin-top:10px;"></div>
4186
+ </div>
4187
+
4188
+ <div class="caps-section" style="margin-top:12px;">
4189
+ <div class="caps-section-head"><div class="caps-section-title">Target</div>
4190
+ <div class="caps-section-hint">Folder ops run on the current Media Pool folder in Resolve. Choose <em>Specific clip</em> and paste a clip id (from <code>media_pool.get_clips</code>) to target one clip.</div></div>
4191
+ <div class="settings-grid">
4192
+ <label class="checkbox-row"><input type="radio" name="aiTarget" value="folder" checked><span>Current folder</span></label>
4193
+ <label class="checkbox-row"><input type="radio" name="aiTarget" value="clip"><span>Specific clip</span></label>
4194
+ <label>Clip id <input id="aiClipId" type="text" placeholder="(clip UniqueId)"></label>
4195
+ </div>
4196
+ </div>
4197
+
4198
+ <div class="caps-section" style="margin-top:12px;">
4199
+ <div class="caps-section-head"><div class="caps-section-title">Analysis</div>
4200
+ <div class="caps-section-hint">Safe, reversible. IntelliSearch and Slate require their AI Extras (Extras Download Manager).</div></div>
4201
+ <div class="settings-grid">
4202
+ <div class="ai-op-row"><button class="ai-op-btn" data-ai-op="perform_audio_classification">Classify audio</button>
4203
+ <button class="ai-op-btn ghost" data-ai-op="clear_audio_classification">Clear classification</button></div>
4204
+ <div class="ai-op-row">
4205
+ <button class="ai-op-btn" data-ai-op="analyze_for_intellisearch">IntelliSearch</button>
4206
+ <label class="checkbox-row"><input id="aiIdentifyFaces" type="checkbox"><span>Identify faces</span></label>
4207
+ <label class="checkbox-row"><input id="aiBetterMode" type="checkbox"><span>Better mode</span></label>
4208
+ </div>
4209
+ <div class="ai-op-row">
4210
+ <button class="ai-op-btn" data-ai-op="analyze_for_slate">Analyze for slate</button>
4211
+ <label>Marker <select id="aiSlateColor"></select></label>
4212
+ </div>
4213
+ <div class="ai-op-row">
4214
+ <button class="ai-op-btn" data-ai-op="transcribe_audio">Transcribe</button>
4215
+ <label class="checkbox-row"><input id="aiSpeakerDetection" type="checkbox"><span>Speaker detection</span></label>
4216
+ <button class="ai-op-btn ghost" data-ai-op="clear_transcription">Clear transcription</button>
4217
+ </div>
4218
+ </div>
4219
+ </div>
4220
+
4221
+ <div class="caps-section" style="margin-top:12px;">
4222
+ <div class="caps-section-head"><div class="caps-section-title">Motion deblur</div>
4223
+ <div class="caps-section-hint">Renders new deblurred media. Creates files; asks for confirmation. Leave fields blank for Resolve defaults.</div></div>
4224
+ <div class="settings-grid">
4225
+ <label>Format <input id="aiDeblurFormat" type="text" placeholder="mov"></label>
4226
+ <label>Codec <input id="aiDeblurCodec" type="text" placeholder="ProRes422"></label>
4227
+ <label class="checkbox-row"><input id="aiDeblurExtreme" type="checkbox"><span>Extreme mode</span></label>
4228
+ <label class="checkbox-row"><input id="aiDeblurMarkInOut" type="checkbox"><span>Use mark in/out</span></label>
4229
+ <label class="checkbox-row"><input id="aiDeblurSourceRes" type="checkbox"><span>Render at source res</span></label>
4230
+ <div class="ai-op-row"><button class="ai-op-btn danger" data-ai-op="remove_motion_blur">Remove motion blur</button></div>
4231
+ </div>
4232
+ </div>
4233
+
4234
+ <div class="caps-section" style="margin-top:12px;">
4235
+ <div class="caps-section-head"><div class="caps-section-title">Speech generator</div>
4236
+ <div class="caps-section-hint">AI text-to-speech. Requires the AI Speech Generator Extra. Creates a new audio item; asks for confirmation.</div></div>
4237
+ <div class="settings-grid">
4238
+ <label style="grid-column:1/-1;">Text <textarea id="aiSpeechText" rows="2" placeholder="Text to synthesize"></textarea></label>
4239
+ <label>Voice model <input id="aiSpeechVoice" type="text" placeholder="Female 1"></label>
4240
+ <label>Speed <input id="aiSpeechSpeed" type="number" placeholder="(default)"></label>
4241
+ <label>Pitch <input id="aiSpeechPitch" type="number" placeholder="(default)"></label>
4242
+ <label>Variation <input id="aiSpeechVariation" type="number" placeholder="(default)"></label>
4243
+ <label class="checkbox-row"><input id="aiSpeechAddTimeline" type="checkbox"><span>Add to timeline</span></label>
4244
+ <label>Timecode <input id="aiSpeechTimecode" type="text" placeholder="01:00:00:00"></label>
4245
+ <label>Audio track <input id="aiSpeechTrack" type="number" placeholder="(default)"></label>
4246
+ <div class="ai-op-row"><button class="ai-op-btn danger" data-ai-op="generate_speech">Generate speech</button></div>
4247
+ </div>
4248
+ </div>
4249
+
4250
+ <div class="caps-section" style="margin-top:12px;">
4251
+ <div class="caps-section-head"><div class="caps-section-title">Session</div>
4252
+ <div class="caps-section-hint">Quiet Resolve's background tasks for this session before heavy work. Resets on restart.</div></div>
4253
+ <div class="ai-op-row"><button class="ai-op-btn ghost" data-ai-op="disable_background_tasks">Disable background tasks</button></div>
4254
+ </div>
4255
+
4256
+ <div class="caps-section" style="margin-top:12px;">
4257
+ <div class="caps-section-head"><div class="caps-section-title">Result</div></div>
4258
+ <div id="aiConsoleResult" class="ai-console-result caps-section-hint">No op run yet.</div>
4259
+ </div>
4260
+ </section>
4261
+ </main>
4262
+
4122
4263
  <main id="panel-diagnostics" class="panel control-grid">
4123
4264
  <section class="span-12 subpage active" data-subpage-scope="diagnostics" data-subpage="resolve">
4124
4265
  <h2>Resolve</h2>
@@ -4712,6 +4853,7 @@ HTML = r"""<!doctype html>
4712
4853
  const PANEL_LABELS = {
4713
4854
  overview: 'Overview',
4714
4855
  analysis: 'Analysis',
4856
+ aiconsole: 'AI Console',
4715
4857
  diagnostics: 'Setup',
4716
4858
  projects: 'Projects',
4717
4859
  docs: 'Docs',
@@ -4720,6 +4862,7 @@ HTML = r"""<!doctype html>
4720
4862
  const PANEL_ICONS = {
4721
4863
  overview: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>',
4722
4864
  analysis: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"></path><path d="m19 9-5 5-4-4-3 3"></path></svg>',
4865
+ aiconsole: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"></path><rect x="4" y="12" width="16" height="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>',
4723
4866
  diagnostics: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l2.1-2.1a6 6 0 0 1-7.6 7.6l-4 4a2.1 2.1 0 0 1-3-3l4-4a6 6 0 0 1 7.6-7.6z"></path></svg>',
4724
4867
  projects: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 3h11v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><path d="M7 7V5a2 2 0 0 1 2-2h3l2 2h3a2 2 0 0 1 2 2v3"></path></svg>',
4725
4868
  docs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"></path></svg>',
@@ -4882,6 +5025,9 @@ HTML = r"""<!doctype html>
4882
5025
  syncPreferencesPanel();
4883
5026
  refreshSetupDefaults().catch(alertError);
4884
5027
  }
5028
+ if (next === 'aiconsole') {
5029
+ initAiConsole();
5030
+ }
4885
5031
  if (next === 'docs' && subpage) {
4886
5032
  loadDoc(subpage, { updateHash: false }).catch(alertError);
4887
5033
  }
@@ -7111,6 +7257,249 @@ HTML = r"""<!doctype html>
7111
7257
  tableEl.style.display = '';
7112
7258
  }
7113
7259
 
7260
+ // ─── Resolve 21 AI Console ──────────────────────────────────────
7261
+ const AI_MARKER_COLORS = ['Blue','Cyan','Green','Yellow','Red','Pink','Purple',
7262
+ 'Fuchsia','Rose','Lavender','Sky','Mint','Lemon','Sand','Cocoa','Cream'];
7263
+ const AI_OP_LABELS = {
7264
+ perform_audio_classification: 'Classify audio',
7265
+ clear_audio_classification: 'Clear classification',
7266
+ analyze_for_intellisearch: 'IntelliSearch',
7267
+ analyze_for_slate: 'Analyze for slate',
7268
+ transcribe_audio: 'Transcribe',
7269
+ clear_transcription: 'Clear transcription',
7270
+ remove_motion_blur: 'Remove motion blur',
7271
+ generate_speech: 'Generate speech',
7272
+ disable_background_tasks: 'Disable background tasks',
7273
+ };
7274
+ // Which features gate which buttons (key in resolve.ai_features.features).
7275
+ const AI_OP_FEATURE = {
7276
+ perform_audio_classification: 'perform_audio_classification',
7277
+ clear_audio_classification: 'clear_audio_classification',
7278
+ analyze_for_intellisearch: 'analyze_for_intellisearch',
7279
+ analyze_for_slate: 'analyze_for_slate',
7280
+ remove_motion_blur: 'remove_motion_blur',
7281
+ generate_speech: 'generate_speech',
7282
+ disable_background_tasks: 'disable_background_tasks',
7283
+ };
7284
+ let _aiConsoleInit = false;
7285
+
7286
+ function renderAiConsole() {
7287
+ const feats = (state.boot?.resolve?.ai_features) || {};
7288
+ const features = feats.features || {};
7289
+ const requiresExtra = feats.requires_extra || {};
7290
+ const capsEl = $('aiConsoleCaps');
7291
+ if (capsEl) {
7292
+ if (state.boot?.resolve?.available !== true) {
7293
+ capsEl.innerHTML = '<div class="caps-section-hint">Resolve is not connected. Open a project in DaVinci Resolve, then reload.</div>';
7294
+ } else {
7295
+ const items = Object.keys(AI_OP_LABELS)
7296
+ .filter(op => op in AI_OP_FEATURE)
7297
+ .map(op => {
7298
+ const key = AI_OP_FEATURE[op];
7299
+ const on = !!features[key];
7300
+ const extra = requiresExtra[key];
7301
+ return `<div class="ai-caps-item"><span class="ai-caps-dot ${on ? 'on' : 'off'}"></span>`
7302
+ + `<span>${escapeHtml(AI_OP_LABELS[op])}</span>`
7303
+ + (extra ? `<span class="ai-caps-extra">· needs ${escapeHtml(extra)}</span>` : '')
7304
+ + `</div>`;
7305
+ }).join('');
7306
+ capsEl.innerHTML = `<div class="caps-section-head"><div class="caps-section-title">Available on this Resolve build</div>`
7307
+ + `<div class="caps-section-hint">A grey dot means the method is absent (older Resolve). "needs …" means the method is present but requires that Extra to actually run — install via Extras Download Manager.</div></div>`
7308
+ + `<div class="ai-caps-grid">${items}</div>`;
7309
+ }
7310
+ }
7311
+ // Slate color dropdown (once).
7312
+ const sel = $('aiSlateColor');
7313
+ if (sel && !sel.options.length) {
7314
+ sel.innerHTML = AI_MARKER_COLORS.map(c => `<option value="${c}">${c}</option>`).join('');
7315
+ }
7316
+ }
7317
+
7318
+ function aiTarget() {
7319
+ const checked = document.querySelector('input[name="aiTarget"]:checked');
7320
+ return checked ? checked.value : 'folder';
7321
+ }
7322
+
7323
+ function aiBuildParams(op) {
7324
+ const params = {};
7325
+ if (op === 'analyze_for_intellisearch') {
7326
+ params.identify_faces = !!$('aiIdentifyFaces')?.checked;
7327
+ params.is_better_mode = !!$('aiBetterMode')?.checked;
7328
+ } else if (op === 'analyze_for_slate') {
7329
+ params.marker_color = $('aiSlateColor')?.value || 'Blue';
7330
+ } else if (op === 'transcribe_audio') {
7331
+ if ($('aiSpeakerDetection')?.checked) params.use_speaker_detection = true;
7332
+ } else if (op === 'remove_motion_blur') {
7333
+ const d = {};
7334
+ const fmt = ($('aiDeblurFormat')?.value || '').trim(); if (fmt) d.Format = fmt;
7335
+ const codec = ($('aiDeblurCodec')?.value || '').trim(); if (codec) d.Codec = codec;
7336
+ if ($('aiDeblurExtreme')?.checked) d.UseExtremeMode = true;
7337
+ if ($('aiDeblurMarkInOut')?.checked) d.UseMarkInMarkOut = true;
7338
+ if ($('aiDeblurSourceRes')?.checked) d.RenderAtSourceRes = true;
7339
+ params.deblur_option = d;
7340
+ } else if (op === 'generate_speech') {
7341
+ const text = ($('aiSpeechText')?.value || '').trim();
7342
+ const settings = { TextInput: text };
7343
+ const voice = ($('aiSpeechVoice')?.value || '').trim(); if (voice) settings.VoiceModel = voice;
7344
+ const num = (id) => { const v = ($(id)?.value || '').trim(); return v === '' ? null : Number(v); };
7345
+ const speed = num('aiSpeechSpeed'); if (speed != null) settings.Speed = speed;
7346
+ const pitch = num('aiSpeechPitch'); if (pitch != null) settings.Pitch = pitch;
7347
+ const variation = num('aiSpeechVariation'); if (variation != null) settings.Variation = variation;
7348
+ if ($('aiSpeechAddTimeline')?.checked) {
7349
+ settings.AddToTimeline = true;
7350
+ const track = num('aiSpeechTrack'); if (track != null) settings.AudioTrack = track;
7351
+ }
7352
+ params.speech_generation_settings = settings;
7353
+ const tc = ($('aiSpeechTimecode')?.value || '').trim(); if (tc) params.timecode = tc;
7354
+ }
7355
+ return params;
7356
+ }
7357
+
7358
+ function aiShowResult(op, payload, isErr) {
7359
+ const el = $('aiConsoleResult');
7360
+ if (!el) return;
7361
+ el.classList.toggle('err', !!isErr);
7362
+ el.classList.toggle('ok', !isErr);
7363
+ const head = `${AI_OP_LABELS[op] || op} — ${new Date().toLocaleTimeString()}\n`;
7364
+ el.textContent = head + JSON.stringify(payload, null, 2);
7365
+ }
7366
+
7367
+ async function runAiOp(op) {
7368
+ const target = (op === 'generate_speech' || op === 'disable_background_tasks') ? 'folder' : aiTarget();
7369
+ const params = aiBuildParams(op);
7370
+ if (target === 'clip') {
7371
+ const clipId = ($('aiClipId')?.value || '').trim();
7372
+ if (!clipId) { aiShowResult(op, { error: 'Enter a clip id, or switch target to Current folder.' }, true); return; }
7373
+ params.clip_id = clipId;
7374
+ }
7375
+ if (op === 'generate_speech' && !params.speech_generation_settings?.TextInput) {
7376
+ aiShowResult(op, { error: 'Enter text to synthesize.' }, true); return;
7377
+ }
7378
+ const buttons = document.querySelectorAll('.ai-op-btn');
7379
+ buttons.forEach(b => { b.disabled = true; });
7380
+ try {
7381
+ let res = await api('/api/resolve_ai/run', {
7382
+ method: 'POST', body: JSON.stringify({ op, target, params }),
7383
+ }).catch(err => ({ success: false, error: String(err && err.message || err) }));
7384
+ // Confirm-token two-step for the media-creating ops.
7385
+ if (res && res.status === 'confirmation_required') {
7386
+ const preview = res.preview || {};
7387
+ const gov = preview.governance || {};
7388
+ const govWarn = (gov.applies && (gov.warnings || []).length)
7389
+ ? '\n\nGovernance (' + (gov.tier || '') + '):\n• ' + gov.warnings.join('\n• ')
7390
+ : '';
7391
+ const proceed = await brandedConfirm({
7392
+ kicker: gov.exceeded ? 'Over governance limit' : 'Creates new media',
7393
+ title: AI_OP_LABELS[op] || op,
7394
+ body: (preview.warning || 'This operation creates new media. Proceed?') + govWarn,
7395
+ detail: JSON.stringify(preview, null, 2),
7396
+ confirmLabel: 'Run it',
7397
+ tone: 'danger',
7398
+ });
7399
+ if (!proceed) { aiShowResult(op, { cancelled: true }, false); return; }
7400
+ const params2 = { ...params, confirm_token: res.confirm_token };
7401
+ res = await api('/api/resolve_ai/run', {
7402
+ method: 'POST', body: JSON.stringify({ op, target, params: params2 }),
7403
+ }).catch(err => ({ success: false, error: String(err && err.message || err) }));
7404
+ }
7405
+ aiShowResult(op, res, !(res && res.success));
7406
+ // Refresh the ledger + governance widgets so totals stay current.
7407
+ refreshResolveAiOps().catch(() => {});
7408
+ refreshGovernance().catch(() => {});
7409
+ } finally {
7410
+ buttons.forEach(b => { b.disabled = false; });
7411
+ }
7412
+ }
7413
+
7414
+ // ─── Governance tier selector + consumption ─────────────────────
7415
+ const AI_GOV_TIER_ORDER = ['off', 'lenient', 'standard', 'strict'];
7416
+ const AI_GOV_TIER_TAGS = {
7417
+ off: 'No limits',
7418
+ lenient: 'Big jobs',
7419
+ standard: 'Sensible default',
7420
+ strict: 'Tight leash',
7421
+ };
7422
+ state.aiGov = state.aiGov || { tier: 'standard', thresholds: {}, usage: {} };
7423
+
7424
+ function aiGovFmt(dim, v) {
7425
+ if (v == null) return '∞';
7426
+ if (dim === 'render_bytes') return fmtBytes(v);
7427
+ if (dim === 'render_wall_clock_ms') return fmtMs(v);
7428
+ return String(v);
7429
+ }
7430
+ function renderGovTiers() {
7431
+ const el = $('aiGovTiers');
7432
+ if (!el) return;
7433
+ const tiers = state.aiGov.tiersAvailable || {};
7434
+ const active = state.aiGov.tier || 'standard';
7435
+ el.innerHTML = AI_GOV_TIER_ORDER.filter(k => tiers[k]).map(key => {
7436
+ const t = tiers[key];
7437
+ const stats = [['deblur_runs','Deblur runs'],['speech_runs','Speech runs'],
7438
+ ['render_bytes','Media'],['render_wall_clock_ms','Render time']].map(([d,label]) =>
7439
+ `<span class="stat-label">${escapeHtml(label)}</span><span class="stat-value">${escapeHtml(aiGovFmt(d, t[d]))}</span>`).join('');
7440
+ return `<button type="button" class="caps-preset-card${key === active ? ' is-active' : ''}" data-gov-tier="${escapeHtml(key)}" role="radio" aria-checked="${key === active}">
7441
+ <div class="caps-preset-card-head"><span class="caps-preset-card-name">${escapeHtml(key)}</span>${key === active ? '<span class="caps-preset-card-badge">Active</span>' : ''}</div>
7442
+ <div class="caps-preset-card-tag">${escapeHtml(AI_GOV_TIER_TAGS[key] || '')}</div>
7443
+ <div class="caps-preset-card-stats">${stats}</div>
7444
+ </button>`;
7445
+ }).join('');
7446
+ }
7447
+ function renderGovUsage() {
7448
+ const el = $('aiGovUsage');
7449
+ if (!el) return;
7450
+ const usage = state.aiGov.usage || {};
7451
+ const th = state.aiGov.thresholds || {};
7452
+ const dims = [['deblur_runs','Deblur runs'],['speech_runs','Speech runs'],
7453
+ ['render_bytes','Media created'],['render_wall_clock_ms','Render time']];
7454
+ el.innerHTML = dims.map(([d,label]) => {
7455
+ const used = usage[d] || 0;
7456
+ const cap = th[d];
7457
+ const pct = (cap == null || cap === 0) ? 0 : Math.min(100, Math.round((used / cap) * 100));
7458
+ const tone = pct >= 100 ? '#e06c5a' : (pct >= 80 ? '#e0a83a' : '#34a853');
7459
+ return `<div class="caps-gauge">
7460
+ <div class="caps-gauge-top"><span class="caps-gauge-label">${escapeHtml(label)}</span>
7461
+ <span class="caps-gauge-numbers">${escapeHtml(aiGovFmt(d, used))}${cap == null ? '' : ' / ' + escapeHtml(aiGovFmt(d, cap))}</span></div>
7462
+ <div class="caps-gauge-bar"><span class="caps-gauge-fill" style="width:${pct}%; background:${tone};"></span></div>
7463
+ </div>`;
7464
+ }).join('');
7465
+ }
7466
+ async function refreshGovernance() {
7467
+ const data = await api('/api/resolve_ai/governance').catch(() => ({ success: false }));
7468
+ if (!data || !data.success) return;
7469
+ state.aiGov.tier = data.tier;
7470
+ state.aiGov.thresholds = data.thresholds || {};
7471
+ state.aiGov.usage = data.usage || {};
7472
+ state.aiGov.tiersAvailable = data.tiers_available || {};
7473
+ renderGovTiers();
7474
+ renderGovUsage();
7475
+ }
7476
+ async function setGovernanceTier(tier) {
7477
+ state.aiGov.tier = tier;
7478
+ renderGovTiers();
7479
+ const res = await api('/api/resolve_ai/governance', {
7480
+ method: 'POST', body: JSON.stringify({ preset: tier }),
7481
+ }).catch(err => ({ success: false, error: String(err) }));
7482
+ if (!res || !res.success) console.warn('governance save failed:', res && res.error);
7483
+ await refreshGovernance();
7484
+ }
7485
+
7486
+ function initAiConsole() {
7487
+ if (_aiConsoleInit) { renderAiConsole(); refreshGovernance().catch(() => {}); return; }
7488
+ _aiConsoleInit = true;
7489
+ renderAiConsole();
7490
+ document.querySelectorAll('#panel-aiconsole .ai-op-btn').forEach(btn => {
7491
+ btn.addEventListener('click', () => runAiOp(btn.dataset.aiOp));
7492
+ });
7493
+ const govEl = $('aiGovTiers');
7494
+ if (govEl) {
7495
+ govEl.addEventListener('click', (ev) => {
7496
+ const card = ev.target.closest('[data-gov-tier]');
7497
+ if (card && card.dataset.govTier) setGovernanceTier(card.dataset.govTier);
7498
+ });
7499
+ }
7500
+ refreshGovernance().catch(() => {});
7501
+ }
7502
+
7114
7503
  // ─── Caps inspector + refusals + reset ──────────────────────────
7115
7504
  async function inspectCapsFromUI() {
7116
7505
  const clipId = ($('capsInspectClipId')?.value || '').trim();
@@ -10783,6 +11172,58 @@ def _resolve_identity() -> Dict[str, Any]:
10783
11172
  }
10784
11173
 
10785
11174
 
11175
+ # ── Resolve 21 AI Console: op dispatch ──────────────────────────────────────
11176
+ # Folder/clip-level ops are routed to the consolidated `folder` /
11177
+ # `media_pool_item` tools; project/resolve-level ops to their tools. The
11178
+ # consolidated tools own the confirm-token gate for the two media-creators, so
11179
+ # this dispatcher just relays params (incl. confirm_token) and the result.
11180
+
11181
+ _AI_CONSOLE_FOLDER_OPS = frozenset({
11182
+ "perform_audio_classification", "clear_audio_classification",
11183
+ "analyze_for_intellisearch", "analyze_for_slate", "remove_motion_blur",
11184
+ "transcribe_audio", "clear_transcription",
11185
+ })
11186
+
11187
+
11188
+ def _run_resolve_ai_op(body: Dict[str, Any]) -> Dict[str, Any]:
11189
+ """Dispatch one AI Console op to the right consolidated server tool.
11190
+
11191
+ body = {op, target?, params?}. target is 'folder' (current Media Pool
11192
+ folder, default) or 'clip' (params.clip_id required). Returns the tool's
11193
+ response verbatim — including a {status:'confirmation_required', confirm_token,
11194
+ preview} shape for the gated media-creating ops.
11195
+ """
11196
+ op = (body.get("op") or "").strip()
11197
+ target = (body.get("target") or "folder").strip()
11198
+ params = dict(body.get("params") or {})
11199
+ if not op:
11200
+ return {"success": False, "error": "op is required"}
11201
+ try:
11202
+ from src.server import (
11203
+ folder as _folder_tool,
11204
+ media_pool_item as _mpi_tool,
11205
+ project_settings as _ps_tool,
11206
+ resolve_control as _rc_tool,
11207
+ )
11208
+ except Exception as exc: # pragma: no cover - import guard
11209
+ return {"success": False, "error": f"server tools unavailable: {exc}"}
11210
+
11211
+ if op == "disable_background_tasks":
11212
+ return _rc_tool("disable_background_tasks_for_current_session", {})
11213
+ if op == "generate_speech":
11214
+ return _ps_tool("generate_speech", params)
11215
+ if op not in _AI_CONSOLE_FOLDER_OPS:
11216
+ return {"success": False, "error": f"unknown op {op!r}"}
11217
+ if target == "clip":
11218
+ clip_id = params.get("clip_id") or body.get("clip_id")
11219
+ if not clip_id:
11220
+ return {"success": False, "error": "clip target requires a clip_id"}
11221
+ params["clip_id"] = clip_id
11222
+ return _mpi_tool(op, params)
11223
+ # default: operate on the current Media Pool folder
11224
+ return _folder_tool(op, params)
11225
+
11226
+
10786
11227
  def _clip_props(clip: Any) -> Dict[str, Any]:
10787
11228
  props, _ = _safe_call(clip, "GetClipProperty", "")
10788
11229
  return props if isinstance(props, dict) else {}
@@ -13699,6 +14140,16 @@ class Handler(BaseHTTPRequestHandler):
13699
14140
  except Exception as exc:
13700
14141
  self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13701
14142
  return
14143
+ if path == "/api/resolve_ai/governance":
14144
+ # Effective governance tier + this project's render usage. Proxies
14145
+ # into the media_analysis get_ai_governance action.
14146
+ try:
14147
+ from src.server import media_analysis as _ma_tool
14148
+ import asyncio
14149
+ self._json(asyncio.run(_ma_tool("get_ai_governance", params={})))
14150
+ except Exception as exc:
14151
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
14152
+ return
13702
14153
  if path.startswith("/api/timeline_thumbnail/"):
13703
14154
  rel = unquote(path[len("/api/timeline_thumbnail/"):])
13704
14155
  # Path is <slug>/<vNN.png>; constrain it to live under _soul/timeline_versions
@@ -13953,6 +14404,33 @@ class Handler(BaseHTTPRequestHandler):
13953
14404
  except Exception as exc:
13954
14405
  self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
13955
14406
  return
14407
+ if path == "/api/resolve_ai/run":
14408
+ # Run a Resolve 21 AI op from the panel. Loopback-only because it
14409
+ # mutates Resolve (and the media-creators write new files). The
14410
+ # confirm-token two-step is handled by the consolidated tool; the
14411
+ # 'confirmation_required' shape is relayed to the panel as 200.
14412
+ if not _request_is_loopback(self):
14413
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
14414
+ return
14415
+ try:
14416
+ result = _run_resolve_ai_op(body)
14417
+ ok = bool(result.get("success")) or result.get("status") == "confirmation_required"
14418
+ self._json(result, 200 if ok else 400)
14419
+ except Exception as exc:
14420
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
14421
+ return
14422
+ if path == "/api/resolve_ai/governance":
14423
+ if not _request_is_loopback(self):
14424
+ self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
14425
+ return
14426
+ try:
14427
+ from src.server import media_analysis as _ma_tool
14428
+ import asyncio
14429
+ result = asyncio.run(_ma_tool("set_ai_governance", params=body))
14430
+ self._json(result, 200 if result.get("success") else 400)
14431
+ except Exception as exc:
14432
+ self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
14433
+ return
13956
14434
  if path == "/api/caps/reset_day":
13957
14435
  if not _request_is_loopback(self):
13958
14436
  self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.30.0"
83
+ VERSION = "2.32.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.30.0"
14
+ VERSION = "2.32.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -675,6 +675,37 @@ def _ai_ledger_timed(op: str, *, clip_id: Optional[str] = None):
675
675
  )
676
676
 
677
677
 
678
+ # ─── Resolve 21 AI-ops governance (soft tiers over the ledger) ────────────────
679
+
680
+ from src.utils import resolve_ai_governance as _resolve_ai_governance
681
+
682
+
683
+ def _ai_governance_preset() -> Optional[str]:
684
+ try:
685
+ return _read_media_analysis_preferences().get("resolve_ai_governance_preset")
686
+ except Exception:
687
+ return None
688
+
689
+
690
+ def _ai_governance_overrides() -> Optional[Dict[str, Any]]:
691
+ try:
692
+ raw = _read_media_analysis_preferences().get("resolve_ai_governance_overrides") or {}
693
+ return raw if isinstance(raw, dict) else None
694
+ except Exception:
695
+ return None
696
+
697
+
698
+ def _ai_governance_check(op: str) -> Dict[str, Any]:
699
+ """Advisory governance status for the next run of `op` (best-effort)."""
700
+ try:
701
+ return _resolve_ai_governance.check(
702
+ project_root=_ai_ledger_root(), session_id=_AI_LEDGER_SESSION_ID, op=op,
703
+ preset=_ai_governance_preset(), overrides=_ai_governance_overrides(),
704
+ )
705
+ except Exception:
706
+ return {"applies": False}
707
+
708
+
678
709
  def _destructive_preference_provider(key: str) -> Any:
679
710
  """Reader for C6 preferences out of the existing media-analysis prefs file."""
680
711
  try:
@@ -6459,6 +6490,11 @@ _MEDIA_ANALYSIS_DEFAULT_PREFS = {
6459
6490
  # vision_tokens_per_job, vision_tokens_per_day, wall_clock_seconds_per_call,
6460
6491
  # max_frame_dim_pixels). Pass an integer or "unlimited" to lift that cap.
6461
6492
  "analysis_caps_overrides": {},
6493
+ # Soft governance tier for the media-creating Resolve 21 AI ops (deblur /
6494
+ # speech): off | lenient | standard | strict. Advisory only — surfaced in the
6495
+ # confirm preview + panel; never hard-blocks (the ops are confirm-gated).
6496
+ "resolve_ai_governance_preset": "standard",
6497
+ "resolve_ai_governance_overrides": {},
6462
6498
  }
6463
6499
 
6464
6500
 
@@ -12458,6 +12494,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12458
12494
  "voice_model": settings.get("VoiceModel"),
12459
12495
  "add_to_timeline": bool(settings.get("AddToTimeline")),
12460
12496
  "timecode": timecode,
12497
+ "governance": _ai_governance_check("generate_speech"),
12461
12498
  },
12462
12499
  )
12463
12500
  blocked = _consume_confirm_token(action="project_settings.generate_speech", params=p)
@@ -13504,6 +13541,7 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
13504
13541
  "warning": "Renders NEW deblurred media files for clips in the folder; source media is not modified.",
13505
13542
  "folder": f.GetName(),
13506
13543
  "deblur_option": deblur,
13544
+ "governance": _ai_governance_check("remove_motion_blur"),
13507
13545
  },
13508
13546
  )
13509
13547
  blocked = _consume_confirm_token(action="folder.remove_motion_blur", params=p)
@@ -13814,6 +13852,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13814
13852
  "warning": "Renders a NEW deblurred media file; the source clip is not modified.",
13815
13853
  "clip": clip.GetName(),
13816
13854
  "deblur_option": deblur,
13855
+ "governance": _ai_governance_check("remove_motion_blur"),
13817
13856
  },
13818
13857
  )
13819
13858
  blocked = _consume_confirm_token(action="media_pool_item.remove_motion_blur", params=p)
@@ -13979,6 +14018,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
13979
14018
  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.
13980
14019
  get_usage(scope?, scope_key?, clip_id?, job_id?) -> {scope, usage} — raw usage rollup for one scope (clip | job | day).
13981
14020
  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).
14021
+ get_ai_governance() -> {tier, thresholds, usage, tiers_available} — soft governance tier (off|lenient|standard|strict) for the media-creating AI ops vs this session's render usage. Advisory; surfaced in the confirm preview + panel, never blocks.
14022
+ set_ai_governance(preset, overrides?) -> {success, tier, overrides} — set the governance tier. Overrides keys: deblur_runs, speech_runs, render_bytes, render_wall_clock_ms (int or "unlimited").
13982
14023
  resolve_output_root(analysis_root?, source_paths?) -> {project_root}
13983
14024
  plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts}
13984
14025
  analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest}
@@ -14152,6 +14193,31 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
14152
14193
  }
14153
14194
  except Exception as exc:
14154
14195
  return _err(f"{type(exc).__name__}: {exc}")
14196
+ if action == "get_ai_governance":
14197
+ # Soft-tier governance for the media-creating AI ops (deblur / speech).
14198
+ # Advisory only — reports this session's render usage vs the active tier.
14199
+ try:
14200
+ project_root = _ai_ledger_root()
14201
+ st = _resolve_ai_governance.status(
14202
+ project_root=project_root, session_id=_AI_LEDGER_SESSION_ID,
14203
+ preset=_ai_governance_preset(), overrides=_ai_governance_overrides(),
14204
+ )
14205
+ return {"success": True, "session_id": _AI_LEDGER_SESSION_ID, **st}
14206
+ except Exception as exc:
14207
+ return _err(f"{type(exc).__name__}: {exc}")
14208
+ if action == "set_ai_governance":
14209
+ preset = (p.get("preset") or "").strip().lower()
14210
+ if preset not in _resolve_ai_governance.VALID_TIERS:
14211
+ return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
14212
+ prefs = _read_media_analysis_preferences()
14213
+ prefs["resolve_ai_governance_preset"] = preset
14214
+ if isinstance(p.get("overrides"), dict):
14215
+ prefs["resolve_ai_governance_overrides"] = p["overrides"]
14216
+ path = _media_analysis_preferences_path()
14217
+ os.makedirs(os.path.dirname(path), exist_ok=True)
14218
+ with open(path, "w", encoding="utf-8") as fh:
14219
+ json.dump(prefs, fh, indent=2)
14220
+ return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
14155
14221
  if action == "set_caps_preset":
14156
14222
  preset = (p.get("preset") or "").strip().lower()
14157
14223
  from src.utils import analysis_caps as _ac
@@ -0,0 +1,214 @@
1
+ """Soft governance tiers for the Resolve 21 media-creating AI ops.
2
+
3
+ The two media-creating ops (`remove_motion_blur`, `generate_speech`) render new
4
+ files on Resolve's GPU/AI engine. They are already confirm-token gated, so
5
+ governance here is intentionally **advisory**: it reads the AI-ops ledger for the
6
+ current session and reports how the *next* run sits against a named tier's soft
7
+ thresholds. The result is surfaced in the confirm preview and the control panel —
8
+ it never hard-blocks (the analysis-caps layer meters Claude tokens; this is a
9
+ different, local concern).
10
+
11
+ Tiers (per session):
12
+
13
+ | dimension | off | lenient | standard | strict |
14
+ |---------------------------|-----:|--------:|---------:|-------:|
15
+ | deblur_runs | None | 50 | 15 | 5 |
16
+ | speech_runs | None | 50 | 15 | 5 |
17
+ | render_bytes (new media) | None | 50 GB | 10 GB | 2 GB |
18
+ | render_wall_clock_ms | None | 1 h | 20 min | 5 min |
19
+
20
+ `standard` is the default. Only the render ops are governed; analysis ops
21
+ (classify / IntelliSearch / slate / transcribe) are always within-tier.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from src.utils import resolve_ai_ledger
30
+
31
+ logger = logging.getLogger("resolve-mcp.resolve-ai-governance")
32
+
33
+ TIER_OFF = "off"
34
+ TIER_LENIENT = "lenient"
35
+ TIER_STANDARD = "standard"
36
+ TIER_STRICT = "strict"
37
+ DEFAULT_TIER = TIER_STANDARD
38
+
39
+ _GB = 1024 ** 3
40
+
41
+ # dimension -> value (None = uncapped)
42
+ TIERS: Dict[str, Dict[str, Optional[int]]] = {
43
+ TIER_OFF: {
44
+ "deblur_runs": None, "speech_runs": None,
45
+ "render_bytes": None, "render_wall_clock_ms": None,
46
+ },
47
+ TIER_LENIENT: {
48
+ "deblur_runs": 50, "speech_runs": 50,
49
+ "render_bytes": 50 * _GB, "render_wall_clock_ms": 60 * 60 * 1000,
50
+ },
51
+ TIER_STANDARD: {
52
+ "deblur_runs": 15, "speech_runs": 15,
53
+ "render_bytes": 10 * _GB, "render_wall_clock_ms": 20 * 60 * 1000,
54
+ },
55
+ TIER_STRICT: {
56
+ "deblur_runs": 5, "speech_runs": 5,
57
+ "render_bytes": 2 * _GB, "render_wall_clock_ms": 5 * 60 * 1000,
58
+ },
59
+ }
60
+
61
+ VALID_TIERS = frozenset(TIERS)
62
+
63
+ _OVERRIDE_KEYS = frozenset({"deblur_runs", "speech_runs", "render_bytes", "render_wall_clock_ms"})
64
+
65
+ # Which ledger op feeds which run-count dimension.
66
+ _OP_RUN_DIM = {
67
+ "remove_motion_blur": "deblur_runs",
68
+ "generate_speech": "speech_runs",
69
+ }
70
+
71
+ GOVERNED_OPS = frozenset(_OP_RUN_DIM)
72
+
73
+
74
+ def list_tiers() -> Dict[str, Dict[str, Optional[int]]]:
75
+ return {name: dict(vals) for name, vals in TIERS.items()}
76
+
77
+
78
+ def resolve_tier(preset: Optional[str] = None, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
79
+ """Return the effective thresholds dict for `preset` with `overrides` applied.
80
+
81
+ An override value of None / "unlimited" means uncapped on that dimension.
82
+ """
83
+ name = (preset or DEFAULT_TIER).strip().lower()
84
+ if name not in TIERS:
85
+ name = DEFAULT_TIER
86
+ effective = dict(TIERS[name])
87
+ if isinstance(overrides, dict):
88
+ for key, val in overrides.items():
89
+ if key not in _OVERRIDE_KEYS:
90
+ continue
91
+ if val is None or (isinstance(val, str) and val.strip().lower() == "unlimited"):
92
+ effective[key] = None
93
+ else:
94
+ try:
95
+ effective[key] = int(val)
96
+ except (TypeError, ValueError):
97
+ continue
98
+ return {"preset": name, "thresholds": effective}
99
+
100
+
101
+ def _render_usage(project_root: str, session_id: Optional[str]) -> Dict[str, int]:
102
+ """Aggregate this session's render-op usage from the ledger."""
103
+ summary = resolve_ai_ledger.get_summary(project_root=project_root, session_id=session_id)
104
+ by_op = summary.get("by_op", {})
105
+ deblur = by_op.get("remove_motion_blur", {})
106
+ speech = by_op.get("generate_speech", {})
107
+ render_bytes = (deblur.get("bytes_created", 0) or 0) + (speech.get("bytes_created", 0) or 0)
108
+ render_ms = (deblur.get("wall_clock_ms", 0) or 0) + (speech.get("wall_clock_ms", 0) or 0)
109
+ return {
110
+ "deblur_runs": deblur.get("runs", 0) or 0,
111
+ "speech_runs": speech.get("runs", 0) or 0,
112
+ "render_bytes": render_bytes,
113
+ "render_wall_clock_ms": render_ms,
114
+ }
115
+
116
+
117
+ def check(
118
+ *,
119
+ project_root: Optional[str],
120
+ session_id: Optional[str],
121
+ op: str,
122
+ preset: Optional[str] = None,
123
+ overrides: Optional[Dict[str, Any]] = None,
124
+ ) -> Dict[str, Any]:
125
+ """Advisory governance status for the NEXT run of `op`.
126
+
127
+ Returns {applies, exceeded, near, tier, thresholds, usage, projected, warnings}.
128
+ `applies` is False for non-render ops (no governance) or missing project_root.
129
+ `exceeded` is True if the projected run-count is over the tier's run cap, or a
130
+ cumulative dimension (bytes/time) is already over. `near` is True at >=80%.
131
+ Never blocks — purely informational.
132
+ """
133
+ resolved = resolve_tier(preset, overrides)
134
+ base = {
135
+ "applies": False,
136
+ "exceeded": False,
137
+ "near": False,
138
+ "tier": resolved["preset"],
139
+ "thresholds": resolved["thresholds"],
140
+ "usage": {},
141
+ "projected": {},
142
+ "warnings": [],
143
+ }
144
+ if op not in GOVERNED_OPS or not project_root:
145
+ return base
146
+ thresholds = resolved["thresholds"]
147
+ usage = _render_usage(project_root, session_id)
148
+ run_dim = _OP_RUN_DIM[op]
149
+ projected = dict(usage)
150
+ projected[run_dim] = usage[run_dim] + 1 # this run adds one
151
+
152
+ warnings: List[str] = []
153
+ exceeded = False
154
+ near = False
155
+
156
+ def _assess(dim: str, value: int, label: str, fmt) -> None:
157
+ nonlocal exceeded, near
158
+ cap = thresholds.get(dim)
159
+ if cap is None:
160
+ return
161
+ if value > cap:
162
+ exceeded = True
163
+ warnings.append(f"{label}: {fmt(value)} exceeds the {resolved['preset']} limit of {fmt(cap)}.")
164
+ elif value >= cap * 0.8:
165
+ near = True
166
+ warnings.append(f"{label}: {fmt(value)} is approaching the {resolved['preset']} limit of {fmt(cap)}.")
167
+
168
+ def _fmt_bytes(n: int) -> str:
169
+ if not n:
170
+ return "0 B"
171
+ units = ["B", "KB", "MB", "GB", "TB"]
172
+ v = float(n)
173
+ i = 0
174
+ while v >= 1024 and i < len(units) - 1:
175
+ v /= 1024
176
+ i += 1
177
+ return f"{v:.1f} {units[i]}"
178
+
179
+ def _fmt_min(ms: int) -> str:
180
+ return f"{ms / 60000:.1f} min"
181
+
182
+ _assess(run_dim, projected[run_dim], "Runs this session", str)
183
+ _assess("render_bytes", usage["render_bytes"], "Media created this session", _fmt_bytes)
184
+ _assess("render_wall_clock_ms", usage["render_wall_clock_ms"], "Render time this session", _fmt_min)
185
+
186
+ base.update({
187
+ "applies": True,
188
+ "exceeded": exceeded,
189
+ "near": near,
190
+ "usage": usage,
191
+ "projected": projected,
192
+ "warnings": warnings,
193
+ })
194
+ return base
195
+
196
+
197
+ def status(
198
+ *,
199
+ project_root: Optional[str],
200
+ session_id: Optional[str],
201
+ preset: Optional[str] = None,
202
+ overrides: Optional[Dict[str, Any]] = None,
203
+ ) -> Dict[str, Any]:
204
+ """Current session render usage vs the effective tier (for the panel/MCP)."""
205
+ resolved = resolve_tier(preset, overrides)
206
+ usage = _render_usage(project_root, session_id) if project_root else {
207
+ "deblur_runs": 0, "speech_runs": 0, "render_bytes": 0, "render_wall_clock_ms": 0,
208
+ }
209
+ return {
210
+ "tier": resolved["preset"],
211
+ "thresholds": resolved["thresholds"],
212
+ "usage": usage,
213
+ "tiers_available": list_tiers(),
214
+ }