davinci-resolve-mcp 2.29.0 → 2.31.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 +60 -0
- package/README.md +1 -1
- package/docs/SKILL.md +9 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +450 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +128 -21
- package/src/utils/resolve_ai_ledger.py +242 -0
- package/src/utils/timeline_brain_db.py +43 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
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.31.0
|
|
6
|
+
|
|
7
|
+
Adds the **AI Console** to the control panel — an interactive surface for the
|
|
8
|
+
Resolve 21 local AI operations (Phase 2 of the staged AI-ops build: ledger →
|
|
9
|
+
console → governance).
|
|
10
|
+
|
|
11
|
+
A new **AI Console** tab runs the 21.0 ops against the current Media Pool folder
|
|
12
|
+
or a specific clip:
|
|
13
|
+
|
|
14
|
+
- **Capability matrix** — shows which AI methods this Resolve build exposes (green
|
|
15
|
+
= available, grey = absent on older builds) and which Extra each gated method
|
|
16
|
+
needs to actually run.
|
|
17
|
+
- **Analysis** — Classify audio / Clear classification, IntelliSearch (with
|
|
18
|
+
identify-faces and Better-mode toggles), Analyze for slate (16-color marker
|
|
19
|
+
picker), Transcribe (with speaker-detection toggle), Clear transcription.
|
|
20
|
+
- **Motion deblur** and **Speech generator** — full options forms; because both
|
|
21
|
+
create new media files they route through a confirmation modal (the same
|
|
22
|
+
confirm-token gate the MCP tools use) before running.
|
|
23
|
+
- **Session** — Disable background tasks for the current Resolve session.
|
|
24
|
+
- A live result readout, and the *Resolve 21 AI ops* ledger refreshes after each
|
|
25
|
+
run so file/byte totals stay current.
|
|
26
|
+
|
|
27
|
+
Backend: a loopback-only `POST /api/resolve_ai/run` endpoint dispatches each op
|
|
28
|
+
to the consolidated `folder` / `media_pool_item` / `project_settings` /
|
|
29
|
+
`resolve_control` tools, relaying the confirm-token two-step. No new MCP tools or
|
|
30
|
+
Resolve API surface — the console reuses the existing v2.29.0 actions. Validated
|
|
31
|
+
live end-to-end against Resolve Studio 21.0.0.47.
|
|
32
|
+
|
|
33
|
+
## What's New in v2.30.0
|
|
34
|
+
|
|
35
|
+
Adds the **Resolve 21 AI-ops ledger** — usage/time/file accounting for the
|
|
36
|
+
Resolve-local AI operations added in v2.29.0 (audio classification, IntelliSearch,
|
|
37
|
+
slate, motion-deblur, speech generation). These run on Resolve's own GPU/AI engine
|
|
38
|
+
and do **not** consume the Claude-side analysis token budget, so they get their
|
|
39
|
+
own ledger instead of being metered by the analysis-caps layer.
|
|
40
|
+
|
|
41
|
+
**What's tracked.** Every run of the five 21.0 ops records: op name, op class
|
|
42
|
+
(`analysis` vs `render`), clip id, success/failure, wall-clock time, and — for the
|
|
43
|
+
two media-creating ops (`remove_motion_blur`, `generate_speech`) — the output
|
|
44
|
+
file path and byte size. The reliable signal is invocation counts + the
|
|
45
|
+
file/disk accounting for the creators; durations for the bool-returning analysis
|
|
46
|
+
ops reflect the script-call time (some queue work inside Resolve).
|
|
47
|
+
|
|
48
|
+
- **New table** `resolve_ai_op_usage` (timeline_brain DB schema v7).
|
|
49
|
+
- **New module** `src/utils/resolve_ai_ledger.py` — `timed()` context manager +
|
|
50
|
+
`record_op` / `get_usage` / `get_summary`. All writes are best-effort and never
|
|
51
|
+
block or mask the underlying Resolve op.
|
|
52
|
+
- **Instrumentation** wraps the consolidated `folder` / `media_pool_item`
|
|
53
|
+
`perform_audio_classification` / `clear_audio_classification` /
|
|
54
|
+
`analyze_for_intellisearch` / `analyze_for_slate` / `remove_motion_blur`
|
|
55
|
+
handlers and `project_settings.generate_speech`.
|
|
56
|
+
- **New MCP action** `media_analysis(action="get_resolve_ai_usage", session_only?, op?, limit?)`
|
|
57
|
+
returns the per-op summary + recent runs.
|
|
58
|
+
- **Control panel**: a read-only "Resolve 21 AI ops" card (`/api/resolve_ai_usage`)
|
|
59
|
+
shows runs, success/fail, total time, and files/bytes created.
|
|
60
|
+
|
|
61
|
+
Phase 1 of a staged build (ledger → interactive console → governance). Granular
|
|
62
|
+
`--full` server instrumentation is deferred — the ledger covers the consolidated
|
|
63
|
+
server, which is the default surface. Validated live against Resolve Studio 21.0.0.47.
|
|
64
|
+
|
|
5
65
|
## What's New in v2.29.0
|
|
6
66
|
|
|
7
67
|
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
|
-
[](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
|
@@ -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.
|
|
38
|
+
VERSION = "2.31.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
|
@@ -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,94 @@ 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">Target</div>
|
|
4183
|
+
<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>
|
|
4184
|
+
<div class="settings-grid">
|
|
4185
|
+
<label class="checkbox-row"><input type="radio" name="aiTarget" value="folder" checked><span>Current folder</span></label>
|
|
4186
|
+
<label class="checkbox-row"><input type="radio" name="aiTarget" value="clip"><span>Specific clip</span></label>
|
|
4187
|
+
<label>Clip id <input id="aiClipId" type="text" placeholder="(clip UniqueId)"></label>
|
|
4188
|
+
</div>
|
|
4189
|
+
</div>
|
|
4190
|
+
|
|
4191
|
+
<div class="caps-section" style="margin-top:12px;">
|
|
4192
|
+
<div class="caps-section-head"><div class="caps-section-title">Analysis</div>
|
|
4193
|
+
<div class="caps-section-hint">Safe, reversible. IntelliSearch and Slate require their AI Extras (Extras Download Manager).</div></div>
|
|
4194
|
+
<div class="settings-grid">
|
|
4195
|
+
<div class="ai-op-row"><button class="ai-op-btn" data-ai-op="perform_audio_classification">Classify audio</button>
|
|
4196
|
+
<button class="ai-op-btn ghost" data-ai-op="clear_audio_classification">Clear classification</button></div>
|
|
4197
|
+
<div class="ai-op-row">
|
|
4198
|
+
<button class="ai-op-btn" data-ai-op="analyze_for_intellisearch">IntelliSearch</button>
|
|
4199
|
+
<label class="checkbox-row"><input id="aiIdentifyFaces" type="checkbox"><span>Identify faces</span></label>
|
|
4200
|
+
<label class="checkbox-row"><input id="aiBetterMode" type="checkbox"><span>Better mode</span></label>
|
|
4201
|
+
</div>
|
|
4202
|
+
<div class="ai-op-row">
|
|
4203
|
+
<button class="ai-op-btn" data-ai-op="analyze_for_slate">Analyze for slate</button>
|
|
4204
|
+
<label>Marker <select id="aiSlateColor"></select></label>
|
|
4205
|
+
</div>
|
|
4206
|
+
<div class="ai-op-row">
|
|
4207
|
+
<button class="ai-op-btn" data-ai-op="transcribe_audio">Transcribe</button>
|
|
4208
|
+
<label class="checkbox-row"><input id="aiSpeakerDetection" type="checkbox"><span>Speaker detection</span></label>
|
|
4209
|
+
<button class="ai-op-btn ghost" data-ai-op="clear_transcription">Clear transcription</button>
|
|
4210
|
+
</div>
|
|
4211
|
+
</div>
|
|
4212
|
+
</div>
|
|
4213
|
+
|
|
4214
|
+
<div class="caps-section" style="margin-top:12px;">
|
|
4215
|
+
<div class="caps-section-head"><div class="caps-section-title">Motion deblur</div>
|
|
4216
|
+
<div class="caps-section-hint">Renders new deblurred media. Creates files; asks for confirmation. Leave fields blank for Resolve defaults.</div></div>
|
|
4217
|
+
<div class="settings-grid">
|
|
4218
|
+
<label>Format <input id="aiDeblurFormat" type="text" placeholder="mov"></label>
|
|
4219
|
+
<label>Codec <input id="aiDeblurCodec" type="text" placeholder="ProRes422"></label>
|
|
4220
|
+
<label class="checkbox-row"><input id="aiDeblurExtreme" type="checkbox"><span>Extreme mode</span></label>
|
|
4221
|
+
<label class="checkbox-row"><input id="aiDeblurMarkInOut" type="checkbox"><span>Use mark in/out</span></label>
|
|
4222
|
+
<label class="checkbox-row"><input id="aiDeblurSourceRes" type="checkbox"><span>Render at source res</span></label>
|
|
4223
|
+
<div class="ai-op-row"><button class="ai-op-btn danger" data-ai-op="remove_motion_blur">Remove motion blur</button></div>
|
|
4224
|
+
</div>
|
|
4225
|
+
</div>
|
|
4226
|
+
|
|
4227
|
+
<div class="caps-section" style="margin-top:12px;">
|
|
4228
|
+
<div class="caps-section-head"><div class="caps-section-title">Speech generator</div>
|
|
4229
|
+
<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>
|
|
4230
|
+
<div class="settings-grid">
|
|
4231
|
+
<label style="grid-column:1/-1;">Text <textarea id="aiSpeechText" rows="2" placeholder="Text to synthesize"></textarea></label>
|
|
4232
|
+
<label>Voice model <input id="aiSpeechVoice" type="text" placeholder="Female 1"></label>
|
|
4233
|
+
<label>Speed <input id="aiSpeechSpeed" type="number" placeholder="(default)"></label>
|
|
4234
|
+
<label>Pitch <input id="aiSpeechPitch" type="number" placeholder="(default)"></label>
|
|
4235
|
+
<label>Variation <input id="aiSpeechVariation" type="number" placeholder="(default)"></label>
|
|
4236
|
+
<label class="checkbox-row"><input id="aiSpeechAddTimeline" type="checkbox"><span>Add to timeline</span></label>
|
|
4237
|
+
<label>Timecode <input id="aiSpeechTimecode" type="text" placeholder="01:00:00:00"></label>
|
|
4238
|
+
<label>Audio track <input id="aiSpeechTrack" type="number" placeholder="(default)"></label>
|
|
4239
|
+
<div class="ai-op-row"><button class="ai-op-btn danger" data-ai-op="generate_speech">Generate speech</button></div>
|
|
4240
|
+
</div>
|
|
4241
|
+
</div>
|
|
4242
|
+
|
|
4243
|
+
<div class="caps-section" style="margin-top:12px;">
|
|
4244
|
+
<div class="caps-section-head"><div class="caps-section-title">Session</div>
|
|
4245
|
+
<div class="caps-section-hint">Quiet Resolve's background tasks for this session before heavy work. Resets on restart.</div></div>
|
|
4246
|
+
<div class="ai-op-row"><button class="ai-op-btn ghost" data-ai-op="disable_background_tasks">Disable background tasks</button></div>
|
|
4247
|
+
</div>
|
|
4248
|
+
|
|
4249
|
+
<div class="caps-section" style="margin-top:12px;">
|
|
4250
|
+
<div class="caps-section-head"><div class="caps-section-title">Result</div></div>
|
|
4251
|
+
<div id="aiConsoleResult" class="ai-console-result caps-section-hint">No op run yet.</div>
|
|
4252
|
+
</div>
|
|
4253
|
+
</section>
|
|
4254
|
+
</main>
|
|
4255
|
+
|
|
4122
4256
|
<main id="panel-diagnostics" class="panel control-grid">
|
|
4123
4257
|
<section class="span-12 subpage active" data-subpage-scope="diagnostics" data-subpage="resolve">
|
|
4124
4258
|
<h2>Resolve</h2>
|
|
@@ -4370,6 +4504,24 @@ HTML = r"""<!doctype html>
|
|
|
4370
4504
|
</div>
|
|
4371
4505
|
</div>
|
|
4372
4506
|
|
|
4507
|
+
<div class="caps-section">
|
|
4508
|
+
<div class="caps-section-head">
|
|
4509
|
+
<div class="caps-section-title">Resolve 21 AI ops</div>
|
|
4510
|
+
<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>
|
|
4511
|
+
</div>
|
|
4512
|
+
<div id="resolveAiOpsBlock" class="caps-usage-block">
|
|
4513
|
+
<div id="resolveAiOpsSummary" class="caps-section-hint">loading…</div>
|
|
4514
|
+
<table id="resolveAiOpsTable" class="resolve-ai-ops-table" style="display:none; width:100%; border-collapse:collapse; margin-top:8px; font-size:12px;">
|
|
4515
|
+
<thead><tr style="text-align:left; opacity:0.7;">
|
|
4516
|
+
<th style="padding:4px 6px;">Op</th><th style="padding:4px 6px;">Runs</th>
|
|
4517
|
+
<th style="padding:4px 6px;">OK</th><th style="padding:4px 6px;">Time</th>
|
|
4518
|
+
<th style="padding:4px 6px;">Files</th><th style="padding:4px 6px;">Created</th>
|
|
4519
|
+
</tr></thead>
|
|
4520
|
+
<tbody id="resolveAiOpsRows"></tbody>
|
|
4521
|
+
</table>
|
|
4522
|
+
</div>
|
|
4523
|
+
</div>
|
|
4524
|
+
|
|
4373
4525
|
<div class="caps-section">
|
|
4374
4526
|
<div class="caps-section-head">
|
|
4375
4527
|
<div class="caps-section-title">Safety</div>
|
|
@@ -4694,6 +4846,7 @@ HTML = r"""<!doctype html>
|
|
|
4694
4846
|
const PANEL_LABELS = {
|
|
4695
4847
|
overview: 'Overview',
|
|
4696
4848
|
analysis: 'Analysis',
|
|
4849
|
+
aiconsole: 'AI Console',
|
|
4697
4850
|
diagnostics: 'Setup',
|
|
4698
4851
|
projects: 'Projects',
|
|
4699
4852
|
docs: 'Docs',
|
|
@@ -4702,6 +4855,7 @@ HTML = r"""<!doctype html>
|
|
|
4702
4855
|
const PANEL_ICONS = {
|
|
4703
4856
|
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>',
|
|
4704
4857
|
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>',
|
|
4858
|
+
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>',
|
|
4705
4859
|
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>',
|
|
4706
4860
|
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>',
|
|
4707
4861
|
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>',
|
|
@@ -4864,6 +5018,9 @@ HTML = r"""<!doctype html>
|
|
|
4864
5018
|
syncPreferencesPanel();
|
|
4865
5019
|
refreshSetupDefaults().catch(alertError);
|
|
4866
5020
|
}
|
|
5021
|
+
if (next === 'aiconsole') {
|
|
5022
|
+
initAiConsole();
|
|
5023
|
+
}
|
|
4867
5024
|
if (next === 'docs' && subpage) {
|
|
4868
5025
|
loadDoc(subpage, { updateHash: false }).catch(alertError);
|
|
4869
5026
|
}
|
|
@@ -7040,6 +7197,217 @@ HTML = r"""<!doctype html>
|
|
|
7040
7197
|
</svg>`;
|
|
7041
7198
|
}
|
|
7042
7199
|
|
|
7200
|
+
// ─── Resolve 21 AI ops ledger (read-only) ───────────────────────
|
|
7201
|
+
function fmtMs(ms) {
|
|
7202
|
+
ms = ms || 0;
|
|
7203
|
+
if (ms < 1000) return ms + 'ms';
|
|
7204
|
+
const s = ms / 1000;
|
|
7205
|
+
return s < 60 ? s.toFixed(1) + 's' : (s / 60).toFixed(1) + 'm';
|
|
7206
|
+
}
|
|
7207
|
+
function fmtBytes(n) {
|
|
7208
|
+
n = n || 0;
|
|
7209
|
+
if (!n) return '—';
|
|
7210
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
7211
|
+
let i = 0; let v = n;
|
|
7212
|
+
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
7213
|
+
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
|
7214
|
+
}
|
|
7215
|
+
async function refreshResolveAiOps() {
|
|
7216
|
+
const summaryEl = $('resolveAiOpsSummary');
|
|
7217
|
+
const tableEl = $('resolveAiOpsTable');
|
|
7218
|
+
const rowsEl = $('resolveAiOpsRows');
|
|
7219
|
+
if (!summaryEl || !tableEl || !rowsEl) return;
|
|
7220
|
+
const data = await api('/api/resolve_ai_usage').catch(() => ({ success: false }));
|
|
7221
|
+
if (!data || !data.success) {
|
|
7222
|
+
summaryEl.textContent = 'ledger unavailable';
|
|
7223
|
+
tableEl.style.display = 'none';
|
|
7224
|
+
return;
|
|
7225
|
+
}
|
|
7226
|
+
const totals = (data.summary && data.summary.totals) || {};
|
|
7227
|
+
const byOp = (data.summary && data.summary.by_op) || {};
|
|
7228
|
+
const ops = Object.keys(byOp).sort();
|
|
7229
|
+
if (!ops.length) {
|
|
7230
|
+
summaryEl.textContent = 'No Resolve AI ops recorded yet for this project.';
|
|
7231
|
+
tableEl.style.display = 'none';
|
|
7232
|
+
return;
|
|
7233
|
+
}
|
|
7234
|
+
summaryEl.innerHTML = `<strong>${totals.runs || 0}</strong> runs · `
|
|
7235
|
+
+ `<strong>${totals.successes || 0}</strong> ok / ${totals.failures || 0} failed · `
|
|
7236
|
+
+ `${fmtMs(totals.wall_clock_ms)} total · `
|
|
7237
|
+
+ `<strong>${totals.files_created || 0}</strong> files (${fmtBytes(totals.bytes_created)}) created`;
|
|
7238
|
+
rowsEl.innerHTML = ops.map(op => {
|
|
7239
|
+
const b = byOp[op];
|
|
7240
|
+
const isRender = b.op_class === 'render';
|
|
7241
|
+
return `<tr style="border-top:1px solid rgba(255,255,255,0.06);">
|
|
7242
|
+
<td style="padding:4px 6px;">${escapeHtml(op)}${isRender ? ' <span style="opacity:0.6;">(media)</span>' : ''}</td>
|
|
7243
|
+
<td style="padding:4px 6px;">${b.runs}</td>
|
|
7244
|
+
<td style="padding:4px 6px;">${b.successes}</td>
|
|
7245
|
+
<td style="padding:4px 6px;">${fmtMs(b.wall_clock_ms)}</td>
|
|
7246
|
+
<td style="padding:4px 6px;">${b.files_created || '—'}</td>
|
|
7247
|
+
<td style="padding:4px 6px;">${b.bytes_created ? fmtBytes(b.bytes_created) : '—'}</td>
|
|
7248
|
+
</tr>`;
|
|
7249
|
+
}).join('');
|
|
7250
|
+
tableEl.style.display = '';
|
|
7251
|
+
}
|
|
7252
|
+
|
|
7253
|
+
// ─── Resolve 21 AI Console ──────────────────────────────────────
|
|
7254
|
+
const AI_MARKER_COLORS = ['Blue','Cyan','Green','Yellow','Red','Pink','Purple',
|
|
7255
|
+
'Fuchsia','Rose','Lavender','Sky','Mint','Lemon','Sand','Cocoa','Cream'];
|
|
7256
|
+
const AI_OP_LABELS = {
|
|
7257
|
+
perform_audio_classification: 'Classify audio',
|
|
7258
|
+
clear_audio_classification: 'Clear classification',
|
|
7259
|
+
analyze_for_intellisearch: 'IntelliSearch',
|
|
7260
|
+
analyze_for_slate: 'Analyze for slate',
|
|
7261
|
+
transcribe_audio: 'Transcribe',
|
|
7262
|
+
clear_transcription: 'Clear transcription',
|
|
7263
|
+
remove_motion_blur: 'Remove motion blur',
|
|
7264
|
+
generate_speech: 'Generate speech',
|
|
7265
|
+
disable_background_tasks: 'Disable background tasks',
|
|
7266
|
+
};
|
|
7267
|
+
// Which features gate which buttons (key in resolve.ai_features.features).
|
|
7268
|
+
const AI_OP_FEATURE = {
|
|
7269
|
+
perform_audio_classification: 'perform_audio_classification',
|
|
7270
|
+
clear_audio_classification: 'clear_audio_classification',
|
|
7271
|
+
analyze_for_intellisearch: 'analyze_for_intellisearch',
|
|
7272
|
+
analyze_for_slate: 'analyze_for_slate',
|
|
7273
|
+
remove_motion_blur: 'remove_motion_blur',
|
|
7274
|
+
generate_speech: 'generate_speech',
|
|
7275
|
+
disable_background_tasks: 'disable_background_tasks',
|
|
7276
|
+
};
|
|
7277
|
+
let _aiConsoleInit = false;
|
|
7278
|
+
|
|
7279
|
+
function renderAiConsole() {
|
|
7280
|
+
const feats = (state.boot?.resolve?.ai_features) || {};
|
|
7281
|
+
const features = feats.features || {};
|
|
7282
|
+
const requiresExtra = feats.requires_extra || {};
|
|
7283
|
+
const capsEl = $('aiConsoleCaps');
|
|
7284
|
+
if (capsEl) {
|
|
7285
|
+
if (state.boot?.resolve?.available !== true) {
|
|
7286
|
+
capsEl.innerHTML = '<div class="caps-section-hint">Resolve is not connected. Open a project in DaVinci Resolve, then reload.</div>';
|
|
7287
|
+
} else {
|
|
7288
|
+
const items = Object.keys(AI_OP_LABELS)
|
|
7289
|
+
.filter(op => op in AI_OP_FEATURE)
|
|
7290
|
+
.map(op => {
|
|
7291
|
+
const key = AI_OP_FEATURE[op];
|
|
7292
|
+
const on = !!features[key];
|
|
7293
|
+
const extra = requiresExtra[key];
|
|
7294
|
+
return `<div class="ai-caps-item"><span class="ai-caps-dot ${on ? 'on' : 'off'}"></span>`
|
|
7295
|
+
+ `<span>${escapeHtml(AI_OP_LABELS[op])}</span>`
|
|
7296
|
+
+ (extra ? `<span class="ai-caps-extra">· needs ${escapeHtml(extra)}</span>` : '')
|
|
7297
|
+
+ `</div>`;
|
|
7298
|
+
}).join('');
|
|
7299
|
+
capsEl.innerHTML = `<div class="caps-section-head"><div class="caps-section-title">Available on this Resolve build</div>`
|
|
7300
|
+
+ `<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>`
|
|
7301
|
+
+ `<div class="ai-caps-grid">${items}</div>`;
|
|
7302
|
+
}
|
|
7303
|
+
}
|
|
7304
|
+
// Slate color dropdown (once).
|
|
7305
|
+
const sel = $('aiSlateColor');
|
|
7306
|
+
if (sel && !sel.options.length) {
|
|
7307
|
+
sel.innerHTML = AI_MARKER_COLORS.map(c => `<option value="${c}">${c}</option>`).join('');
|
|
7308
|
+
}
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7311
|
+
function aiTarget() {
|
|
7312
|
+
const checked = document.querySelector('input[name="aiTarget"]:checked');
|
|
7313
|
+
return checked ? checked.value : 'folder';
|
|
7314
|
+
}
|
|
7315
|
+
|
|
7316
|
+
function aiBuildParams(op) {
|
|
7317
|
+
const params = {};
|
|
7318
|
+
if (op === 'analyze_for_intellisearch') {
|
|
7319
|
+
params.identify_faces = !!$('aiIdentifyFaces')?.checked;
|
|
7320
|
+
params.is_better_mode = !!$('aiBetterMode')?.checked;
|
|
7321
|
+
} else if (op === 'analyze_for_slate') {
|
|
7322
|
+
params.marker_color = $('aiSlateColor')?.value || 'Blue';
|
|
7323
|
+
} else if (op === 'transcribe_audio') {
|
|
7324
|
+
if ($('aiSpeakerDetection')?.checked) params.use_speaker_detection = true;
|
|
7325
|
+
} else if (op === 'remove_motion_blur') {
|
|
7326
|
+
const d = {};
|
|
7327
|
+
const fmt = ($('aiDeblurFormat')?.value || '').trim(); if (fmt) d.Format = fmt;
|
|
7328
|
+
const codec = ($('aiDeblurCodec')?.value || '').trim(); if (codec) d.Codec = codec;
|
|
7329
|
+
if ($('aiDeblurExtreme')?.checked) d.UseExtremeMode = true;
|
|
7330
|
+
if ($('aiDeblurMarkInOut')?.checked) d.UseMarkInMarkOut = true;
|
|
7331
|
+
if ($('aiDeblurSourceRes')?.checked) d.RenderAtSourceRes = true;
|
|
7332
|
+
params.deblur_option = d;
|
|
7333
|
+
} else if (op === 'generate_speech') {
|
|
7334
|
+
const text = ($('aiSpeechText')?.value || '').trim();
|
|
7335
|
+
const settings = { TextInput: text };
|
|
7336
|
+
const voice = ($('aiSpeechVoice')?.value || '').trim(); if (voice) settings.VoiceModel = voice;
|
|
7337
|
+
const num = (id) => { const v = ($(id)?.value || '').trim(); return v === '' ? null : Number(v); };
|
|
7338
|
+
const speed = num('aiSpeechSpeed'); if (speed != null) settings.Speed = speed;
|
|
7339
|
+
const pitch = num('aiSpeechPitch'); if (pitch != null) settings.Pitch = pitch;
|
|
7340
|
+
const variation = num('aiSpeechVariation'); if (variation != null) settings.Variation = variation;
|
|
7341
|
+
if ($('aiSpeechAddTimeline')?.checked) {
|
|
7342
|
+
settings.AddToTimeline = true;
|
|
7343
|
+
const track = num('aiSpeechTrack'); if (track != null) settings.AudioTrack = track;
|
|
7344
|
+
}
|
|
7345
|
+
params.speech_generation_settings = settings;
|
|
7346
|
+
const tc = ($('aiSpeechTimecode')?.value || '').trim(); if (tc) params.timecode = tc;
|
|
7347
|
+
}
|
|
7348
|
+
return params;
|
|
7349
|
+
}
|
|
7350
|
+
|
|
7351
|
+
function aiShowResult(op, payload, isErr) {
|
|
7352
|
+
const el = $('aiConsoleResult');
|
|
7353
|
+
if (!el) return;
|
|
7354
|
+
el.classList.toggle('err', !!isErr);
|
|
7355
|
+
el.classList.toggle('ok', !isErr);
|
|
7356
|
+
const head = `${AI_OP_LABELS[op] || op} — ${new Date().toLocaleTimeString()}\n`;
|
|
7357
|
+
el.textContent = head + JSON.stringify(payload, null, 2);
|
|
7358
|
+
}
|
|
7359
|
+
|
|
7360
|
+
async function runAiOp(op) {
|
|
7361
|
+
const target = (op === 'generate_speech' || op === 'disable_background_tasks') ? 'folder' : aiTarget();
|
|
7362
|
+
const params = aiBuildParams(op);
|
|
7363
|
+
if (target === 'clip') {
|
|
7364
|
+
const clipId = ($('aiClipId')?.value || '').trim();
|
|
7365
|
+
if (!clipId) { aiShowResult(op, { error: 'Enter a clip id, or switch target to Current folder.' }, true); return; }
|
|
7366
|
+
params.clip_id = clipId;
|
|
7367
|
+
}
|
|
7368
|
+
if (op === 'generate_speech' && !params.speech_generation_settings?.TextInput) {
|
|
7369
|
+
aiShowResult(op, { error: 'Enter text to synthesize.' }, true); return;
|
|
7370
|
+
}
|
|
7371
|
+
const buttons = document.querySelectorAll('.ai-op-btn');
|
|
7372
|
+
buttons.forEach(b => { b.disabled = true; });
|
|
7373
|
+
try {
|
|
7374
|
+
let res = await api('/api/resolve_ai/run', {
|
|
7375
|
+
method: 'POST', body: JSON.stringify({ op, target, params }),
|
|
7376
|
+
}).catch(err => ({ success: false, error: String(err && err.message || err) }));
|
|
7377
|
+
// Confirm-token two-step for the media-creating ops.
|
|
7378
|
+
if (res && res.status === 'confirmation_required') {
|
|
7379
|
+
const preview = res.preview || {};
|
|
7380
|
+
const proceed = await brandedConfirm({
|
|
7381
|
+
kicker: 'Creates new media',
|
|
7382
|
+
title: AI_OP_LABELS[op] || op,
|
|
7383
|
+
body: preview.warning || 'This operation creates new media. Proceed?',
|
|
7384
|
+
detail: JSON.stringify(preview, null, 2),
|
|
7385
|
+
confirmLabel: 'Run it',
|
|
7386
|
+
tone: 'danger',
|
|
7387
|
+
});
|
|
7388
|
+
if (!proceed) { aiShowResult(op, { cancelled: true }, false); return; }
|
|
7389
|
+
const params2 = { ...params, confirm_token: res.confirm_token };
|
|
7390
|
+
res = await api('/api/resolve_ai/run', {
|
|
7391
|
+
method: 'POST', body: JSON.stringify({ op, target, params: params2 }),
|
|
7392
|
+
}).catch(err => ({ success: false, error: String(err && err.message || err) }));
|
|
7393
|
+
}
|
|
7394
|
+
aiShowResult(op, res, !(res && res.success));
|
|
7395
|
+
// Refresh the ledger widget so creators' file/byte totals update.
|
|
7396
|
+
refreshResolveAiOps().catch(() => {});
|
|
7397
|
+
} finally {
|
|
7398
|
+
buttons.forEach(b => { b.disabled = false; });
|
|
7399
|
+
}
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7402
|
+
function initAiConsole() {
|
|
7403
|
+
if (_aiConsoleInit) { renderAiConsole(); return; }
|
|
7404
|
+
_aiConsoleInit = true;
|
|
7405
|
+
renderAiConsole();
|
|
7406
|
+
document.querySelectorAll('#panel-aiconsole .ai-op-btn').forEach(btn => {
|
|
7407
|
+
btn.addEventListener('click', () => runAiOp(btn.dataset.aiOp));
|
|
7408
|
+
});
|
|
7409
|
+
}
|
|
7410
|
+
|
|
7043
7411
|
// ─── Caps inspector + refusals + reset ──────────────────────────
|
|
7044
7412
|
async function inspectCapsFromUI() {
|
|
7045
7413
|
const clipId = ($('capsInspectClipId')?.value || '').trim();
|
|
@@ -9916,6 +10284,7 @@ HTML = r"""<!doctype html>
|
|
|
9916
10284
|
refreshCapsWidget().catch(() => {});
|
|
9917
10285
|
refreshCapsHistory().catch(() => {});
|
|
9918
10286
|
refreshCapsRefusals().catch(() => {});
|
|
10287
|
+
refreshResolveAiOps().catch(() => {});
|
|
9919
10288
|
|
|
9920
10289
|
// Caps inspector + reset
|
|
9921
10290
|
$('capsInspectBtn')?.addEventListener('click', () => inspectCapsFromUI().catch(alertError));
|
|
@@ -10711,6 +11080,58 @@ def _resolve_identity() -> Dict[str, Any]:
|
|
|
10711
11080
|
}
|
|
10712
11081
|
|
|
10713
11082
|
|
|
11083
|
+
# ── Resolve 21 AI Console: op dispatch ──────────────────────────────────────
|
|
11084
|
+
# Folder/clip-level ops are routed to the consolidated `folder` /
|
|
11085
|
+
# `media_pool_item` tools; project/resolve-level ops to their tools. The
|
|
11086
|
+
# consolidated tools own the confirm-token gate for the two media-creators, so
|
|
11087
|
+
# this dispatcher just relays params (incl. confirm_token) and the result.
|
|
11088
|
+
|
|
11089
|
+
_AI_CONSOLE_FOLDER_OPS = frozenset({
|
|
11090
|
+
"perform_audio_classification", "clear_audio_classification",
|
|
11091
|
+
"analyze_for_intellisearch", "analyze_for_slate", "remove_motion_blur",
|
|
11092
|
+
"transcribe_audio", "clear_transcription",
|
|
11093
|
+
})
|
|
11094
|
+
|
|
11095
|
+
|
|
11096
|
+
def _run_resolve_ai_op(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
11097
|
+
"""Dispatch one AI Console op to the right consolidated server tool.
|
|
11098
|
+
|
|
11099
|
+
body = {op, target?, params?}. target is 'folder' (current Media Pool
|
|
11100
|
+
folder, default) or 'clip' (params.clip_id required). Returns the tool's
|
|
11101
|
+
response verbatim — including a {status:'confirmation_required', confirm_token,
|
|
11102
|
+
preview} shape for the gated media-creating ops.
|
|
11103
|
+
"""
|
|
11104
|
+
op = (body.get("op") or "").strip()
|
|
11105
|
+
target = (body.get("target") or "folder").strip()
|
|
11106
|
+
params = dict(body.get("params") or {})
|
|
11107
|
+
if not op:
|
|
11108
|
+
return {"success": False, "error": "op is required"}
|
|
11109
|
+
try:
|
|
11110
|
+
from src.server import (
|
|
11111
|
+
folder as _folder_tool,
|
|
11112
|
+
media_pool_item as _mpi_tool,
|
|
11113
|
+
project_settings as _ps_tool,
|
|
11114
|
+
resolve_control as _rc_tool,
|
|
11115
|
+
)
|
|
11116
|
+
except Exception as exc: # pragma: no cover - import guard
|
|
11117
|
+
return {"success": False, "error": f"server tools unavailable: {exc}"}
|
|
11118
|
+
|
|
11119
|
+
if op == "disable_background_tasks":
|
|
11120
|
+
return _rc_tool("disable_background_tasks_for_current_session", {})
|
|
11121
|
+
if op == "generate_speech":
|
|
11122
|
+
return _ps_tool("generate_speech", params)
|
|
11123
|
+
if op not in _AI_CONSOLE_FOLDER_OPS:
|
|
11124
|
+
return {"success": False, "error": f"unknown op {op!r}"}
|
|
11125
|
+
if target == "clip":
|
|
11126
|
+
clip_id = params.get("clip_id") or body.get("clip_id")
|
|
11127
|
+
if not clip_id:
|
|
11128
|
+
return {"success": False, "error": "clip target requires a clip_id"}
|
|
11129
|
+
params["clip_id"] = clip_id
|
|
11130
|
+
return _mpi_tool(op, params)
|
|
11131
|
+
# default: operate on the current Media Pool folder
|
|
11132
|
+
return _folder_tool(op, params)
|
|
11133
|
+
|
|
11134
|
+
|
|
10714
11135
|
def _clip_props(clip: Any) -> Dict[str, Any]:
|
|
10715
11136
|
props, _ = _safe_call(clip, "GetClipProperty", "")
|
|
10716
11137
|
return props if isinstance(props, dict) else {}
|
|
@@ -13613,6 +14034,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
13613
14034
|
except Exception as exc:
|
|
13614
14035
|
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
13615
14036
|
return
|
|
14037
|
+
if path == "/api/resolve_ai_usage":
|
|
14038
|
+
# Ledger of Resolve-local 21.0 AI ops (read straight from this
|
|
14039
|
+
# project's brain DB — no Resolve round-trip needed).
|
|
14040
|
+
try:
|
|
14041
|
+
from src.utils import resolve_ai_ledger as _ledger
|
|
14042
|
+
root = self.state.project_root
|
|
14043
|
+
self._json({
|
|
14044
|
+
"success": True,
|
|
14045
|
+
"summary": _ledger.get_summary(project_root=root),
|
|
14046
|
+
"recent": _ledger.get_usage(project_root=root, limit=50),
|
|
14047
|
+
})
|
|
14048
|
+
except Exception as exc:
|
|
14049
|
+
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
14050
|
+
return
|
|
13616
14051
|
if path.startswith("/api/timeline_thumbnail/"):
|
|
13617
14052
|
rel = unquote(path[len("/api/timeline_thumbnail/"):])
|
|
13618
14053
|
# Path is <slug>/<vNN.png>; constrain it to live under _soul/timeline_versions
|
|
@@ -13867,6 +14302,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
13867
14302
|
except Exception as exc:
|
|
13868
14303
|
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
13869
14304
|
return
|
|
14305
|
+
if path == "/api/resolve_ai/run":
|
|
14306
|
+
# Run a Resolve 21 AI op from the panel. Loopback-only because it
|
|
14307
|
+
# mutates Resolve (and the media-creators write new files). The
|
|
14308
|
+
# confirm-token two-step is handled by the consolidated tool; the
|
|
14309
|
+
# 'confirmation_required' shape is relayed to the panel as 200.
|
|
14310
|
+
if not _request_is_loopback(self):
|
|
14311
|
+
self._json({"success": False, "error": "Loopback only."}, HTTPStatus.FORBIDDEN)
|
|
14312
|
+
return
|
|
14313
|
+
try:
|
|
14314
|
+
result = _run_resolve_ai_op(body)
|
|
14315
|
+
ok = bool(result.get("success")) or result.get("status") == "confirmation_required"
|
|
14316
|
+
self._json(result, 200 if ok else 400)
|
|
14317
|
+
except Exception as exc:
|
|
14318
|
+
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
14319
|
+
return
|
|
13870
14320
|
if path == "/api/caps/reset_day":
|
|
13871
14321
|
if not _request_is_loopback(self):
|
|
13872
14322
|
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.31.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.
|
|
14
|
+
VERSION = "2.31.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13458
|
-
|
|
13459
|
-
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13463
|
-
|
|
13464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|