davinci-resolve-mcp 2.39.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
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.40.0
6
+
7
+ Control panel UX overhaul + docs refresh, from a full live audit of every
8
+ panel view, with drift guards so neither can silently go stale again.
9
+
10
+ - **Added** a governance mode toggle (Advisory / Enforce) to the AI Console,
11
+ with corrected copy (the old text predated v2.39.0 enforce mode), and a
12
+ Recent-runs list on the AI-ops ledger showing each run's actor.
13
+ - **Added** chat-first onboarding: empty Overview, Review, and Inventory
14
+ states show plain-language guidance with a copyable suggested prompt
15
+ instead of zero-walls and MCP action names.
16
+ - **Added** History as a first-class Media menu item, deep-linkable at
17
+ `#analysis/review/history`; documented the full hash deep-link scheme.
18
+ - **Changed** the top-level "Analysis" menu to "Media" (it collided with the
19
+ Preferences page of the same name); "Analyze" is now "Inventory". Full
20
+ vocabulary pass: humanized readiness chips and frame-sampling labels, no
21
+ internal codenames, file names, or absolute paths in UI copy.
22
+ - **Fixed** the README's linked screenshot rendering broken in the Docs
23
+ reader: badge parsing no longer claims local linked images, and a new
24
+ `/api/doc_asset/` route serves repo doc images (path-constrained).
25
+ - **Fixed** poll hygiene: timers pause while the tab is hidden, panel-state
26
+ polling backs off when unfocused, and unchanged inventory polls return a
27
+ tiny 200 instead of a 304 that Chrome logged as an aborted request.
28
+ - **Docs**: `docs/guides/control-panel.md` fully rewritten for the current
29
+ IA with all screenshots regenerated from the live panel
30
+ (`scripts/regen_panel_screenshots.py`). A new drift-guard test fails the
31
+ suite — and the publish workflow, which now runs all three static guards
32
+ on every tag — when the guide drifts from the panel's navigation or
33
+ screenshots.
34
+
5
35
  ## What's New in v2.39.0
6
36
 
