davinci-resolve-mcp 2.31.0 → 2.32.1
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 +53 -0
- package/README.md +1 -1
- package/docs/SKILL.md +8 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +118 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +78 -2
- package/src/utils/resolve_ai_governance.py +214 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
Fixes `fusion_comp(action="add_keyframe")` so it actually **animates** the input.
|
|
8
|
+
|
|
9
|
+
Previously the handler did `inp[time] = value` on the input directly. On an input
|
|
10
|
+
with no animation spline, that only assigns a **static** value (last write wins) —
|
|
11
|
+
no keyframe is created. Symptoms: `get_keyframes` returned `[]`, `get_input` at
|
|
12
|
+
different times returned the same value, and the clip never animated.
|
|
13
|
+
|
|
14
|
+
The handler now attaches a `BezierSpline` modifier the first time an input is
|
|
15
|
+
animated, then sets the keyframe. A new optional `modifier` param lets callers
|
|
16
|
+
pass e.g. `"Path"` for Point inputs such as `Center`. Behavior is unchanged for
|
|
17
|
+
inputs that are already animated or otherwise connected.
|
|
18
|
+
|
|
19
|
+
- **Fixed** `add_keyframe` now creates real, editable keyframes (live-validated on
|
|
20
|
+
DaVinci Resolve Studio 21.0.0).
|
|
21
|
+
- **Added** optional `modifier` param to `add_keyframe`.
|
|
22
|
+
- Thanks to @sandypoli-boop for the diagnosis and fix ([#56](https://github.com/samuelgursky/davinci-resolve-mcp/pull/56)).
|
|
23
|
+
|
|
24
|
+
## What's New in v2.32.0
|
|
25
|
+
|
|
26
|
+
Adds **governance tiers** for the media-creating Resolve 21 AI ops (Phase 3, the
|
|
27
|
+
final phase of the AI-ops build: ledger → console → governance).
|
|
28
|
+
|
|
29
|
+
The two ops that render new files — `remove_motion_blur` and `generate_speech` —
|
|
30
|
+
now have **soft, per-session limits** chosen by a named tier:
|
|
31
|
+
|
|
32
|
+
| dimension | off | lenient | standard | strict |
|
|
33
|
+
|---|---|---|---|---|
|
|
34
|
+
| deblur runs | ∞ | 50 | 15 | 5 |
|
|
35
|
+
| speech runs | ∞ | 50 | 15 | 5 |
|
|
36
|
+
| media created | ∞ | 50 GB | 10 GB | 2 GB |
|
|
37
|
+
| render time | ∞ | 1 h | 20 min | 5 min |
|
|
38
|
+
|
|
39
|
+
Governance is **advisory** — it never hard-blocks (the ops are already
|
|
40
|
+
confirm-token gated). When you're near or over the active tier, the confirmation
|
|
41
|
+
dialog surfaces a warning before you proceed; the limits are computed from the
|
|
42
|
+
v2.30.0 AI-ops ledger for the current session.
|
|
43
|
+
|
|
44
|
+
- **New module** `src/utils/resolve_ai_governance.py` — tiers + `check()` (status
|
|
45
|
+
for the next run) + `status()` (session usage vs tier). Default tier: `standard`.
|
|
46
|
+
- **New MCP actions** `media_analysis(action="get_ai_governance")` and
|
|
47
|
+
`media_analysis(action="set_ai_governance", preset, overrides?)`. Override keys:
|
|
48
|
+
`deblur_runs`, `speech_runs`, `render_bytes`, `render_wall_clock_ms` (int or `"unlimited"`).
|
|
49
|
+
- **Confirm preview** for the two creators now carries a `governance` block
|
|
50
|
+
(current usage, projected, warnings, exceeded/near flags).
|
|
51
|
+
- **Control panel** — the AI Console gains a **Governance** section: a tier picker
|
|
52
|
+
(off/lenient/standard/strict, with each tier's thresholds) plus live
|
|
53
|
+
consumption gauges, and the confirm dialog shows the warning inline.
|
|
54
|
+
|
|
55
|
+
This completes the staged AI-ops build. Validated live against Resolve Studio
|
|
56
|
+
21.0.0.47 (tier switching, persistence, gauges, confirm-dialog warnings).
|
|
57
|
+
|
|
5
58
|
## What's New in v2.31.0
|
|
6
59
|
|
|
7
60
|
Adds the **AI Console** to the control panel — an interactive surface for the
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
38
|
+
VERSION = "2.32.1"
|
|
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
|
@@ -4178,6 +4178,13 @@ HTML = r"""<!doctype html>
|
|
|
4178
4178
|
<div class="caps-section-hint">Checking which AI methods this Resolve build exposes…</div>
|
|
4179
4179
|
</div>
|
|
4180
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
|
+
|
|
4181
4188
|
<div class="caps-section" style="margin-top:12px;">
|
|
4182
4189
|
<div class="caps-section-head"><div class="caps-section-title">Target</div>
|
|
4183
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>
|
|
@@ -7377,10 +7384,14 @@ HTML = r"""<!doctype html>
|
|
|
7377
7384
|
// Confirm-token two-step for the media-creating ops.
|
|
7378
7385
|
if (res && res.status === 'confirmation_required') {
|
|
7379
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
|
+
: '';
|
|
7380
7391
|
const proceed = await brandedConfirm({
|
|
7381
|
-
kicker: 'Creates new media',
|
|
7392
|
+
kicker: gov.exceeded ? 'Over governance limit' : 'Creates new media',
|
|
7382
7393
|
title: AI_OP_LABELS[op] || op,
|
|
7383
|
-
body: preview.warning || 'This operation creates new media. Proceed?',
|
|
7394
|
+
body: (preview.warning || 'This operation creates new media. Proceed?') + govWarn,
|
|
7384
7395
|
detail: JSON.stringify(preview, null, 2),
|
|
7385
7396
|
confirmLabel: 'Run it',
|
|
7386
7397
|
tone: 'danger',
|
|
@@ -7392,20 +7403,101 @@ HTML = r"""<!doctype html>
|
|
|
7392
7403
|
}).catch(err => ({ success: false, error: String(err && err.message || err) }));
|
|
7393
7404
|
}
|
|
7394
7405
|
aiShowResult(op, res, !(res && res.success));
|
|
7395
|
-
// Refresh the ledger
|
|
7406
|
+
// Refresh the ledger + governance widgets so totals stay current.
|
|
7396
7407
|
refreshResolveAiOps().catch(() => {});
|
|
7408
|
+
refreshGovernance().catch(() => {});
|
|
7397
7409
|
} finally {
|
|
7398
7410
|
buttons.forEach(b => { b.disabled = false; });
|
|
7399
7411
|
}
|
|
7400
7412
|
}
|
|
7401
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
|
+
|
|
7402
7486
|
function initAiConsole() {
|
|
7403
|
-
if (_aiConsoleInit) { renderAiConsole(); return; }
|
|
7487
|
+
if (_aiConsoleInit) { renderAiConsole(); refreshGovernance().catch(() => {}); return; }
|
|
7404
7488
|
_aiConsoleInit = true;
|
|
7405
7489
|
renderAiConsole();
|
|
7406
7490
|
document.querySelectorAll('#panel-aiconsole .ai-op-btn').forEach(btn => {
|
|
7407
7491
|
btn.addEventListener('click', () => runAiOp(btn.dataset.aiOp));
|
|
7408
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(() => {});
|
|
7409
7501
|
}
|
|
7410
7502
|
|
|
7411
7503
|
// ─── Caps inspector + refusals + reset ──────────────────────────
|
|
@@ -14048,6 +14140,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14048
14140
|
except Exception as exc:
|
|
14049
14141
|
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
14050
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
|
|
14051
14153
|
if path.startswith("/api/timeline_thumbnail/"):
|
|
14052
14154
|
rel = unquote(path[len("/api/timeline_thumbnail/"):])
|
|
14053
14155
|
# Path is <slug>/<vNN.png>; constrain it to live under _soul/timeline_versions
|
|
@@ -14317,6 +14419,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14317
14419
|
except Exception as exc:
|
|
14318
14420
|
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
14319
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
|
|
14320
14434
|
if path == "/api/caps/reset_day":
|
|
14321
14435
|
if not _request_is_loopback(self):
|
|
14322
14436
|
self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.32.1"
|
|
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.
|
|
14
|
+
VERSION = "2.32.1"
|
|
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
|
|
@@ -18597,7 +18663,17 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18597
18663
|
inp = tool[p["input_name"]]
|
|
18598
18664
|
if not inp:
|
|
18599
18665
|
return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'")
|
|
18600
|
-
|
|
18666
|
+
# Attach a BezierSpline modifier the first time this input is animated.
|
|
18667
|
+
# Without a spline, `inp[time] = value` only sets a STATIC value (the last
|
|
18668
|
+
# write wins) and never creates a keyframe. See the Fusion Scripting Guide.
|
|
18669
|
+
# Optional `modifier` lets callers pass e.g. "Path" for Point inputs.
|
|
18670
|
+
try:
|
|
18671
|
+
_already_animated = inp.GetConnectedOutput() is not None
|
|
18672
|
+
except Exception:
|
|
18673
|
+
_already_animated = False
|
|
18674
|
+
if not _already_animated:
|
|
18675
|
+
tool.AddModifier(p["input_name"], p.get("modifier", "BezierSpline"))
|
|
18676
|
+
tool[p["input_name"]][p["time"]] = p["value"]
|
|
18601
18677
|
return _ok()
|
|
18602
18678
|
finally:
|
|
18603
18679
|
comp.Unlock()
|
|
@@ -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
|
+
}
|