davinci-resolve-mcp 2.38.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.
@@ -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
@@ -14678,6 +14832,8 @@ def _warm_inventory_cache(project_root: str) -> None:
14678
14832
 
14679
14833
 
14680
14834
  def main() -> None:
14835
+ from src.utils import actor_identity
14836
+ actor_identity.set_instance("control-panel")
14681
14837
  args = parse_args()
14682
14838
  state = DashboardState(args.project_name, args.project_id, args.analysis_root)
14683
14839
  Handler.state = state
package/src/batch_cli.py CHANGED
@@ -526,6 +526,8 @@ _HANDLERS = {
526
526
 
527
527
 
528
528
  def main(argv: Optional[List[str]] = None) -> int:
529
+ from src.utils import actor_identity
530
+ actor_identity.set_instance("batch-cli")
529
531
  parser = _build_parser()
530
532
  args = parser.parse_args(argv)
531
533
  if not hasattr(args, "json"):
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.38.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()}")