7
37
  Governance enforce mode and actor identity — the staged Phase 3 of the
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.39.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.40.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-33%20(341%20full)-blue.svg)](#server-modes)
@@ -1,9 +1,9 @@
1
1
  # Local Control Panel
2
2
 
3
- The DaVinci Resolve MCP ships with a local, single-user browser control panel for
4
- inspecting Resolve state, running source-safe media analysis, drilling into
5
- analyzed clips and shots, fixing analysis output inline, and managing
6
- preferences.
3
+ The DaVinci Resolve MCP ships with a local, single-user browser control panel
4
+ for inspecting Resolve state, running source-safe media analysis, drilling into
5
+ analyzed clips and shots, fixing analysis output inline, reviewing timeline
6
+ edit history, driving Resolve's local AI operations, and managing preferences.
7
7
 
8
8
  The panel is a local HTTP server (default `http://127.0.0.1:8765`). It does not
9
9
  expose a network listener beyond loopback and does not modify source media.
@@ -21,8 +21,29 @@ npx davinci-resolve-mcp control-panel
21
21
  resolve_control(action="open_control_panel")
22
22
  ```
23
23
 
24
- Once running, you can `resolve_control(action="control_panel_status")` to check
25
- the pidfile or `resolve_control(action="close_control_panel")` to stop it.
24
+ Once running, `resolve_control(action="control_panel_status")` checks the
25
+ pidfile and `resolve_control(action="close_control_panel")` stops it.
26
+
27
+ ## Navigation and deep links
28
+
29
+ Top-level sections: **Overview**, **Media** (Inventory / Review / History),
30
+ **AI Console**, **Setup** (Resolve / MCP / Storage / Tools / Media Pool
31
+ History), **Docs**, and **Preferences** (Analysis / Caps + Safety / Metadata
32
+ and Markers / Paths and Workflow / MCP Updates). The project selector on the
33
+ right scopes the panel to an analysis context; **View All Projects** opens a
34
+ read-only browser over the Resolve project database with confirm-gated
35
+ loading.
36
+
37
+ Every view is addressable by URL hash, so links can be bookmarked or pasted
38
+ into chat:
39
+
40
+ ```
41
+ #overview #aiconsole
42
+ #analysis/media #analysis/review
43
+ #analysis/review/history #analysis/review/clip/<clip_id>
44
+ #analysis/review/clip/<clip_id>/shot/<n> #analysis/review/clip/<clip_id>/transcript
45
+ #diagnostics/mcp #preferences/caps
46
+ ```
26
47
 
27
48
  ## Tour
28
49
 
@@ -30,78 +51,125 @@ the pidfile or `resolve_control(action="close_control_panel")` to stop it.
30
51
 
31
52
  ![Control panel overview](../images/control-panel/01-overview.png)
32
53
 
33
- The Overview tab is the at-a-glance summary for the active project: Resolve
34
- connection state, source clip counts, analysis progress, search index size,
35
- and the source-media safety status.
54
+ The at-a-glance summary for the active project: Resolve connection state,
55
+ source clip counts, analysis progress, search-index status, and the
56
+ source-media safety posture. When the project has no source clips yet, the
57
+ view shows a get-started card with a suggested chat prompt and a
58
+ **Copy prompt** button — the panel is summoned by conversation, and its empty
59
+ states point back into it.
36
60
 
37
- ### AnalysisAnalyze
61
+ ### MediaInventory
38
62
 
39
- ![Analyze view](../images/control-panel/06-analyze.png)
63
+ ![Inventory view](../images/control-panel/06-inventory.png)
40
64
 
41
- The Analyze view inventories the Resolve media pool read-only and lets you
42
- queue source-safe analysis jobs. Filters cover bin, media type, clip status,
43
- and analysis status. Resolve media stays read-only; analysis outputs land under
44
- the configured analysis root.
65
+ Inventory walks the Resolve Media Pool read-only and filters to source clips so
66
+ timelines, compounds, titles, and generated items stay out of analysis queues.
67
+ Filters cover bin, media type, clip status, and analysis status, with optional
68
+ auto-refresh polling. From here you can select analyzable clips, copy a
69
+ ready-made analysis prompt for your chat session, or launch a supported chat
70
+ client directly.
45
71
 
46
- ### Analysis → Review (bin grid)
72
+ ### Media → Review
47
73
 
48
74
  ![Review bin grid](../images/control-panel/02-review-bin-grid.png)
49
75
 
50
- The Review surface is the browser for analyzed clips. Each card shows a
51
- representative thumbnail, summary one-liner, primary use, shot count, and tags.
52
- Click into a card to drill down.
76
+ Review is the browser for analyzed clips. The readiness strip summarizes the
77
+ evidence base (analyzed / superseded / vision-pending / warnings) with
78
+ humanized source-trust and analysis-layer chips. Cards show a representative
79
+ thumbnail, duration, shot count, status, and summary one-liner; grid and list
80
+ layouts are available, and full-text search covers clips, summaries, tags, and
81
+ transcripts once the search index is built.
53
82
 
54
83
  ### Clip detail
55
84
 
56
85
  ![Clip detail with shot strip](../images/control-panel/03-clip-detail.png)
57
86
 
58
- The clip view shows the full summary, tags, editorial notes, and a strip of
59
- every detected shot with thumbnails. From here you can open the clip in
60
- Resolve, jump to the transcript, or click into any shot for the full V2 field
61
- breakdown.
87
+ The clip view shows the full summary, tags, star rating, editorial notes, and
88
+ a contact sheet of every detected shot. From here you can open the clip in
89
+ Resolve, jump to the transcript, or click into any shot.
62
90
 
63
91
  ### Transcript
64
92
 
65
93
  ![Transcript view with word-level timing](../images/control-panel/04-transcript.png)
66
94
 
67
- The transcript view is reachable from the clip detail (top-right `Transcript`
68
- button). Each segment shows its start timecode, the cleaned sentence, and the
69
- word-level transcription beneath. The segment filter narrows by text; clicking
70
- a segment can jump the Resolve playhead to that source time. `Re-transcribe
71
- with words` re-runs the configured local transcription backend (Whisper by
72
- default) without re-doing visual analysis, and `corrections.json` captures
73
- inline edits so they survive future re-runs.
95
+ Each segment shows its start time, the cleaned sentence, and the original
96
+ machine transcription beneath. Inline edits are preserved across re-analysis,
97
+ and `Re-transcribe with words` re-runs the configured local transcription
98
+ backend (Whisper by default) without re-doing visual analysis.
74
99
 
75
100
  ### Shot detail with inline correction
76
101
 
77
- ![Shot detail with V2 fields and frames](../images/control-panel/05-shot-detail.png)
102
+ ![Shot detail with fields and frames](../images/control-panel/05-shot-detail.png)
78
103
 
79
- The shot view shows every V2 schema field (shot size, framing, camera height,
80
- motion direction, lens character, lighting, composition notes, and more)
81
- alongside the frames the vision pass actually saw. Each subjective field is
82
- editable inline edits land in `corrections.json` with an append-only
83
- changelog and are merged on top of the next re-analysis so human notes survive
84
- fresh vision runs. `Open in Resolve` bounces you straight to the clip in the
85
- source viewer with the shot's mark in/out set.
104
+ The shot view shows the analysis fields for the shot alongside the frames the
105
+ vision pass actually sampled, each labeled with why it was chosen ("Shot
106
+ start", "After cut", "Flash frame", "Motion peak"). Subjective fields are
107
+ editable inline; edits are kept with the clip's analysis and merged on top of
108
+ future re-analysis so human notes survive fresh vision runs. `Open in Resolve`
109
+ jumps straight to the clip in the source viewer with the shot's mark in/out
110
+ set.
86
111
 
87
- ### SetupResolve diagnostic
112
+ ### MediaHistory
88
113
 
89
- ![Setup / Resolve diagnostic](../images/control-panel/07-diagnostics-resolve.png)
114
+ ![Timeline history](../images/control-panel/10-history.png)
90
115
 
91
- The Resolve diagnostic page reports the live connection, product/version,
92
- active page, project identity, and media pool inventory. Useful when chat
93
- sessions need to confirm Resolve is reachable before issuing scripted
94
- operations.
116
+ The timeline edit history: archived timeline versions and brain edits per
117
+ timeline. Begin/end labeled runs to group related edits, archive the current
118
+ timeline before risky work, and select any timeline to inspect its version
119
+ chain and the edits between versions. Every destructive timeline edit made
120
+ through the MCP archives a version here automatically.
121
+
122
+ ### AI Console
123
+
124
+ ![AI Console with governance](../images/control-panel/09-ai-console.png)
125
+
126
+ Runs Resolve's local AI operations (audio classification, IntelliSearch, slate
127
+ analysis, transcription, motion deblur, speech generation) on the current
128
+ Media Pool folder or a specific clip. Capability dots show what this Resolve
129
+ build exposes and which AI Extras are required. The **Governance** card sets
130
+ the per-session tier (off / lenient / standard / strict) for the two
131
+ media-creating ops and the governance **mode**: *Advisory* warns in the
132
+ confirm dialog; *Enforce* refuses an over-tier run until you raise the tier,
133
+ relax the mode, or consciously override. Usage gauges track runs, media
134
+ created, and render time against the active tier, and the AI-ops ledger
135
+ (Preferences → Caps + Safety) records every run with the acting instance.
136
+
137
+ ### Setup
138
+
139
+ ![Setup / Resolve diagnostic](../images/control-panel/07-diagnostics-resolve.png)
95
140
 
96
- ### Preferences Analysis
141
+ - **Resolve** live connection, product/version, active page, project
142
+ identity, and Media Pool inventory.
143
+ - **MCP** — server identity, detected Resolve scripting paths, transport
144
+ status (with a start button for the networked mode), and per-client install
145
+ status with one-click install/repair for every supported harness.
146
+ - **Storage** — the analysis root, search index, and jobs database paths and
147
+ sizes for the active project.
148
+ - **Tools** — runtime helpers (ffprobe, ffmpeg, Whisper backends, OpenCV)
149
+ with ready/missing status and copyable install commands.
150
+ - **Media Pool History** — the provenance log for destructive media-pool
151
+ operations (deletes, replaces, relinks), kept separate from timeline brain
152
+ edits.
153
+
154
+ ### Preferences
97
155
 
98
156
  ![Preferences / Analysis defaults](../images/control-panel/08-preferences-analysis.png)
99
157
 
100
- Server-wide analysis defaults: vision on/off, transcription default, slate
101
- detection, source-trust grading (`auto` / `filename` / `low` / `medium` /
102
- `high`), default analysis depth, default sample frame count, persistence
103
- behavior, summary style, and report format. These are the defaults the
104
- `analyze_media` prompt uses when not overridden per call.
158
+ Server-wide defaults for this MCP install (dashboard-only conveniences stay in
159
+ the browser):
160
+
161
+ - **Analysis** vision/transcription defaults, slate detection, source-trust
162
+ grading, depth, frame-sampling mode, summary style, report format.
163
+ - **Caps + Safety** — token and frame budget presets (minimal / standard /
164
+ generous / unlimited) with live usage gauges, per-clip/job/day caps, the
165
+ Resolve AI-ops ledger, and destructive-edit safety rails.
166
+ - **Metadata and Markers** — Resolve metadata write-back fields, timed-marker
167
+ defaults, marker types and colors, overwrite policy.
168
+ - **Paths and Workflow** — preferred analysis root, generated-media folder,
169
+ post-operation page behavior, and a read-only map of where files live.
170
+ - **MCP Updates** — update policy (prompt / notify / auto / never), release
171
+ channel (stable / beta / dev), check cadence, apply/rollback with update
172
+ history.
105
173
 
106
174
  ## Chat ↔ panel state sharing
107
175
 
@@ -115,8 +183,9 @@ active project's analysis root). MCP actions:
115
183
  - `media_analysis(action="session_start_context")` — bootstrap a chat session
116
184
  with the panel's current focus
117
185
 
118
- The dashboard polls `/api/panel_state` every 2 seconds, so changes from chat
119
- appear in the UI without a refresh.
186
+ The dashboard polls `/api/panel_state` every 2 seconds while it has focus,
187
+ backs off to roughly every 10 seconds when the window is unfocused, and stops
188
+ polling entirely while the tab is hidden.
120
189
 
121
190
  ## Open in Resolve
122
191
 
@@ -30,6 +30,11 @@ Every release bump must update all version surfaces:
30
30
  - README current stats or latest-release summary when they changed
31
31
  - `CHANGELOG.md` latest release entry
32
32
  - `docs/SKILL.md` when tool discovery, examples, or behavior changed
33
+ - `docs/guides/control-panel.md` when the control panel UI changed, plus
34
+ regenerated screenshots: start the panel against a project with analysis
35
+ data, then run `venv/bin/python scripts/regen_panel_screenshots.py`.
36
+ The `tests.test_panel_docs_drift` guard fails the suite (and the publish
37
+ workflow) when the guide drifts from the panel's navigation or screenshots.
33
38
  - Git tag, e.g. `v2.4.1`
34
39
  - GitHub Release notes
35
40
 
@@ -43,12 +48,18 @@ Always run static checks before release:
43
48
  ```bash
44
49
  venv/bin/python tests/test_import.py
45
50
  venv/bin/python scripts/audit_api_parity.py
51
+ venv/bin/python -m unittest tests.test_static_undefined_names tests.test_action_list_drift tests.test_panel_docs_drift
46
52
  node bin/davinci-resolve-mcp.mjs --help
47
53
  node bin/davinci-resolve-mcp.mjs --version
48
54
  npm pack --dry-run
49
55
  git diff --check
50
56
  ```
51
57
 
58
+ The three drift guards (undefined names in `src/`, tool action lists vs
59
+ dispatch, control-panel guide vs panel navigation/screenshots) also run in the
60
+ `Publish npm package` workflow on every `v*` tag, so a stale doc or drifted
61
+ action list fails the publish — fix the drift rather than bypassing the gate.
62
+
52
63
  Run focused unit tests for the changed surface. For recent timeline/marker
53
64
  helpers, this usually includes:
54
65
 
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.39.0"
38
+ VERSION = "2.40.0"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.39.0",
3
+ "version": "2.40.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Regenerate the control-panel doc screenshots from a live panel.
3
+
4
+ Drives the panel's hash deep links headlessly and writes 1280x800 captures
5
+ into docs/images/control-panel/. Run whenever the panel UI changes visibly.
6
+
7
+ Setup:
8
+ 1. venv/bin/pip install playwright (chromium is fetched on first run if absent)
9
+ 2. Start the panel bound to a project that HAS an analyzed clip, e.g.:
10
+ venv/bin/python -m src.control_panel --no-open
11
+ (Bind to a rich analysis context if the live Resolve project is empty.)
12
+ 3. venv/bin/python scripts/regen_panel_screenshots.py [--clip-id <id>] [--port 8765]
13
+
14
+ The clip id defaults to the first analyzed clip reported by /api/clips.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import pathlib
21
+ import sys
22
+ import urllib.request
23
+
24
+ OUT_DIR = pathlib.Path(__file__).resolve().parent.parent / "docs" / "images" / "control-panel"
25
+
26
+
27
+ def first_clip_id(base: str) -> str:
28
+ with urllib.request.urlopen(f"{base}/api/clips", timeout=10) as resp:
29
+ payload = json.load(resp)
30
+ clips = payload.get("clips") or []
31
+ if not clips:
32
+ sys.exit("No analyzed clips in the panel's project — bind the panel to a project with analysis data first.")
33
+ return clips[0]["clip_id"]
34
+
35
+
36
+ def main() -> int:
37
+ parser = argparse.ArgumentParser(description=__doc__)
38
+ parser.add_argument("--port", type=int, default=8765)
39
+ parser.add_argument("--clip-id", default=None)
40
+ args = parser.parse_args()
41
+ base = f"http://127.0.0.1:{args.port}"
42
+
43
+ try:
44
+ from playwright.sync_api import sync_playwright
45
+ except ImportError:
46
+ sys.exit("playwright not installed: venv/bin/pip install playwright")
47
+
48
+ clip = args.clip_id or first_clip_id(base)
49
+ shots = [
50
+ ("#overview", "01-overview.png", 3000),
51
+ ("#analysis/review", "02-review-bin-grid.png", 3500),
52
+ (f"#analysis/review/clip/{clip}", "03-clip-detail.png", 4000),
53
+ (f"#analysis/review/clip/{clip}/transcript", "04-transcript.png", 4000),
54
+ (f"#analysis/review/clip/{clip}/shot/3", "05-shot-detail.png", 4500),
55
+ ("#analysis/media", "06-inventory.png", 3500),
56
+ ("#diagnostics/resolve", "07-diagnostics-resolve.png", 3000),
57
+ ("#preferences/analysis", "08-preferences-analysis.png", 3000),
58
+ ("#aiconsole", "09-ai-console.png", 3000),
59
+ ("#analysis/review/history", "10-history.png", 3500),
60
+ ]
61
+ with sync_playwright() as pw:
62
+ browser = pw.chromium.launch()
63
+ page = browser.new_page(viewport={"width": 1280, "height": 800})
64
+ for hash_, name, wait in shots:
65
+ page.goto(f"{base}/{hash_}")
66
+ page.wait_for_timeout(wait)
67
+ page.screenshot(path=str(OUT_DIR / name))
68
+ print("shot:", name)
69
+ browser.close()
70
+ print(f"done — {len(shots)} screenshots in {OUT_DIR}")
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
@@ -3753,6 +3753,7 @@ HTML = r"""<!doctype html>
3753
3753
  justify-content: flex-start;
3754
3754
  padding-bottom: 2px;
3755
3755
  }
3756
+ .control-tabs select { max-width: 150px; min-width: 110px; }
3756
3757
  .lab-navbar {
3757
3758
  height: 88px;
3758
3759
  flex-wrap: wrap;
@@ -3817,10 +3818,11 @@ HTML = r"""<!doctype html>
3817
3818
  <nav class="control-tabs" aria-label="Control panel sections">
3818
3819
  <button class="control-tab active" data-panel-target="overview">Overview</button>
3819
3820
  <div class="control-nav-item">
3820
- <button class="control-tab has-menu" data-panel-target="analysis">Analysis <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>
3821
+ <button class="control-tab has-menu" data-panel-target="analysis">Media <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>
3821
3822
  <div class="nav-dropdown" role="menu" aria-label="Analysis pages">
3822
- <button class="nav-dropdown-item" data-panel-target="analysis" data-subpage-target="media" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m7 15 3-3 2 2 4-5 1 2"></path></svg></span>Analyze</button>
3823
+ <button class="nav-dropdown-item" data-panel-target="analysis" data-subpage-target="media" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m7 15 3-3 2 2 4-5 1 2"></path></svg></span>Inventory</button>
3823
3824
  <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>
3825
+ <button class="nav-dropdown-item" id="navHistoryItem" data-panel-target="analysis" data-subpage-target="review" data-review-view="history" role="menuitem"><span class="nav-dropdown-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg></span>History</button>
3824
3826
  </div>
3825
3827
  </div>
3826
3828
  <button class="control-tab" data-panel-target="aiconsole">AI Console</button>
@@ -3969,7 +3971,7 @@ HTML = r"""<!doctype html>
3969
3971
  <section class="span-12 subpage active" data-subpage-scope="analysis" data-subpage="media">
3970
3972
  <div class="section-top">
3971
3973
  <div>
3972
- <h2>Analyze</h2>
3974
+ <h2>Inventory</h2>
3973
3975
  <p class="section-copy">Resolve media is inventoried read-only and filtered to source clips so timelines, compounds, titles, and generated items stay out of analysis queues.</p>
3974
3976
  <div class="section-meta" id="resolveProject">Resolve connection pending</div>
3975
3977
  <div class="copy-status" id="copyPromptStatus"></div>
@@ -4048,7 +4050,7 @@ HTML = r"""<!doctype html>
4048
4050
  </div>
4049
4051
  <div class="controls" id="reviewControls">
4050
4052
  <button class="secondary" id="reviewRefreshBtn">Refresh</button>
4051
- <button class="secondary" id="reviewHistoryBtn" title="Timeline edit history (C6)">History</button>
4053
+ <button class="secondary" id="reviewHistoryBtn" title="Timeline edit history">History</button>
4052
4054
  <button class="secondary" id="reviewBackBtn" style="display:none">← Back</button>
4053
4055
  </div>
4054
4056
  </div>
@@ -4057,7 +4059,6 @@ HTML = r"""<!doctype html>
4057
4059
  <div class="readiness-card-header">
4058
4060
  <span class="readiness-title">Project readiness</span>
4059
4061
  <span id="readinessEvidenceBase" class="readiness-evidence"></span>
4060
- <button id="readinessRefreshBtn" class="secondary" type="button" title="Refresh readiness summary">Refresh</button>
4061
4062
  </div>
4062
4063
  <div id="readinessSummaryRow" class="readiness-summary-row"></div>
4063
4064
  <div id="readinessDetails" class="readiness-details"></div>
@@ -4181,8 +4182,12 @@ HTML = r"""<!doctype html>
4181
4182
 
4182
4183
  <div class="caps-section" style="margin-top:12px;">
4183
4184
  <div class="caps-section-head"><div class="caps-section-title">Governance</div>
4184
- <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>
4185
+ <div class="caps-section-hint">Per-session limits for the two media-creating ops (deblur, speech). In <strong>Advisory</strong> mode you're warned in the confirm dialog but never blocked; in <strong>Enforce</strong> mode an over-tier run is refused until you raise the tier, relax the mode, or consciously override. Pick the tier that matches the job.</div></div>
4185
4186
  <div id="aiGovTiers" class="caps-preset-cards" role="radiogroup" aria-label="AI governance tier"></div>
4187
+ <div class="review-view-toggle" id="aiGovMode" role="radiogroup" aria-label="Governance mode" style="margin-top:10px;">
4188
+ <button type="button" data-gov-mode="advisory">Advisory</button>
4189
+ <button type="button" data-gov-mode="enforce">Enforce</button>
4190
+ </div>
4186
4191
  <div id="aiGovUsage" class="caps-usage-gauges" style="margin-top:10px;"></div>
4187
4192
  </div>
4188
4193
 
@@ -4527,6 +4532,7 @@ HTML = r"""<!doctype html>
4527
4532
  </tr></thead>
4528
4533
  <tbody id="resolveAiOpsRows"></tbody>
4529
4534
  </table>
4535
+ <div id="resolveAiOpsRecent"></div>
4530
4536
  </div>
4531
4537
  </div>
4532
4538
 
@@ -4853,7 +4859,7 @@ HTML = r"""<!doctype html>
4853
4859
  const VIEW_ALL_PROJECTS_VALUE = '__view_all_projects__';
4854
4860
  const PANEL_LABELS = {
4855
4861
  overview: 'Overview',
4856
- analysis: 'Analysis',
4862
+ analysis: 'Media',
4857
4863
  aiconsole: 'AI Console',
4858
4864
  diagnostics: 'Setup',
4859
4865
  projects: 'Projects',
@@ -4877,7 +4883,7 @@ HTML = r"""<!doctype html>
4877
4883
  };
4878
4884
  const SUBPAGE_LABELS = {
4879
4885
  analysis: {
4880
- media: 'Analyze',
4886
+ media: 'Inventory',
4881
4887
  review: 'Review',
4882
4888
  },
4883
4889
  diagnostics: {
@@ -5153,6 +5159,10 @@ HTML = r"""<!doctype html>
5153
5159
  openClipDetail(clipId, { writePanelState: false, pushHash: false }).catch(alertError);
5154
5160
  }
5155
5161
  }
5162
+ if (panelName === 'analysis' && subpage === 'review' && route[2] === 'history') {
5163
+ reviewSetView('history');
5164
+ refreshHistoryTimelines().catch(alertError);
5165
+ }
5156
5166
  // Deep links: #analysis/review/combined/<id1,id2,...>
5157
5167
  if (panelName === 'analysis' && subpage === 'review' && route[2] === 'combined' && route[3]) {
5158
5168
  const ids = decodeURIComponent(route[3]).split(',').filter(Boolean);
@@ -5268,12 +5278,19 @@ HTML = r"""<!doctype html>
5268
5278
  renderInfoRows('overviewStatusList', [
5269
5279
  { label: 'Resolve project', value: resolveProject, icon: ICONS.project, pill: { tone: 'pill-ok', label: 'Read-only' } },
5270
5280
  { label: 'Source clips', value: `${clipLabel(clips.length)} · ${readyClips} ready`, icon: ICONS.clips, pill: { tone: clipsTone, label: `${readyClips} ready` } },
5271
- { label: 'Sequences', value: `${sequences} read-only context ${sequences === 1 ? 'item' : 'items'}`, icon: ICONS.sequences },
5281
+ { label: 'Sequences', value: sequences ? `${sequences} timeline${sequences === 1 ? '' : 's'} detected (read-only)` : 'No timelines detected', icon: ICONS.sequences },
5272
5282
  { label: 'Clip media status', value: `${onlineClips} online · ${missingClips} missing/offline`, icon: ICONS.status, pill: { tone: mediaTone, label: missingClips ? `${missingClips} missing` : 'All online' } },
5273
5283
  { label: 'Analysis progress', value: `${analyzedClips} of ${clips.length} analyzed (${analyzedPct}%)`, icon: ICONS.analysis, pill: { tone: analysisTone, label: `${analyzedPct}%` }, meter: { percent: analyzedPct } },
5274
5284
  { label: 'Search index', value: index.detail, icon: ICONS.search, pill: { tone: index.tone, label: index.label } },
5275
5285
  { label: 'Safety', value: 'Source media is read-only; outputs stay in the active project analysis root.', icon: ICONS.safety, pill: { tone: 'pill-ok', label: 'Read-only' } },
5276
5286
  ]);
5287
+ if (!clips.length) {
5288
+ const list = $('overviewStatusList');
5289
+ if (list) list.insertAdjacentHTML('afterbegin', chatPromptCard(
5290
+ 'This project has no source clips yet. Import media in Resolve, then ask your assistant to take it from there:',
5291
+ 'Look at my current Resolve project, inventory the media, and analyze the source clips.'
5292
+ ));
5293
+ }
5277
5294
  }
5278
5295
  }
5279
5296
 
@@ -5700,6 +5717,16 @@ HTML = r"""<!doctype html>
5700
5717
  analyzeBtn.disabled = !hasAnalyzableClips;
5701
5718
  copyBtn.disabled = !hasAnalyzableClips;
5702
5719
  setAnalyzeClientButtonsEnabled(hasAnalyzableClips);
5720
+ if (!allClips.length) {
5721
+ body.className = 'empty';
5722
+ body.innerHTML = chatPromptCard(
5723
+ 'The Media Pool has no source clips yet. Import media in Resolve, then ask your assistant to inventory and analyze it:',
5724
+ 'Look at my current Resolve project, inventory the media, and analyze the source clips.'
5725
+ );
5726
+ updateMediaPollStatus();
5727
+ renderControlPanels();
5728
+ return;
5729
+ }
5703
5730
  body.className = '';
5704
5731
  const rows = clips.map((clip, index) => {
5705
5732
  // The server normalizes 'succeeded'/'skipped' → 'analyzed' whenever the
@@ -6325,6 +6352,7 @@ HTML = r"""<!doctype html>
6325
6352
  state.resolveMediaStale = false;
6326
6353
  if (res.status === 304) return;
6327
6354
  const payload = await res.json();
6355
+ if (payload && payload.unchanged) return;
6328
6356
  if (!res.ok || payload.success === false) {
6329
6357
  throw new Error(payload.error || res.statusText);
6330
6358
  }
@@ -6467,6 +6495,21 @@ HTML = r"""<!doctype html>
6467
6495
  }
6468
6496
  }
6469
6497
 
6498
+ function chatPromptCard(message, prompt) {
6499
+ return `<div class="empty" style="padding:var(--space-3);border:1px dashed var(--border-default);border-radius:var(--radius-md);display:grid;gap:8px;justify-items:start;text-align:left;">
6500
+ <span>${escapeHtml(message)}</span>
6501
+ <code style="font-size:12px;color:var(--text-secondary);white-space:normal;">\u201c${escapeHtml(prompt)}\u201d</code>
6502
+ <button class="secondary" type="button" data-copy-chat-prompt="${escapeHtml(prompt)}">Copy prompt</button>
6503
+ </div>`;
6504
+ }
6505
+ document.addEventListener('click', async (e) => {
6506
+ const btn = e.target.closest('[data-copy-chat-prompt]');
6507
+ if (!btn) return;
6508
+ const ok = await writePromptToClipboard(btn.dataset.copyChatPrompt);
6509
+ btn.textContent = ok ? 'Copied \u2014 paste in your chat session' : 'Copy prompt';
6510
+ setTimeout(() => { btn.textContent = 'Copy prompt'; }, 2600);
6511
+ });
6512
+
6470
6513
  async function copyMcpPrompt() {
6471
6514
  const prompt = buildMcpPrompt();
6472
6515
  await writePromptToClipboard(prompt);
@@ -6829,6 +6872,7 @@ HTML = r"""<!doctype html>
6829
6872
  const interval = Number($('mediaPollInterval').value || 0);
6830
6873
  if (enabled && interval > 0) {
6831
6874
  state.mediaPollTimer = setInterval(() => {
6875
+ if (document.hidden) return;
6832
6876
  refreshResolveMedia({ silent: true }).catch(error => {
6833
6877
  console.warn('Resolve media poll failed', error);
6834
6878
  updateMediaPollStatus(error.message || String(error));
@@ -7306,6 +7350,17 @@ HTML = r"""<!doctype html>
7306
7350
  <td style="padding:4px 6px;">${b.bytes_created ? fmtBytes(b.bytes_created) : '—'}</td>
7307
7351
  </tr>`;
7308
7352
  }).join('');
