Skip to content

Commit eb7de8b

Browse files
committed
v1.10.4: expandable 'Why this suggestion?' on Assistant cards (P)
Each Assistant suggestion can now expose the signals behind it. Clicking 'Why this suggestion?' expands a collapsible table showing: - For gap-based suggestions: the top 5 of N gaps with track name, start → end timecode, and duration. - For other suggestions: the flat stats dict (total_gaps, min_threshold_sec, etc.). Turns the assistant from AI-black-box into an explainable editor — users can see why OpenCut is recommending an action before deciding. Backend: - core/assistant.py: _count_gaps now returns (count, [top_5_details]) so the sample gaps ride along in the suggestion payload. silence-dead-air suggestions get a 'details' dict. Frontend: - _assistantDetailView(sug) renders the details as either a three- column row-table (track | time range | duration) or a generic key/value list. Plain <details>/<summary> with CSS-replaced disclosure triangle — zero JS for the expand/collapse behavior.
1 parent fbba713 commit eb7de8b

File tree

3 files changed

+169
-8
lines changed

3 files changed

+169
-8
lines changed

extension/com.opencut.panel/client/main.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6793,6 +6793,56 @@
67936793
el.assistantBody.appendChild(frag);
67946794
}
67956795

6796+
function _assistantDetailView(sug) {
6797+
var det = sug.details || {};
6798+
var wrap = document.createElement("div");
6799+
wrap.className = "assistant-detail-body";
6800+
6801+
// Gap-based suggestion: render a small table of the top few gaps.
6802+
if (Array.isArray(det.gaps) && det.gaps.length) {
6803+
var intro = document.createElement("div");
6804+
intro.className = "assistant-detail-intro";
6805+
intro.textContent = "Top " + det.gaps.length + " of " +
6806+
(det.total_gaps || det.gaps.length) + " gaps above " +
6807+
(det.min_threshold_sec || 0.8) + "s:";
6808+
wrap.appendChild(intro);
6809+
6810+
var tbl = document.createElement("div");
6811+
tbl.className = "assistant-detail-table";
6812+
for (var i = 0; i < det.gaps.length; i++) {
6813+
var g = det.gaps[i];
6814+
var row = document.createElement("div");
6815+
row.className = "assistant-detail-row";
6816+
row.innerHTML =
6817+
'<span class="assistant-detail-cell">' +
6818+
esc(g.track || "audio") + '</span>' +
6819+
'<span class="assistant-detail-cell assistant-detail-time">' +
6820+
fmtDur(g.start) + " → " + fmtDur(g.end) + '</span>' +
6821+
'<span class="assistant-detail-cell assistant-detail-metric">' +
6822+
(g.duration || 0).toFixed(2) + 's' + '</span>';
6823+
tbl.appendChild(row);
6824+
}
6825+
wrap.appendChild(tbl);
6826+
return wrap;
6827+
}
6828+
6829+
// Generic dict: render key/value pairs.
6830+
var keys = Object.keys(det);
6831+
if (!keys.length) return null;
6832+
for (var k = 0; k < keys.length; k++) {
6833+
var key = keys[k];
6834+
var val = det[key];
6835+
if (typeof val === "object") continue;
6836+
var line = document.createElement("div");
6837+
line.className = "assistant-detail-row";
6838+
line.innerHTML =
6839+
'<span class="assistant-detail-cell">' + esc(key.replace(/_/g, " ")) + '</span>' +
6840+
'<span class="assistant-detail-cell assistant-detail-metric">' + esc(String(val)) + '</span>';
6841+
wrap.appendChild(line);
6842+
}
6843+
return wrap.children.length ? wrap : null;
6844+
}
6845+
67966846
function _assistantCard(sug) {
67976847
var card = document.createElement("div");
67986848
card.className = "assistant-suggestion";
@@ -6808,6 +6858,20 @@
68086858
why.textContent = sug.why;
68096859
card.appendChild(why);
68106860

6861+
// v1.10.4 (P): expandable details with the actual signals so the
6862+
// assistant is explainable instead of opaque.
6863+
if (sug.details && typeof sug.details === "object") {
6864+
var detailsBlock = document.createElement("details");
6865+
detailsBlock.className = "assistant-suggestion-details";
6866+
var summary = document.createElement("summary");
6867+
summary.className = "assistant-suggestion-details-summary";
6868+
summary.textContent = "Why this suggestion?";
6869+
detailsBlock.appendChild(summary);
6870+
var inner = _assistantDetailView(sug);
6871+
if (inner) detailsBlock.appendChild(inner);
6872+
card.appendChild(detailsBlock);
6873+
}
6874+
68116875
var actions = document.createElement("div");
68126876
actions.className = "assistant-suggestion-actions";
68136877

extension/com.opencut.panel/client/style.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13115,3 +13115,85 @@ body:not(.has-clip) .media-sidecar .file-info {
1311513115
gap: 6px;
1311613116
margin-top: 4px;
1311713117
}
13118+
13119+
/* ============================================================
13120+
ASSISTANT CARD DETAILS (v1.10.4, feature P)
13121+
============================================================ */
13122+
.assistant-suggestion-details {
13123+
border-top: 1px dashed rgba(236, 230, 218, 0.08);
13124+
padding-top: 6px;
13125+
margin-top: 4px;
13126+
}
13127+
13128+
.assistant-suggestion-details-summary {
13129+
font-size: 11px;
13130+
font-weight: 600;
13131+
color: var(--neon-cyan, #88b2ff);
13132+
cursor: pointer;
13133+
list-style: none;
13134+
padding: 4px 0;
13135+
user-select: none;
13136+
}
13137+
13138+
.assistant-suggestion-details-summary::-webkit-details-marker { display: none; }
13139+
.assistant-suggestion-details-summary::marker { content: ""; }
13140+
13141+
.assistant-suggestion-details-summary::before {
13142+
content: "▸ ";
13143+
display: inline-block;
13144+
transition: transform 0.15s ease;
13145+
color: rgba(136, 178, 255, 0.7);
13146+
}
13147+
13148+
.assistant-suggestion-details[open] .assistant-suggestion-details-summary::before {
13149+
transform: rotate(90deg);
13150+
}
13151+
13152+
.assistant-detail-body {
13153+
margin-top: 8px;
13154+
display: flex;
13155+
flex-direction: column;
13156+
gap: 4px;
13157+
}
13158+
13159+
.assistant-detail-intro {
13160+
font-size: 10px;
13161+
color: var(--text-secondary);
13162+
opacity: 0.8;
13163+
margin-bottom: 4px;
13164+
}
13165+
13166+
.assistant-detail-table {
13167+
display: flex;
13168+
flex-direction: column;
13169+
gap: 2px;
13170+
}
13171+
13172+
.assistant-detail-row {
13173+
display: grid;
13174+
grid-template-columns: auto 1fr auto;
13175+
gap: 10px;
13176+
align-items: center;
13177+
padding: 4px 8px;
13178+
border-radius: 6px;
13179+
background: rgba(255, 255, 255, 0.02);
13180+
font-size: 11px;
13181+
color: var(--text-primary);
13182+
}
13183+
13184+
.assistant-detail-cell {
13185+
white-space: nowrap;
13186+
overflow: hidden;
13187+
text-overflow: ellipsis;
13188+
}
13189+
13190+
.assistant-detail-time {
13191+
color: var(--text-secondary);
13192+
font-variant-numeric: tabular-nums;
13193+
}
13194+
13195+
.assistant-detail-metric {
13196+
font-weight: 600;
13197+
font-variant-numeric: tabular-nums;
13198+
color: var(--neon-cyan, #88b2ff);
13199+
}

opencut/core/assistant.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,29 +63,38 @@ def _pick_clip_path(seq_info: Dict[str, Any]) -> str:
6363
return best_path
6464

6565

66-
def _count_gaps(seq_info: Dict[str, Any], min_sec: float = 0.8) -> int:
67-
"""Count inter-clip gaps on audio tracks > *min_sec* seconds.
68-
69-
Signal for "run silence removal on this sequence".
66+
def _count_gaps(seq_info: Dict[str, Any],
67+
min_sec: float = 0.8) -> "tuple[int, list]":
68+
"""Count inter-clip gaps on audio tracks > *min_sec* seconds and
69+
return both the count and up to 5 representative gap details
70+
(``{track_name, start, end, duration}``).
7071
"""
7172
gaps = 0
73+
details: list = []
7274
for track in seq_info.get("tracks") or []:
7375
if track.get("media_type") and track["media_type"].lower() != "audio":
7476
continue
7577
clips = track.get("clips") or []
7678
if len(clips) < 2:
7779
continue
78-
# Sort by start, accepting either seconds or ticks-as-string
7980
def _s(c): return _num(c.get("start", c.get("in", 0)))
8081
sorted_clips = sorted(clips, key=_s)
8182
for i in range(1, len(sorted_clips)):
8283
prev_end = _num(sorted_clips[i - 1].get("end",
8384
_s(sorted_clips[i - 1]) +
8485
_num(sorted_clips[i - 1].get("duration"))))
8586
next_start = _s(sorted_clips[i])
86-
if next_start - prev_end > min_sec:
87+
gap_len = next_start - prev_end
88+
if gap_len > min_sec:
8789
gaps += 1
88-
return gaps
90+
if len(details) < 5:
91+
details.append({
92+
"track": track.get("name") or "",
93+
"start": round(prev_end, 2),
94+
"end": round(next_start, 2),
95+
"duration": round(gap_len, 2),
96+
})
97+
return gaps, details
8998

9099

91100
def _has_captions_track(seq_info: Dict[str, Any]) -> bool:
@@ -139,7 +148,7 @@ def analyze_sequence(seq_info: Dict[str, Any],
139148
tracks = seq_info.get("tracks") or []
140149

141150
# 1. Dead air — silence removal
142-
gaps = _count_gaps(seq_info, min_sec=0.8)
151+
gaps, gap_details = _count_gaps(seq_info, min_sec=0.8)
143152
if gaps >= 3 and "silence-dead-air" not in dismissed and clip_path:
144153
out.append({
145154
"id": "silence-dead-air",
@@ -151,6 +160,12 @@ def analyze_sequence(seq_info: Dict[str, Any],
151160
"endpoint": "/silence",
152161
"payload": {"filepath": clip_path},
153162
},
163+
"details": {
164+
"total_gaps": gaps,
165+
"shown": len(gap_details),
166+
"gaps": gap_details,
167+
"min_threshold_sec": 0.8,
168+
},
154169
})
155170

156171
# 2. No captions track present -> suggest Generate Captions

0 commit comments

Comments
 (0)