7353
+ const recent = Array.isArray(data.recent) ? data.recent.slice(0, 8) : [];
7354
+ const recentEl = $('resolveAiOpsRecent');
7355
+ if (recentEl) {
7356
+ recentEl.innerHTML = recent.length ? '<div class="caps-section-hint" style="margin-top:8px;">Recent runs</div>'
7357
+ + recent.map(r => {
7358
+ const when = (r.occurred_at || '').replace('T', ' ');
7359
+ const who = r.actor ? ` · <span title="instance:pid that performed the op">${escapeHtml(r.actor)}</span>` : '';
7360
+ const ok = r.success ? 'ok' : 'failed';
7361
+ return `<div style="font-size:12px; color:var(--text-secondary); padding:2px 0;">${escapeHtml(when)} · ${escapeHtml(r.op)} · ${ok}${who}</div>`;
7362
+ }).join('') : '';
7363
+ }
7309
7364
  tableEl.style.display = '';
7310
7365
  }
7311
7366
 
@@ -7471,7 +7526,7 @@ HTML = r"""<!doctype html>
7471
7526
  standard: 'Sensible default',
7472
7527
  strict: 'Tight leash',
7473
7528
  };
7474
- state.aiGov = state.aiGov || { tier: 'standard', thresholds: {}, usage: {} };
7529
+ state.aiGov = state.aiGov || { tier: 'standard', mode: 'advisory', thresholds: {}, usage: {} };
7475
7530
 
7476
7531
  function aiGovFmt(dim, v) {
7477
7532
  if (v == null) return '∞';
@@ -7515,14 +7570,26 @@ HTML = r"""<!doctype html>
7515
7570
  </div>`;
7516
7571
  }).join('');
7517
7572
  }
7573
+ function renderGovMode() {
7574
+ const el = $('aiGovMode');
7575
+ if (!el) return;
7576
+ const mode = state.aiGov.mode || 'advisory';
7577
+ el.querySelectorAll('[data-gov-mode]').forEach(btn => {
7578
+ const active = btn.dataset.govMode === mode;
7579
+ btn.classList.toggle('active', active);
7580
+ btn.setAttribute('aria-checked', active ? 'true' : 'false');
7581
+ });
7582
+ }
7518
7583
  async function refreshGovernance() {
7519
7584
  const data = await api('/api/resolve_ai/governance').catch(() => ({ success: false }));
7520
7585
  if (!data || !data.success) return;
7521
7586
  state.aiGov.tier = data.tier;
7587
+ state.aiGov.mode = data.mode || 'advisory';
7522
7588
  state.aiGov.thresholds = data.thresholds || {};
7523
7589
  state.aiGov.usage = data.usage || {};
7524
7590
  state.aiGov.tiersAvailable = data.tiers_available || {};
7525
7591
  renderGovTiers();
7592
+ renderGovMode();
7526
7593
  renderGovUsage();
7527
7594
  }
7528
7595
  async function setGovernanceTier(tier) {
@@ -7534,6 +7601,15 @@ HTML = r"""<!doctype html>
7534
7601
  if (!res || !res.success) console.warn('governance save failed:', res && res.error);
7535
7602
  await refreshGovernance();
7536
7603
  }
7604
+ async function setGovernanceMode(mode) {
7605
+ state.aiGov.mode = mode;
7606
+ renderGovMode();
7607
+ const res = await api('/api/resolve_ai/governance', {
7608
+ method: 'POST', body: JSON.stringify({ mode }),
7609
+ }).catch(err => ({ success: false, error: String(err) }));
7610
+ if (!res || !res.success) console.warn('governance mode save failed:', res && res.error);
7611
+ await refreshGovernance();
7612
+ }
7537
7613
 
7538
7614
  function initAiConsole() {
7539
7615
  if (_aiConsoleInit) { renderAiConsole(); refreshGovernance().catch(() => {}); return; }
@@ -7549,6 +7625,13 @@ HTML = r"""<!doctype html>
7549
7625
  if (card && card.dataset.govTier) setGovernanceTier(card.dataset.govTier);
7550
7626
  });
7551
7627
  }
7628
+ const govModeEl = $('aiGovMode');
7629
+ if (govModeEl) {
7630
+ govModeEl.addEventListener('click', (ev) => {
7631
+ const btn = ev.target.closest('[data-gov-mode]');
7632
+ if (btn && btn.dataset.govMode) setGovernanceMode(btn.dataset.govMode);
7633
+ });
7634
+ }
7552
7635
  refreshGovernance().catch(() => {});
7553
7636
  }
7554
7637
 
@@ -7979,7 +8062,7 @@ HTML = r"""<!doctype html>
7979
8062
  else if (view === 'shot') meta.textContent = `Shot ${state.review.currentShotIndex} of ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
7980
8063
  else if (view === 'transcript') meta.textContent = `Transcript · ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
7981
8064
  else if (view === 'combined') meta.textContent = `Combined review · ${state.review.combinedData?.clip_count || '?'} clips`;
7982
- else if (view === 'history') meta.textContent = 'Timeline history · versions and brain edits per timeline (C6)';
8065
+ else if (view === 'history') meta.textContent = 'Timeline history · archived versions and brain edits per timeline';
7983
8066
  }
7984
8067
  if (opts.writePanelState !== false) {
7985
8068
  writePanelStateAsync({
@@ -8037,24 +8120,23 @@ HTML = r"""<!doctype html>
8037
8120
  ].map(stat => `<div class="readiness-stat ${stat.kind}"><span class="stat-value">${stat.value}</span><span class="stat-label">${escapeHtml(stat.label)}</span></div>`).join('');
8038
8121
  }
8039
8122
  if (details) {
8123
+ const TRUST_LABELS = { trusted: 'Trusted source', unknown: 'Unverified source', untrusted: 'Untrusted source' };
8124
+ const LAYER_LABELS = {
8125
+ technical: 'Technical', motion: 'Motion', transcription: 'Transcript',
8126
+ vision: 'Vision', cut_analysis: 'Cut analysis', readthrough: 'Readthrough',
8127
+ };
8040
8128
  const trustChips = Object.entries(trustDist)
8041
8129
  .sort((a, b) => b[1] - a[1])
8042
- .map(([k, v]) => `<span class="chip" title="source_trust">${escapeHtml(k)}: ${v}</span>`)
8130
+ .map(([k, v]) => `<span class="chip" title="clips by source trust">${escapeHtml(TRUST_LABELS[k] || k)} · ${v}</span>`)
8043
8131
  .join('');
8044
- const layerChips = ['technical', 'motion', 'transcription', 'vision', 'cut_analysis', 'readthrough']
8045
- .map(layer => `<span class="chip" title="layer present count">${escapeHtml(layer)}: ${layers[layer] || 0}</span>`)
8132
+ const layerChips = Object.entries(LAYER_LABELS)
8133
+ .filter(([layer]) => layers[layer])
8134
+ .map(([layer, label]) => `<span class="chip" title="clips with this analysis layer">${escapeHtml(label)} · ${layers[layer]}</span>`)
8046
8135
  .join('');
8047
- details.innerHTML = `<span class="chip" style="background:none">source_trust:</span>${trustChips}<span class="chip" style="background:none">layers:</span>${layerChips}`;
8136
+ details.innerHTML = trustChips + layerChips;
8048
8137
  }
8049
8138
  }
8050
8139
 
8051
- document.addEventListener('click', (e) => {
8052
- const target = e.target;
8053
- if (target && target.id === 'readinessRefreshBtn') {
8054
- refreshReadinessCard().catch(() => {});
8055
- }
8056
- });
8057
-
8058
8140
  function populateBinFilter() {
8059
8141
  const data = state.review.clipList;
8060
8142
  const select = $('reviewBinFilter');
@@ -8097,10 +8179,16 @@ HTML = r"""<!doctype html>
8097
8179
  }
8098
8180
  const clips = filteredClips();
8099
8181
  if (!clips.length) {
8100
- if (summary) summary.textContent = state.review.binFilter
8101
- ? `No analyzed clips in bin "${state.review.binFilter}".`
8102
- : 'No analyzed clips yet — run analyze_clip first.';
8103
- if (grid) grid.innerHTML = '';
8182
+ if (state.review.binFilter) {
8183
+ if (summary) summary.textContent = `No analyzed clips in bin "${state.review.binFilter}".`;
8184
+ if (grid) grid.innerHTML = '';
8185
+ } else {
8186
+ if (summary) summary.textContent = 'Nothing analyzed yet.';
8187
+ if (grid) grid.innerHTML = chatPromptCard(
8188
+ 'Review fills in as clips are analyzed. Ask your assistant to analyze this project\u2019s media:',
8189
+ 'Analyze the source clips in my current Resolve project and build the search index.'
8190
+ );
8191
+ }
8104
8192
  return;
8105
8193
  }
8106
8194
  const selected = state.review.selectedBinClipIds;
@@ -8111,7 +8199,7 @@ HTML = r"""<!doctype html>
8111
8199
  const total = data.clips.length;
8112
8200
  const base = state.review.binFilter
8113
8201
  ? `${clips.length} of ${total} analyzed clip${total === 1 ? '' : 's'} · bin: ${state.review.binFilter}`
8114
- : `${total} analyzed clip${total === 1 ? '' : 's'} in ${data.project_root || 'this project'}.`;
8202
+ : `${total} analyzed clip${total === 1 ? '' : 's'} in this project\u2019s analysis root.`;
8115
8203
  if (selected.size > 0) {
8116
8204
  summary.innerHTML = `<span>${escapeHtml(base)}</span>
8117
8205
  <span class="bin-selection-toolbar">
@@ -9215,7 +9303,7 @@ HTML = r"""<!doctype html>
9215
9303
  const safe = value == null ? '' : String(value);
9216
9304
  return `<div class="review-notes-row" data-notes-scope="${scope}">
9217
9305
  <div class="label">Notes</div>
9218
- <textarea data-notes-input="${scope}" placeholder="Editorial notes — saved to corrections.json…">${escapeHtml(safe)}</textarea>
9306
+ <textarea data-notes-input="${scope}" placeholder="Editorial notes — saved with this clip\u2019s analysis and preserved on re-analysis\u2026">${escapeHtml(safe)}</textarea>
9219
9307
  <div class="controls">
9220
9308
  <button class="secondary" data-notes-save="${scope}" type="button">Save notes</button>
9221
9309
  <span class="saved" data-notes-saved="${scope}" style="opacity:0">Saved</span>
@@ -9402,13 +9490,21 @@ HTML = r"""<!doctype html>
9402
9490
  extrasHtml += `<div class="group"><div class="group-title">QC flags</div><div class="chip-row" style="display:flex;flex-wrap:wrap;gap:4px">${qcFlags.map(f => `<span class="review-chip">${escapeHtml(String(f))}</span>`).join('')}</div></div>`;
9403
9491
  }
9404
9492
  if (Array.isArray(shot.frame_indices_used) && shot.frame_indices_used.length) {
9405
- extrasHtml += `<div class="group"><div class="group-title">Frame indices</div><div class="value" style="font-family:var(--font-mono);font-size:12px;color:var(--text-secondary)">${escapeHtml(shot.frame_indices_used.join(', '))}</div></div>`;
9493
+ const n = shot.frame_indices_used.length;
9494
+ extrasHtml += `<div class="group"><div class="group-title">Sampled frames</div><div class="value" style="font-size:12px;color:var(--text-secondary)" title="frame indices ${escapeHtml(shot.frame_indices_used.join(', '))}">${n} frame${n === 1 ? '' : 's'} sampled from this shot (shown below)</div></div>`;
9406
9495
  }
9407
9496
  const emptyHint = !hasAnyGroup && !editing
9408
9497
  ? `<div class="empty" style="padding:var(--space-3);border:1px dashed var(--border-default);border-radius:var(--radius-md);color:var(--text-tertiary);font-size:12px">Shot-level analysis fields aren't populated in the report. The clip-level analysis blocks (above on the clip detail page) cover this clip. Re-run analysis with a fuller vision pass to fill in per-shot Visual / Content / Editorial fields.</div>`
9409
9498
  : '';
9410
9499
  $('reviewShotFields').innerHTML = groupsHtml + extrasHtml + emptyHint;
9411
9500
  const frames = data.frames || [];
9501
+ const FRAME_REASON_LABELS = {
9502
+ shot_start: 'Shot start', shot_end: 'Shot end', shot_progress: 'Mid-shot',
9503
+ shot_representative: 'Key frame', cut_after: 'After cut', cut_before: 'Before cut',
9504
+ flash_candidate: 'Flash frame', motion_peak: 'Motion peak', interval: 'Interval sample',
9505
+ scene_change: 'Scene change', first_usable: 'First usable', last_usable: 'Last usable',
9506
+ midpoint: 'Midpoint',
9507
+ };
9412
9508
  $('reviewShotFrames').innerHTML = frames.map(f => {
9413
9509
  const peak = f.motion_peak ? 'peak' : '';
9414
9510
  const src = clipId ? `/api/clips/${encodeURIComponent(clipId)}/frames/${f.frame_index}` : '';
@@ -9416,7 +9512,7 @@ HTML = r"""<!doctype html>
9416
9512
  ${src ? `<img class="review-thumb" loading="lazy" src="${src}" alt="frame ${f.frame_index}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'review-thumb placeholder',textContent:'frame missing'}))">` : ''}
9417
9513
  <div class="label">
9418
9514
  <span>${f.time_seconds != null ? `${Number(f.time_seconds).toFixed(2)}s` : `#${f.frame_index}`}</span>
9419
- <span>${escapeHtml(f.selection_reason || '')}</span>
9515
+ <span title="${escapeHtml(f.selection_reason || '')}">${escapeHtml(FRAME_REASON_LABELS[f.selection_reason] || f.selection_reason || '')}</span>
9420
9516
  </div>
9421
9517
  </div>`;
9422
9518
  }).join('');
@@ -9562,7 +9658,11 @@ HTML = r"""<!doctype html>
9562
9658
 
9563
9659
  function ensureReviewPanelStateTimer() {
9564
9660
  if (state.review.panelStateTimer) return;
9661
+ state.review.panelStateTick = 0;
9565
9662
  state.review.panelStateTimer = window.setInterval(() => {
9663
+ if (document.hidden) return;
9664
+ state.review.panelStateTick = (state.review.panelStateTick + 1) % 5;
9665
+ if (!document.hasFocus() && state.review.panelStateTick !== 0) return;
9566
9666
  if (state.activePanel === 'analysis' && state.activeSubpages.analysis === 'review') {
9567
9667
  pollPanelStateOnce().catch(() => {});
9568
9668
  }
@@ -9658,9 +9758,11 @@ HTML = r"""<!doctype html>
9658
9758
  const active = new Set((activeValues || []).map(value => String(value || '').trim()).filter(Boolean));
9659
9759
  const values = uniqueValues(candidates || [], Array.from(active));
9660
9760
  hidden.value = Array.from(active).join(', ');
9661
- container.innerHTML = values.map(value => `
9662
- <button type="button" class="token-pill${active.has(value) ? ' active' : ''}" data-token-value="${escapeHtml(value)}" aria-pressed="${active.has(value) ? 'true' : 'false'}">${escapeHtml(value)}</button>
9663
- `).join('');
9761
+ container.innerHTML = values.map(value => {
9762
+ const label = String(value).replace(/_/g, ' ');
9763
+ return `
9764
+ <button type="button" class="token-pill${active.has(value) ? ' active' : ''}" data-token-value="${escapeHtml(value)}" aria-pressed="${active.has(value) ? 'true' : 'false'}" title="${escapeHtml(value)}">${escapeHtml(label)}</button>
9765
+ `; }).join('');
9664
9766
  container.querySelectorAll('[data-token-value]').forEach(button => {
9665
9767
  button.addEventListener('click', () => {
9666
9768
  button.classList.toggle('active');
@@ -9964,14 +10066,14 @@ HTML = r"""<!doctype html>
9964
10066
  if (options.updateHash !== false) {
9965
10067
  updateRouteHash('docs', state.activeDoc);
9966
10068
  }
9967
- const rendered = renderMarkdown(payload.content || '');
10069
+ const rendered = renderMarkdown(payload.content || '', payload.path || '');
9968
10070
  setHtml('docReader', rendered.html);
9969
10071
  renderDocSectionNav(rendered.sections);
9970
10072
  setText('docSource', payload.path || '');
9971
10073
  applyDocFilters();
9972
10074
  }
9973
10075
 
9974
- function renderMarkdown(markdown) {
10076
+ function renderMarkdown(markdown, docPath) {
9975
10077
  const lines = String(markdown || '').split(/\r?\n/);
9976
10078
  const parts = [];
9977
10079
  const sections = [];
@@ -10000,7 +10102,9 @@ HTML = r"""<!doctype html>
10000
10102
  const line = rawLine.trimEnd();
10001
10103
  if (!line.trim()) continue;
10002
10104
  const badgeMatches = parseBadgeTokens(line.trim());
10003
- if (badgeMatches.length) {
10105
+ // Badge rows are external status shields; a linked LOCAL image (e.g.
10106
+ // the README's control-panel screenshot) must reach the image handlers.
10107
+ if (badgeMatches.length && badgeMatches.every(token => /^https?:/i.test(token.src))) {
10004
10108
  while (index + 1 < lines.length) {
10005
10109
  const nextMatches = parseBadgeTokens(lines[index + 1].trim());
10006
10110
  if (!nextMatches.length) break;
@@ -10029,9 +10133,14 @@ HTML = r"""<!doctype html>
10029
10133
  parts.push(`<h${level} id="${id}" class="doc-block" data-md-type="heading">${inlineMarkdown(heading[2])}</h${level}>`);
10030
10134
  continue;
10031
10135
  }
10136
+ const linkedImage = line.match(/^\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)$/);
10137
+ if (linkedImage) {
10138
+ parts.push(renderImageBlock(linkedImage[1], linkedImage[2], docPath));
10139
+ continue;
10140
+ }
10032
10141
  const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
10033
10142
  if (image) {
10034
- parts.push(renderImageBlock(image[1], image[2]));
10143
+ parts.push(renderImageBlock(image[1], image[2], docPath));
10035
10144
  continue;
10036
10145
  }
10037
10146
  const bullet = line.match(/^[-*]\s+(.+)$/) || line.match(/^\d+\.\s+(.+)$/);
@@ -10099,8 +10208,27 @@ HTML = r"""<!doctype html>
10099
10208
  return tokens;
10100
10209
  }
10101
10210
 
10102
- function renderImageBlock(alt, src) {
10103
- return `<figure class="doc-image doc-block" data-md-type="image"><img src="${escapeAttribute(src)}" alt="${escapeAttribute(alt || src)}" loading="lazy"></figure>`;
10211
+ function resolveDocAsset(src, docPath) {
10212
+ const clean = String(src || '').trim();
10213
+ if (/^(https?:|data:|\/)/i.test(clean)) return clean;
10214
+ const stack = [];
10215
+ const parts = String(docPath || '').split('/').slice(0, -1).concat(clean.split('/'));
10216
+ for (const part of parts) {
10217
+ if (!part || part === '.') continue;
10218
+ if (part === '..') { stack.pop(); continue; }
10219
+ stack.push(part);
10220
+ }
10221
+ const rel = stack.join('/');
10222
+ const marker = 'docs/images/';
10223
+ if (rel.startsWith(marker)) {
10224
+ return '/api/doc_asset/' + rel.slice(marker.length).split('/').map(encodeURIComponent).join('/');
10225
+ }
10226
+ return clean;
10227
+ }
10228
+
10229
+ function renderImageBlock(alt, src, docPath) {
10230
+ const resolved = resolveDocAsset(src, docPath);
10231
+ return `<figure class="doc-image doc-block" data-md-type="image"><img src="${escapeAttribute(resolved)}" alt="${escapeAttribute(alt || src)}" loading="lazy"></figure>`;
10104
10232
  }
10105
10233
 
10106
10234
  function renderMarkdownTable(lines) {
@@ -10280,6 +10408,13 @@ HTML = r"""<!doctype html>
10280
10408
  closeNavDropdowns();
10281
10409
  closeActionMenus();
10282
10410
  setPanel(control.dataset.panelTarget, { subpage: control.dataset.subpageTarget });
10411
+ if (control.dataset.reviewView === 'history') {
10412
+ reviewSetView('history');
10413
+ refreshHistoryTimelines().catch(alertError);
10414
+ if (window.location.hash !== '#analysis/review/history') {
10415
+ window.history.replaceState(null, '', '#analysis/review/history');
10416
+ }
10417
+ }
10283
10418
  });
10284
10419
  });
10285
10420
  document.querySelectorAll('[data-doc]').forEach(control => {
@@ -10456,7 +10591,7 @@ HTML = r"""<!doctype html>
10456
10591
  refreshRestartBanner().catch(() => {});
10457
10592
  // Poll for restart marker every 30s
10458
10593
  if (state.updates.restartTimer) clearInterval(state.updates.restartTimer);
10459
- state.updates.restartTimer = setInterval(() => refreshRestartBanner().catch(() => {}), 30000);
10594
+ state.updates.restartTimer = setInterval(() => { if (!document.hidden) refreshRestartBanner().catch(() => {}); }, 30000);
10460
10595
 
10461
10596
  // Auto-save preference (server-side preference; reads via /api/setup/defaults)
10462
10597
  $('prefAutoSaveAfterArchive')?.addEventListener('change', async e => {
@@ -13972,9 +14107,13 @@ class Handler(BaseHTTPRequestHandler):
13972
14107
  raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
13973
14108
  etag = '"' + hashlib.md5(raw).hexdigest() + '"'
13974
14109
  if self.headers.get("If-None-Match") == etag:
13975
- self.send_response(HTTPStatus.NOT_MODIFIED)
14110
+ tiny = json.dumps({"success": True, "unchanged": True, "etag": etag}).encode("utf-8")
14111
+ self.send_response(200)
14112
+ self.send_header("Content-Type", "application/json; charset=utf-8")
14113
+ self.send_header("Content-Length", str(len(tiny)))
13976
14114
  self.send_header("ETag", etag)
13977
14115
  self.end_headers()
14116
+ self.wfile.write(tiny)
13978
14117
  return
13979
14118
  self.send_response(200)
13980
14119
  self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -14105,6 +14244,21 @@ class Handler(BaseHTTPRequestHandler):
14105
14244
  payload = _dashboard_doc(doc)
14106
14245
  self._json(payload, 200 if payload.get("success") else 404)
14107
14246
  return
14247
+ if path.startswith("/api/doc_asset/"):
14248
+ rel = unquote(path[len("/api/doc_asset/"):])
14249
+ base = os.path.realpath(os.path.join(_repo_root(), "docs", "images"))
14250
+ full = os.path.realpath(os.path.join(base, rel))
14251
+ if not (full.startswith(base + os.sep) or full == base):
14252
+ self._json({"success": False, "error": "path escape"}, HTTPStatus.FORBIDDEN)
14253
+ return
14254
+ content_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
14255
+ ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp"}
14256
+ ext = os.path.splitext(full)[1].lower()
14257
+ if ext not in content_types or not os.path.isfile(full):
14258
+ self._json({"success": False, "error": "not found"}, HTTPStatus.NOT_FOUND)
14259
+ return
14260
+ self._serve_file(full, content_types[ext])
14261
+ return
14108
14262
  if path == "/api/setup/schema":
14109
14263
  self._json(_setup_defaults("schema"))
14110
14264
  return
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.39.0"
83
+ VERSION = "2.40.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.39.0"
14
+ VERSION = "2.40.0"
15
15
 
16
16
  import base64
17
17
  import os