davinci-resolve-mcp 2.46.1 → 2.48.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 +51 -0
- package/README.md +1 -1
- package/docs/SKILL.md +18 -1
- package/docs/guides/control-panel.md +25 -4
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/regen_panel_screenshots.py +1 -0
- package/src/analysis_dashboard.py +432 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +177 -4
- package/src/utils/edit_engine.py +9 -1
- package/src/utils/timeline_versioning.py +84 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,57 @@
|
|
|
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.48.0
|
|
6
|
+
|
|
7
|
+
Edit-engine hardening: trustworthy execute readback ahead of the first
|
|
8
|
+
real-cut pilot. Live-validated end-to-end on a disposable synthetic-media
|
|
9
|
+
project (24/24 checks).
|
|
10
|
+
|
|
11
|
+
- **Added** `timeline_versioning(action="diff_timelines", params={from_timeline,
|
|
12
|
+
to_timeline})`: structural diff (added/removed/moved/trimmed + summary)
|
|
13
|
+
between two LIVE timelines by name — read-only, no archived snapshots
|
|
14
|
+
needed. Built for edit-engine variants, which are new-name timelines with
|
|
15
|
+
no shared version chain. The item walk and the snapshot comparison were
|
|
16
|
+
factored out of the version-snapshot path
|
|
17
|
+
(`capture_timeline_clip_usage` / `compare_usage_snapshots`) and reused.
|
|
18
|
+
- **Fixed** `execute_swap` audio accounting: the lift was video-only while
|
|
19
|
+
the replacement appended linked video+audio, so item counts drifted on
|
|
20
|
+
every swap. The lift is now scoped to the target's video track plus its
|
|
21
|
+
linked audio tracks (`GetLinkedItems`, with a media-id track-scan fallback;
|
|
22
|
+
items with no linked audio and audio-only timelines are handled
|
|
23
|
+
gracefully), and readback reports per-track-type `track_counts`
|
|
24
|
+
before/after plus an `audio_accounting` block.
|
|
25
|
+
- **Added** `execute_tighten` readback now includes `structural_diff`
|
|
26
|
+
(source vs variant); `execute_selects` readback includes a
|
|
27
|
+
`usage_summary` (per-track-type item counts — a diff against a source
|
|
28
|
+
timeline is meaningless for a fresh assembly).
|
|
29
|
+
- **Changed** the live edit-engine validation harness asserts the tighten
|
|
30
|
+
structural diff, `diff_timelines` agreement, and swap track-count
|
|
31
|
+
symmetry (24 checks, up from 20).
|
|
32
|
+
|
|
33
|
+
## What's New in v2.47.0
|
|
34
|
+
|
|
35
|
+
Edit-engine plan browser in the control panel (Media → Edit Plans) — the
|
|
36
|
+
panel UX pass for the v2.45.0 edit engine. Chat-first: the panel surfaces
|
|
37
|
+
plans and evidence; execution stays in chat behind the confirm-token gate.
|
|
38
|
+
|
|
39
|
+
- **Added** a plan browser at `#analysis/review/plans`: every saved
|
|
40
|
+
`edit_engine` plan with kind, summary, save time, and an `executed` chip;
|
|
41
|
+
fingerprint-corrupt plans surface as warning rows instead of being hidden.
|
|
42
|
+
- **Added** plan detail views per kind: selects decisions render with shot
|
|
43
|
+
thumbnails, rank, duration, rationale, and a deep link to the shot page;
|
|
44
|
+
tighten plans list each dead-air lift with its transcript-gap evidence and
|
|
45
|
+
skipped items; swap plans show the current item plus numbered alternates
|
|
46
|
+
with similarity scores. Executed plans show their execution readback.
|
|
47
|
+
- **Added** a copyable per-kind execute chat prompt on each plan (the panel
|
|
48
|
+
never executes; swaps include an `alternate_index` placeholder).
|
|
49
|
+
- **Added** `/api/edit_plans` + `/api/edit_plans/<plan_id>` panel endpoints
|
|
50
|
+
(DB/file only — no Resolve round-trips; decisions are enriched server-side
|
|
51
|
+
with `resolve_clip_id` and a `thumb_frame_index` for the existing frames
|
|
52
|
+
route).
|
|
53
|
+
- **Changed** `edit_engine.list_plans` gained `include_corrupt` (default off;
|
|
54
|
+
the MCP action shape is unchanged).
|
|
55
|
+
|
|
5
56
|
## What's New in v2.46.1
|
|
6
57
|
|
|
7
58
|
Test and hygiene hardening; no public tool surface changes.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -699,13 +699,25 @@ clip-count readback plus `brain_edits` rationale rows.
|
|
|
699
699
|
filtered to shots long enough to fill the slot exactly.
|
|
700
700
|
`execute_swap(plan_id, alternate_index)` replaces the item in place
|
|
701
701
|
(lift + positioned append at the same record frame) on the
|
|
702
|
-
version-archived timeline.
|
|
702
|
+
version-archived timeline. v2.48.0+: the lift is scoped to the target's
|
|
703
|
+
video track plus its linked audio tracks (GetLinkedItems with a
|
|
704
|
+
media-id fallback), and `readback` carries per-track-type
|
|
705
|
+
`track_counts` plus an `audio_accounting` block so swap symmetry is
|
|
706
|
+
verifiable. `execute_tighten` readback gains `structural_diff` (source
|
|
707
|
+
vs variant, via the same engine as `diff_timelines`); `execute_selects`
|
|
708
|
+
readback gains a `usage_summary`.
|
|
703
709
|
- `list_plans(limit?)` / `get_plan(plan_id)`.
|
|
704
710
|
|
|
705
711
|
The engine needs the analysis substrate: analyzed clips in the DB (run
|
|
706
712
|
`db_ingest` on older roots), transcripts for tighten, and visual embeddings
|
|
707
713
|
(`build_embeddings(kinds=['visual'])`) for swap.
|
|
708
714
|
|
|
715
|
+
Plans are reviewable in the control panel (Media → Edit Plans, v2.47.0+):
|
|
716
|
+
decisions/lifts/alternates with thumbnails and rationale, deep links to shot
|
|
717
|
+
pages, and a copyable per-kind execute prompt. The panel never executes —
|
|
718
|
+
when the user says they reviewed a plan there, execution still comes back
|
|
719
|
+
through chat with the confirm-token gate.
|
|
720
|
+
|
|
709
721
|
**`timeline_versioning`** — Version-on-mutate, archive, rollback, brain-edit history (C6).
|
|
710
722
|
|
|
711
723
|
Every destructive timeline op (compound, captions, ripple delete, gap close,
|
|
@@ -734,6 +746,11 @@ Key actions:
|
|
|
734
746
|
lists clips kept in place but re-trimmed (carries `out_frame_before`); `summary`
|
|
735
747
|
has per-bucket counts plus `before_clip_count`/`after_clip_count`. Clips are
|
|
736
748
|
keyed by media_pool_item_id and timeline position.
|
|
749
|
+
- `diff_timelines(from_timeline, to_timeline)` (v2.48.0+) — the same
|
|
750
|
+
structural diff between two LIVE timelines by NAME, read-only, no archived
|
|
751
|
+
snapshots needed. Built for edit-engine variants (tighten/selects produce
|
|
752
|
+
new-name timelines with no shared version chain). For unrelated timelines
|
|
753
|
+
everything reports as added/removed.
|
|
737
754
|
- `get_history(timeline_name?, analysis_run_id?, limit?)` — brain-edit rows
|
|
738
755
|
with `edit_type`, `target_metric`, `before_value`, `after_value`, `delta`,
|
|
739
756
|
`rationale`, and `initiator`. Filter by timeline or run; defaults to 50.
|
|
@@ -26,10 +26,10 @@ pidfile and `resolve_control(action="close_control_panel")` stops it.
|
|
|
26
26
|
|
|
27
27
|
## Navigation and deep links
|
|
28
28
|
|
|
29
|
-
Top-level sections: **Overview**, **Media** (Inventory / Review / History
|
|
30
|
-
**AI Console**, **Setup** (Resolve / MCP / Storage / Tools /
|
|
31
|
-
History), **Docs**, and **Preferences** (Analysis / Caps + Safety /
|
|
32
|
-
and Markers / Paths and Workflow / MCP Updates). The project selector on the
|
|
29
|
+
Top-level sections: **Overview**, **Media** (Inventory / Review / History /
|
|
30
|
+
Edit Plans), **AI Console**, **Setup** (Resolve / MCP / Storage / Tools /
|
|
31
|
+
Media Pool History), **Docs**, and **Preferences** (Analysis / Caps + Safety /
|
|
32
|
+
Metadata and Markers / Paths and Workflow / MCP Updates). The project selector on the
|
|
33
33
|
right scopes the panel to an analysis context; **View All Projects** opens a
|
|
34
34
|
read-only browser over the Resolve project database with confirm-gated
|
|
35
35
|
loading.
|
|
@@ -42,6 +42,7 @@ into chat:
|
|
|
42
42
|
#analysis/media #analysis/review
|
|
43
43
|
#analysis/review/history #analysis/review/clip/<clip_id>
|
|
44
44
|
#analysis/review/clip/<clip_id>/shot/<n> #analysis/review/clip/<clip_id>/transcript
|
|
45
|
+
#analysis/review/plans #analysis/review/plans/<plan_id>
|
|
45
46
|
#diagnostics/mcp #preferences/caps
|
|
46
47
|
```
|
|
47
48
|
|
|
@@ -131,6 +132,26 @@ timeline before risky work, and select any timeline to inspect its version
|
|
|
131
132
|
chain and the edits between versions. Every destructive timeline edit made
|
|
132
133
|
through the MCP archives a version here automatically.
|
|
133
134
|
|
|
135
|
+
### Media → Edit Plans
|
|
136
|
+
|
|
137
|
+

|
|
138
|
+
|
|
139
|
+
Dry-run plans saved by the edit engine (`edit_engine` actions
|
|
140
|
+
`plan_selects` / `plan_tighten` / `plan_swap`). The list shows each plan's
|
|
141
|
+
kind, summary, save time, and an `executed` chip once it has run; plans that
|
|
142
|
+
fail their fingerprint check appear as a warning row instead of being hidden.
|
|
143
|
+
Opening a plan shows its full evidence: selects decisions render with shot
|
|
144
|
+
thumbnails, rank, duration, and rationale (with a deep link to each shot
|
|
145
|
+
page); tighten plans list every dead-air lift with its transcript-gap
|
|
146
|
+
evidence; swap plans show the current item and each numbered alternate with
|
|
147
|
+
its similarity score.
|
|
148
|
+
|
|
149
|
+
The panel never executes a plan. Each detail view carries a copyable chat
|
|
150
|
+
prompt (per kind, with the `alternate_index` placeholder for swaps) — paste
|
|
151
|
+
it into your MCP chat session, which holds the confirm-token gate and the
|
|
152
|
+
versioned execution path. Executed plans keep their execution readback
|
|
153
|
+
inline.
|
|
154
|
+
|
|
134
155
|
### AI Console
|
|
135
156
|
|
|
136
157
|

|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.48.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
|
@@ -57,6 +57,7 @@ def main() -> int:
|
|
|
57
57
|
("#preferences/analysis", "08-preferences-analysis.png", 3000),
|
|
58
58
|
("#aiconsole", "09-ai-console.png", 3000),
|
|
59
59
|
("#analysis/review/history", "10-history.png", 3500),
|
|
60
|
+
("#analysis/review/plans", "11-edit-plans.png", 3500),
|
|
60
61
|
]
|
|
61
62
|
with sync_playwright() as pw:
|
|
62
63
|
browser = pw.chromium.launch()
|
|
@@ -2379,6 +2379,84 @@ HTML = r"""<!doctype html>
|
|
|
2379
2379
|
}
|
|
2380
2380
|
.history-rollback-btn:hover { background: var(--accent-warning); color: var(--bg-base); }
|
|
2381
2381
|
|
|
2382
|
+
/* ─── Edit-engine plan browser ──────────────────────────────────── */
|
|
2383
|
+
.plans-toolbar {
|
|
2384
|
+
display: flex;
|
|
2385
|
+
align-items: center;
|
|
2386
|
+
justify-content: space-between;
|
|
2387
|
+
gap: var(--space-3);
|
|
2388
|
+
margin-bottom: var(--space-3);
|
|
2389
|
+
}
|
|
2390
|
+
.plans-toolbar .section-meta { margin-left: var(--space-2); }
|
|
2391
|
+
.plans-list { display: grid; gap: var(--space-2); }
|
|
2392
|
+
.plan-row {
|
|
2393
|
+
display: flex;
|
|
2394
|
+
align-items: center;
|
|
2395
|
+
gap: var(--space-3);
|
|
2396
|
+
padding: var(--space-2) var(--space-3);
|
|
2397
|
+
border: 1px solid var(--border-default);
|
|
2398
|
+
border-radius: var(--radius-md);
|
|
2399
|
+
background: var(--lab-panel-elevated, var(--bg-base));
|
|
2400
|
+
cursor: pointer;
|
|
2401
|
+
}
|
|
2402
|
+
.plan-row:hover { background: var(--bg-muted); }
|
|
2403
|
+
.plan-row.is-corrupt {
|
|
2404
|
+
cursor: default;
|
|
2405
|
+
border-color: var(--accent-warning);
|
|
2406
|
+
background: var(--accent-warning-muted);
|
|
2407
|
+
}
|
|
2408
|
+
.plan-row .summary { flex: 1; min-width: 0; }
|
|
2409
|
+
.plan-row .saved-at { color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }
|
|
2410
|
+
.plan-chip {
|
|
2411
|
+
display: inline-block;
|
|
2412
|
+
padding: 1px var(--space-2);
|
|
2413
|
+
border-radius: var(--radius-sm);
|
|
2414
|
+
font-size: var(--text-xs);
|
|
2415
|
+
font-weight: 600;
|
|
2416
|
+
text-transform: uppercase;
|
|
2417
|
+
letter-spacing: 0.04em;
|
|
2418
|
+
border: 1px solid var(--border-default);
|
|
2419
|
+
color: var(--text-secondary);
|
|
2420
|
+
white-space: nowrap;
|
|
2421
|
+
}
|
|
2422
|
+
.plan-chip.kind-selects { color: var(--accent-success); border-color: var(--accent-success); }
|
|
2423
|
+
.plan-chip.kind-tighten { color: var(--accent-warning); border-color: var(--accent-warning); }
|
|
2424
|
+
.plan-chip.kind-swap { color: var(--accent-info, var(--text-secondary)); border-color: currentColor; }
|
|
2425
|
+
.plan-chip.executed { color: var(--text-muted); text-transform: none; letter-spacing: 0; }
|
|
2426
|
+
.plan-chip.corrupt { color: var(--accent-warning); border-color: var(--accent-warning); }
|
|
2427
|
+
.plan-detail-header {
|
|
2428
|
+
display: flex;
|
|
2429
|
+
align-items: center;
|
|
2430
|
+
flex-wrap: wrap;
|
|
2431
|
+
gap: var(--space-2);
|
|
2432
|
+
margin-bottom: var(--space-3);
|
|
2433
|
+
}
|
|
2434
|
+
.plan-detail-header .timeline-name { font-weight: 600; font-size: var(--text-md); }
|
|
2435
|
+
.plan-detail-body { display: grid; gap: var(--space-3); }
|
|
2436
|
+
.plan-section {
|
|
2437
|
+
padding: var(--space-3);
|
|
2438
|
+
border: 1px solid var(--border-default);
|
|
2439
|
+
border-radius: var(--radius-md);
|
|
2440
|
+
background: var(--lab-panel-elevated, var(--bg-base));
|
|
2441
|
+
}
|
|
2442
|
+
.plan-section > strong { display: block; margin-bottom: var(--space-2); }
|
|
2443
|
+
.plan-decision-card {
|
|
2444
|
+
display: flex;
|
|
2445
|
+
gap: var(--space-3);
|
|
2446
|
+
padding: var(--space-2) 0;
|
|
2447
|
+
border-bottom: 1px solid var(--border-default);
|
|
2448
|
+
}
|
|
2449
|
+
.plan-decision-card:last-child { border-bottom: none; }
|
|
2450
|
+
.plan-decision-card .review-thumb { width: 120px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); flex: none; }
|
|
2451
|
+
.plan-decision-card .review-thumb.placeholder {
|
|
2452
|
+
display: flex; align-items: center; justify-content: center;
|
|
2453
|
+
background: var(--bg-muted); color: var(--text-muted); font-size: var(--text-xs);
|
|
2454
|
+
}
|
|
2455
|
+
.plan-decision-card .body { flex: 1; min-width: 0; display: grid; gap: 2px; align-content: start; }
|
|
2456
|
+
.plan-decision-card .title-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
|
|
2457
|
+
.plan-decision-card .rationale { color: var(--text-secondary); font-size: var(--text-xs); }
|
|
2458
|
+
.plan-decision-card .meta { color: var(--text-muted); font-size: var(--text-xs); }
|
|
2459
|
+
|
|
2382
2460
|
/* ─── Analysis caps preferences widget ──────────────────────────── */
|
|
2383
2461
|
.caps-section {
|
|
2384
2462
|
margin-top: var(--space-4);
|
|
@@ -3847,6 +3925,7 @@ HTML = r"""<!doctype html>
|
|
|
3847
3925
|
<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>
|
|
3848
3926
|
<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>
|
|
3849
3927
|
<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>
|
|
3928
|
+
<button class="nav-dropdown-item" id="navPlansItem" data-panel-target="analysis" data-subpage-target="review" data-review-view="plans" 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="8" y="2" width="8" height="4" rx="1"></rect><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M9 12h6"></path><path d="M9 16h6"></path></svg></span>Edit Plans</button>
|
|
3850
3929
|
</div>
|
|
3851
3930
|
</div>
|
|
3852
3931
|
<button class="control-tab" data-panel-target="aiconsole">AI Console</button>
|
|
@@ -4192,6 +4271,20 @@ HTML = r"""<!doctype html>
|
|
|
4192
4271
|
</section>
|
|
4193
4272
|
</div>
|
|
4194
4273
|
</div>
|
|
4274
|
+
<div id="reviewPlansView" style="display:none">
|
|
4275
|
+
<div class="plans-toolbar">
|
|
4276
|
+
<div>
|
|
4277
|
+
<strong>Edit plans</strong>
|
|
4278
|
+
<span class="section-meta">Dry-run plans saved by the edit engine — review here, execute from chat.</span>
|
|
4279
|
+
</div>
|
|
4280
|
+
<button id="plansRefreshBtn" class="secondary" type="button">Refresh</button>
|
|
4281
|
+
</div>
|
|
4282
|
+
<div id="plansListBody" class="plans-list"><div class="empty">Loading…</div></div>
|
|
4283
|
+
</div>
|
|
4284
|
+
<div id="reviewPlanView" style="display:none">
|
|
4285
|
+
<div id="planDetailHeader" class="plan-detail-header"></div>
|
|
4286
|
+
<div id="planDetailBody" class="plan-detail-body"></div>
|
|
4287
|
+
</div>
|
|
4195
4288
|
</section>
|
|
4196
4289
|
</main>
|
|
4197
4290
|
|
|
@@ -5160,6 +5253,10 @@ HTML = r"""<!doctype html>
|
|
|
5160
5253
|
if (r.view === 'combined' && Array.isArray(r.combinedClipIds) && r.combinedClipIds.length) {
|
|
5161
5254
|
return `/combined/${r.combinedClipIds.map(encodeURIComponent).join(',')}`;
|
|
5162
5255
|
}
|
|
5256
|
+
if (r.view === 'plan' && state.plans?.currentPlanId) {
|
|
5257
|
+
return `/plans/${encodeURIComponent(state.plans.currentPlanId)}`;
|
|
5258
|
+
}
|
|
5259
|
+
if (r.view === 'plans') return '/plans';
|
|
5163
5260
|
return '';
|
|
5164
5261
|
}
|
|
5165
5262
|
|
|
@@ -5191,6 +5288,15 @@ HTML = r"""<!doctype html>
|
|
|
5191
5288
|
reviewSetView('history');
|
|
5192
5289
|
refreshHistoryTimelines().catch(alertError);
|
|
5193
5290
|
}
|
|
5291
|
+
// Deep links: #analysis/review/plans[/<plan_id>]
|
|
5292
|
+
if (panelName === 'analysis' && subpage === 'review' && route[2] === 'plans') {
|
|
5293
|
+
if (route[3]) {
|
|
5294
|
+
openEditPlan(decodeURIComponent(route[3]), { pushHash: false }).catch(alertError);
|
|
5295
|
+
} else {
|
|
5296
|
+
reviewSetView('plans', { pushHash: false });
|
|
5297
|
+
refreshEditPlans().catch(alertError);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5194
5300
|
// Deep links: #analysis/review/combined/<id1,id2,...>
|
|
5195
5301
|
if (panelName === 'analysis' && subpage === 'review' && route[2] === 'combined' && route[3]) {
|
|
5196
5302
|
const ids = decodeURIComponent(route[3]).split(',').filter(Boolean);
|
|
@@ -7210,6 +7316,229 @@ HTML = r"""<!doctype html>
|
|
|
7210
7316
|
await refreshHistoryTimelines();
|
|
7211
7317
|
}
|
|
7212
7318
|
|
|
7319
|
+
// ─── Edit-engine plan browser (chat-first: the panel surfaces plans
|
|
7320
|
+
// and evidence; execution happens via copyable chat prompts only) ─
|
|
7321
|
+
state.plans = state.plans || { list: null, currentPlanId: null, payload: null };
|
|
7322
|
+
|
|
7323
|
+
const PLAN_KIND_LABELS = { selects: 'Selects', tighten: 'Tighten', swap: 'Swap' };
|
|
7324
|
+
|
|
7325
|
+
function planExecutePrompt(plan) {
|
|
7326
|
+
const id = plan.plan_id;
|
|
7327
|
+
if (plan.kind === 'selects') {
|
|
7328
|
+
return `Execute edit plan ${id}: call edit_engine(action="execute_selects", params={"plan_id": "${id}"}). It creates a NEW selects timeline; nothing existing is touched. Show me the readback when done.`;
|
|
7329
|
+
}
|
|
7330
|
+
if (plan.kind === 'tighten') {
|
|
7331
|
+
return `Execute edit plan ${id}: call edit_engine(action="execute_tighten", params={"plan_id": "${id}"}). It assembles a tightened VARIANT timeline from the keep ranges; the original timeline is never mutated. Show me the readback when done.`;
|
|
7332
|
+
}
|
|
7333
|
+
if (plan.kind === 'swap') {
|
|
7334
|
+
return `Execute edit plan ${id} with alternate <N>: call edit_engine(action="execute_swap", params={"plan_id": "${id}", "alternate_index": <N>}) — replace <N> with the number of the alternate I picked below (0-based). The timeline is version-archived before the swap. Show me the readback when done.`;
|
|
7335
|
+
}
|
|
7336
|
+
return `Load edit plan ${id} with edit_engine(action="get_plan", params={"plan_id": "${id}"}) and walk me through it.`;
|
|
7337
|
+
}
|
|
7338
|
+
|
|
7339
|
+
function planThumb(row, altText) {
|
|
7340
|
+
const clipId = row.resolve_clip_id;
|
|
7341
|
+
const frameIndex = row.thumb_frame_index;
|
|
7342
|
+
if (clipId && frameIndex != null) {
|
|
7343
|
+
return `<img class="review-thumb" loading="lazy" src="/api/clips/${encodeURIComponent(clipId)}/frames/${frameIndex}" alt="${escapeHtml(altText)}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'review-thumb placeholder',textContent:'no frame'}))">`;
|
|
7344
|
+
}
|
|
7345
|
+
return '<div class="review-thumb placeholder">no frame</div>';
|
|
7346
|
+
}
|
|
7347
|
+
|
|
7348
|
+
async function refreshEditPlans() {
|
|
7349
|
+
const body = $('plansListBody');
|
|
7350
|
+
if (body) body.innerHTML = '<div class="empty">Loading…</div>';
|
|
7351
|
+
const data = await api('/api/edit_plans').catch(err => ({ success: false, error: String(err) }));
|
|
7352
|
+
if (!data || !data.success) {
|
|
7353
|
+
if (body) body.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
|
|
7354
|
+
return;
|
|
7355
|
+
}
|
|
7356
|
+
state.plans.list = data.plans || [];
|
|
7357
|
+
renderPlansList();
|
|
7358
|
+
}
|
|
7359
|
+
|
|
7360
|
+
function renderPlansList() {
|
|
7361
|
+
const body = $('plansListBody');
|
|
7362
|
+
if (!body) return;
|
|
7363
|
+
const plans = state.plans.list || [];
|
|
7364
|
+
if (!plans.length) {
|
|
7365
|
+
body.innerHTML = '<div class="empty">No edit plans yet. Ask your chat session to plan one, e.g. “Plan a selects reel from this bin” (edit_engine action="plan_selects").</div>';
|
|
7366
|
+
return;
|
|
7367
|
+
}
|
|
7368
|
+
body.innerHTML = plans.map(p => {
|
|
7369
|
+
if (p.corrupt) {
|
|
7370
|
+
return `<div class="plan-row is-corrupt">
|
|
7371
|
+
<span class="plan-chip corrupt">corrupt</span>
|
|
7372
|
+
<span class="summary">Plan ${escapeHtml(p.plan_id || '?')} failed its fingerprint check — it was edited on disk or truncated. It cannot be executed; re-plan to replace it.</span>
|
|
7373
|
+
</div>`;
|
|
7374
|
+
}
|
|
7375
|
+
const executed = p.executed_at
|
|
7376
|
+
? `<span class="plan-chip executed" title="${escapeHtml(p.executed_at)}">executed</span>` : '';
|
|
7377
|
+
return `<div class="plan-row" data-plan-id="${escapeHtml(p.plan_id)}" tabindex="0" role="button" aria-label="Open plan ${escapeHtml(p.plan_id)}">
|
|
7378
|
+
<span class="plan-chip kind-${escapeHtml(p.kind || '')}">${escapeHtml(PLAN_KIND_LABELS[p.kind] || p.kind || '?')}</span>
|
|
7379
|
+
<span class="summary" title="${escapeHtml(p.summary || '')}">${escapeHtml(p.summary || p.plan_id)}</span>
|
|
7380
|
+
${executed}
|
|
7381
|
+
<span class="saved-at">${escapeHtml(formatVersionDate(p.saved_at))}</span>
|
|
7382
|
+
</div>`;
|
|
7383
|
+
}).join('');
|
|
7384
|
+
body.querySelectorAll('.plan-row[data-plan-id]').forEach(row => {
|
|
7385
|
+
const open = () => openEditPlan(row.dataset.planId).catch(alertError);
|
|
7386
|
+
row.addEventListener('click', open);
|
|
7387
|
+
row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
|
|
7388
|
+
});
|
|
7389
|
+
}
|
|
7390
|
+
|
|
7391
|
+
async function openEditPlan(planId, opts = {}) {
|
|
7392
|
+
state.plans.currentPlanId = planId;
|
|
7393
|
+
reviewSetView('plan', opts);
|
|
7394
|
+
const body = $('planDetailBody');
|
|
7395
|
+
const header = $('planDetailHeader');
|
|
7396
|
+
if (body) body.innerHTML = '<div class="empty">Loading…</div>';
|
|
7397
|
+
if (header) header.innerHTML = '';
|
|
7398
|
+
const data = await api(`/api/edit_plans/${encodeURIComponent(planId)}`)
|
|
7399
|
+
.catch(err => ({ success: false, error: String(err) }));
|
|
7400
|
+
if (!data || !data.success) {
|
|
7401
|
+
if (body) body.innerHTML = `<div class="empty">Error: ${escapeHtml(data?.error || 'unknown')}</div>`;
|
|
7402
|
+
return;
|
|
7403
|
+
}
|
|
7404
|
+
state.plans.payload = data;
|
|
7405
|
+
renderPlanDetail(data);
|
|
7406
|
+
}
|
|
7407
|
+
|
|
7408
|
+
function renderPlanDetail(data) {
|
|
7409
|
+
const header = $('planDetailHeader');
|
|
7410
|
+
const body = $('planDetailBody');
|
|
7411
|
+
if (!header || !body) return;
|
|
7412
|
+
if (data.corrupt) {
|
|
7413
|
+
header.innerHTML = `<span class="plan-chip corrupt">corrupt</span><span class="timeline-name">Plan ${escapeHtml(data.plan_id || '?')}</span>`;
|
|
7414
|
+
body.innerHTML = '<div class="empty">This plan failed its fingerprint check — it was edited on disk or truncated. It cannot be executed; re-plan to replace it.</div>';
|
|
7415
|
+
return;
|
|
7416
|
+
}
|
|
7417
|
+
const plan = data.plan || {};
|
|
7418
|
+
const executed = plan.executed_at
|
|
7419
|
+
? `<span class="plan-chip executed" title="${escapeHtml(plan.executed_at)}">executed ${escapeHtml(formatVersionDate(plan.executed_at))}</span>` : '';
|
|
7420
|
+
header.innerHTML = `
|
|
7421
|
+
<span class="plan-chip kind-${escapeHtml(plan.kind || '')}">${escapeHtml(PLAN_KIND_LABELS[plan.kind] || plan.kind || '?')}</span>
|
|
7422
|
+
<span class="timeline-name">${escapeHtml(plan.timeline_name || plan.plan_id || '')}</span>
|
|
7423
|
+
${executed}
|
|
7424
|
+
<span class="saved-at" style="color:var(--text-muted);font-size:var(--text-xs)">saved ${escapeHtml(formatVersionDate(plan.saved_at))} · plan ${escapeHtml(plan.plan_id || '')}</span>`;
|
|
7425
|
+
|
|
7426
|
+
const sections = [];
|
|
7427
|
+
if (plan.summary) {
|
|
7428
|
+
sections.push(`<div class="plan-section"><strong>Summary</strong><div>${escapeHtml(plan.summary)}</div></div>`);
|
|
7429
|
+
}
|
|
7430
|
+
// Chat-first execute affordance: the panel NEVER executes. Executed
|
|
7431
|
+
// plans keep the card (re-execution of selects is harmless and swap
|
|
7432
|
+
// may target another alternate) — the chip above signals state.
|
|
7433
|
+
sections.push(`<div class="plan-section"><strong>Execute (from chat)</strong>${chatPromptCard(
|
|
7434
|
+
plan.kind === 'swap'
|
|
7435
|
+
? 'Pick an alternate below, then paste this in your chat session (fill in <N>):'
|
|
7436
|
+
: 'Paste this in your chat session to execute the plan:',
|
|
7437
|
+
planExecutePrompt(plan))}</div>`);
|
|
7438
|
+
if (plan.execution_summary) {
|
|
7439
|
+
sections.push(`<div class="plan-section"><strong>Execution readback</strong><pre style="margin:0;white-space:pre-wrap;font-size:var(--text-xs)">${escapeHtml(JSON.stringify(plan.execution_summary, null, 2))}</pre></div>`);
|
|
7440
|
+
}
|
|
7441
|
+
if (plan.settings) {
|
|
7442
|
+
const settings = Object.entries(plan.settings)
|
|
7443
|
+
.map(([k, v]) => `${escapeHtml(k)}=${escapeHtml(v == null ? '—' : String(v))}`).join(' · ');
|
|
7444
|
+
sections.push(`<div class="plan-section"><strong>Settings</strong><div style="font-size:var(--text-xs);color:var(--text-secondary)">${settings}</div></div>`);
|
|
7445
|
+
}
|
|
7446
|
+
if (plan.kind === 'selects') {
|
|
7447
|
+
sections.push(renderSelectsDecisions(plan));
|
|
7448
|
+
} else if (plan.kind === 'tighten') {
|
|
7449
|
+
sections.push(renderTightenLifts(plan));
|
|
7450
|
+
} else if (plan.kind === 'swap') {
|
|
7451
|
+
sections.push(renderSwapAlternates(plan));
|
|
7452
|
+
}
|
|
7453
|
+
body.innerHTML = sections.join('');
|
|
7454
|
+
body.querySelectorAll('[data-open-shot-clip]').forEach(btn => {
|
|
7455
|
+
btn.addEventListener('click', () => {
|
|
7456
|
+
const clipId = btn.dataset.openShotClip;
|
|
7457
|
+
const shotIndex = Number(btn.dataset.openShotIndex);
|
|
7458
|
+
openClipDetail(clipId)
|
|
7459
|
+
.then(() => Number.isFinite(shotIndex) ? openShotDetail(shotIndex) : null)
|
|
7460
|
+
.catch(alertError);
|
|
7461
|
+
});
|
|
7462
|
+
});
|
|
7463
|
+
}
|
|
7464
|
+
|
|
7465
|
+
function renderSelectsDecisions(plan) {
|
|
7466
|
+
const decisions = plan.decisions || [];
|
|
7467
|
+
const rankLabel = { 3: 'high', 2: 'medium', 1: 'low' };
|
|
7468
|
+
const cards = decisions.map((d, i) => {
|
|
7469
|
+
const range = Array.isArray(d.source_frame_range) ? `frames ${d.source_frame_range[0]}–${d.source_frame_range[1]}` : '';
|
|
7470
|
+
const shotLink = d.resolve_clip_id != null && d.shot_index != null
|
|
7471
|
+
? `<button class="secondary" style="font-size:var(--text-xs);padding:1px 8px" data-open-shot-clip="${escapeHtml(d.resolve_clip_id)}" data-open-shot-index="${d.shot_index}">Open shot page</button>` : '';
|
|
7472
|
+
return `<div class="plan-decision-card">
|
|
7473
|
+
${planThumb(d, `decision ${i + 1}`)}
|
|
7474
|
+
<div class="body">
|
|
7475
|
+
<div class="title-row">
|
|
7476
|
+
<strong>${i + 1}. ${escapeHtml(d.clip_name || d.clip_uuid || '?')} · shot ${d.shot_index ?? '?'}</strong>
|
|
7477
|
+
<span class="plan-chip">${escapeHtml(rankLabel[d.rank] || `rank ${d.rank}`)}</span>
|
|
7478
|
+
<span class="meta">${(d.duration_seconds ?? '?')}s${range ? ` · ${range}` : ''}</span>
|
|
7479
|
+
${shotLink}
|
|
7480
|
+
</div>
|
|
7481
|
+
${d.description ? `<div class="meta">${escapeHtml(d.description)}</div>` : ''}
|
|
7482
|
+
<div class="rationale">${escapeHtml(d.rationale || '')}</div>
|
|
7483
|
+
</div>
|
|
7484
|
+
</div>`;
|
|
7485
|
+
});
|
|
7486
|
+
return `<div class="plan-section"><strong>Decisions (${decisions.length} shots, ~${plan.estimated_duration_seconds ?? '?'}s)</strong>${cards.join('') || '<div class="empty">No decisions.</div>'}</div>`;
|
|
7487
|
+
}
|
|
7488
|
+
|
|
7489
|
+
function renderTightenLifts(plan) {
|
|
7490
|
+
const lifts = plan.lifts || [];
|
|
7491
|
+
const rows = lifts.map(l => {
|
|
7492
|
+
const gap = l.evidence?.source_gap_seconds;
|
|
7493
|
+
return `<tr>
|
|
7494
|
+
<td class="edit-type">${escapeHtml(l.kind || 'lift')}</td>
|
|
7495
|
+
<td>${escapeHtml(l.item_name || '—')}</td>
|
|
7496
|
+
<td>${l.timeline_start_frame}–${l.timeline_end_frame}</td>
|
|
7497
|
+
<td>${l.duration_seconds ?? '—'}s</td>
|
|
7498
|
+
<td>${escapeHtml(l.rationale || '')}${gap ? ` <span style="color:var(--text-muted)">(gap ${gap[0]}–${gap[1]}s)</span>` : ''}</td>
|
|
7499
|
+
</tr>`;
|
|
7500
|
+
}).join('');
|
|
7501
|
+
const skipped = (plan.skipped || []).map(s =>
|
|
7502
|
+
`<div class="plan-decision-card"><div class="body"><div class="meta">skipped ${escapeHtml(s.item || '?')}: ${escapeHtml(s.reason || '')}</div></div></div>`).join('');
|
|
7503
|
+
return `<div class="plan-section">
|
|
7504
|
+
<strong>Lifts (${lifts.length} · ${plan.keep_ranges ? plan.keep_ranges.length : 0} keep ranges)</strong>
|
|
7505
|
+
<table class="history-edits-table">
|
|
7506
|
+
<thead><tr><th>Kind</th><th>Item</th><th>Timeline frames</th><th>Removes</th><th>Rationale</th></tr></thead>
|
|
7507
|
+
<tbody>${rows || '<tr><td colspan="5">No lifts.</td></tr>'}</tbody>
|
|
7508
|
+
</table>
|
|
7509
|
+
${skipped}
|
|
7510
|
+
</div>`;
|
|
7511
|
+
}
|
|
7512
|
+
|
|
7513
|
+
function renderSwapAlternates(plan) {
|
|
7514
|
+
const alternates = plan.alternates || [];
|
|
7515
|
+
const item = plan.item || {};
|
|
7516
|
+
const current = `<div class="plan-decision-card"><div class="body">
|
|
7517
|
+
<div class="title-row"><strong>Current: ${escapeHtml(item.item_name || 'item')}</strong>
|
|
7518
|
+
<span class="meta">slot ${item.timeline_start_frame}–${item.timeline_end_frame} · track ${item.track_index ?? 1}</span></div>
|
|
7519
|
+
${item.current_description ? `<div class="meta">${escapeHtml(item.current_description)}</div>` : ''}
|
|
7520
|
+
</div></div>`;
|
|
7521
|
+
const cards = alternates.map((a, i) => {
|
|
7522
|
+
const range = Array.isArray(a.source_frame_range) ? `frames ${a.source_frame_range[0]}–${a.source_frame_range[1]}` : '';
|
|
7523
|
+
const shotLink = a.resolve_clip_id != null && a.shot_index != null
|
|
7524
|
+
? `<button class="secondary" style="font-size:var(--text-xs);padding:1px 8px" data-open-shot-clip="${escapeHtml(a.resolve_clip_id)}" data-open-shot-index="${a.shot_index}">Open shot page</button>` : '';
|
|
7525
|
+
return `<div class="plan-decision-card">
|
|
7526
|
+
${planThumb(a, `alternate ${i}`)}
|
|
7527
|
+
<div class="body">
|
|
7528
|
+
<div class="title-row">
|
|
7529
|
+
<strong>alternate_index ${i} · ${escapeHtml(a.clip_name || '?')} · shot ${a.shot_index ?? '?'}</strong>
|
|
7530
|
+
<span class="plan-chip">score ${a.score ?? '?'}</span>
|
|
7531
|
+
<span class="meta">${range}</span>
|
|
7532
|
+
${shotLink}
|
|
7533
|
+
</div>
|
|
7534
|
+
${a.description ? `<div class="meta">${escapeHtml(a.description)}</div>` : ''}
|
|
7535
|
+
<div class="rationale">${escapeHtml(a.rationale || '')}</div>
|
|
7536
|
+
</div>
|
|
7537
|
+
</div>`;
|
|
7538
|
+
});
|
|
7539
|
+
return `<div class="plan-section"><strong>Alternates (${alternates.length})</strong>${current}${cards.join('') || '<div class="empty">No alternates.</div>'}</div>`;
|
|
7540
|
+
}
|
|
7541
|
+
|
|
7213
7542
|
// ─── Run scoping controls ──────────────────────────────────────────
|
|
7214
7543
|
state.runScope = state.runScope || { current: null, recent: [] };
|
|
7215
7544
|
|
|
@@ -8088,6 +8417,10 @@ HTML = r"""<!doctype html>
|
|
|
8088
8417
|
if (combinedEl) combinedEl.style.display = view === 'combined' ? '' : 'none';
|
|
8089
8418
|
const historyEl = $('reviewHistoryView');
|
|
8090
8419
|
if (historyEl) historyEl.style.display = view === 'history' ? '' : 'none';
|
|
8420
|
+
const plansEl = $('reviewPlansView');
|
|
8421
|
+
if (plansEl) plansEl.style.display = view === 'plans' ? '' : 'none';
|
|
8422
|
+
const planEl = $('reviewPlanView');
|
|
8423
|
+
if (planEl) planEl.style.display = view === 'plan' ? '' : 'none';
|
|
8091
8424
|
const back = $('reviewBackBtn');
|
|
8092
8425
|
if (back) back.style.display = view === 'bin' ? 'none' : '';
|
|
8093
8426
|
const meta = $('reviewMeta');
|
|
@@ -8098,6 +8431,8 @@ HTML = r"""<!doctype html>
|
|
|
8098
8431
|
else if (view === 'transcript') meta.textContent = `Transcript · ${state.review.currentClipData?.card?.clip_name || 'clip'}`;
|
|
8099
8432
|
else if (view === 'combined') meta.textContent = `Combined review · ${state.review.combinedData?.clip_count || '?'} clips`;
|
|
8100
8433
|
else if (view === 'history') meta.textContent = 'Timeline history · archived versions and brain edits per timeline';
|
|
8434
|
+
else if (view === 'plans') meta.textContent = 'Edit plans · dry-run plans saved by the edit engine';
|
|
8435
|
+
else if (view === 'plan') meta.textContent = `Plan ${state.plans?.currentPlanId || ''} · review here, execute from chat`;
|
|
8101
8436
|
}
|
|
8102
8437
|
if (opts.writePanelState !== false) {
|
|
8103
8438
|
writePanelStateAsync({
|
|
@@ -10470,6 +10805,12 @@ HTML = r"""<!doctype html>
|
|
|
10470
10805
|
if (window.location.hash !== '#analysis/review/history') {
|
|
10471
10806
|
window.history.replaceState(null, '', '#analysis/review/history');
|
|
10472
10807
|
}
|
|
10808
|
+
} else if (control.dataset.reviewView === 'plans') {
|
|
10809
|
+
reviewSetView('plans');
|
|
10810
|
+
refreshEditPlans().catch(alertError);
|
|
10811
|
+
if (window.location.hash !== '#analysis/review/plans') {
|
|
10812
|
+
window.history.replaceState(null, '', '#analysis/review/plans');
|
|
10813
|
+
}
|
|
10473
10814
|
}
|
|
10474
10815
|
});
|
|
10475
10816
|
});
|
|
@@ -10588,6 +10929,10 @@ HTML = r"""<!doctype html>
|
|
|
10588
10929
|
if (historyRefreshEl) {
|
|
10589
10930
|
historyRefreshEl.onclick = () => refreshHistoryTimelines().catch(alertError);
|
|
10590
10931
|
}
|
|
10932
|
+
const plansRefreshEl = $('plansRefreshBtn');
|
|
10933
|
+
if (plansRefreshEl) {
|
|
10934
|
+
plansRefreshEl.onclick = () => refreshEditPlans().catch(alertError);
|
|
10935
|
+
}
|
|
10591
10936
|
const historyArchiveBtnEl = $('historyArchiveCurrentBtn');
|
|
10592
10937
|
if (historyArchiveBtnEl) {
|
|
10593
10938
|
historyArchiveBtnEl.onclick = () => {
|
|
@@ -10707,6 +11052,13 @@ HTML = r"""<!doctype html>
|
|
|
10707
11052
|
reviewSetView('bin');
|
|
10708
11053
|
} else if (state.review.view === 'history') {
|
|
10709
11054
|
reviewSetView('bin');
|
|
11055
|
+
} else if (state.review.view === 'plan') {
|
|
11056
|
+
state.plans.currentPlanId = null;
|
|
11057
|
+
state.plans.payload = null;
|
|
11058
|
+
reviewSetView('plans');
|
|
11059
|
+
refreshEditPlans().catch(alertError);
|
|
11060
|
+
} else if (state.review.view === 'plans') {
|
|
11061
|
+
reviewSetView('bin');
|
|
10710
11062
|
}
|
|
10711
11063
|
};
|
|
10712
11064
|
$('reviewBinGrid').addEventListener('click', event => {
|
|
@@ -13361,6 +13713,71 @@ def get_timeline_history_payload(
|
|
|
13361
13713
|
}
|
|
13362
13714
|
|
|
13363
13715
|
|
|
13716
|
+
def list_edit_plans_payload(project_root: str) -> Dict[str, Any]:
|
|
13717
|
+
"""Edit-engine plan list for the panel browser (DB/file only, no Resolve).
|
|
13718
|
+
|
|
13719
|
+
Fingerprint-corrupt plans surface as {"plan_id", "corrupt": True} warning
|
|
13720
|
+
rows rather than being silently hidden.
|
|
13721
|
+
"""
|
|
13722
|
+
try:
|
|
13723
|
+
from src.utils import edit_engine as _edit_engine
|
|
13724
|
+
return _edit_engine.list_plans(project_root, limit=50, include_corrupt=True)
|
|
13725
|
+
except Exception as exc: # noqa: BLE001 — panel reads fail soft
|
|
13726
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}", "plans": []}
|
|
13727
|
+
|
|
13728
|
+
|
|
13729
|
+
def get_edit_plan_payload(project_root: str, plan_id: str) -> Dict[str, Any]:
|
|
13730
|
+
"""Full plan detail for the panel, enriched for rendering: selects
|
|
13731
|
+
decisions and swap alternates gain a `thumb_frame_index` (the shot's first
|
|
13732
|
+
sampled frame, for the existing /api/clips/<id>/frames/<idx> route) and a
|
|
13733
|
+
`resolve_clip_id` fallback mapped from clip_uuid. Enrichment is best-effort
|
|
13734
|
+
— the plan still renders without thumbnails when the DB is unavailable.
|
|
13735
|
+
"""
|
|
13736
|
+
try:
|
|
13737
|
+
from src.utils import edit_engine as _edit_engine
|
|
13738
|
+
plan = _edit_engine.load_plan(project_root, plan_id)
|
|
13739
|
+
except Exception as exc: # noqa: BLE001
|
|
13740
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
13741
|
+
if plan is None:
|
|
13742
|
+
return {"success": False, "error": f"Plan {plan_id} not found"}
|
|
13743
|
+
if plan.get("_corrupt"):
|
|
13744
|
+
return {"success": True, "plan_id": plan_id, "corrupt": True}
|
|
13745
|
+
plan = json.loads(json.dumps(plan, default=str)) # detach a plain copy
|
|
13746
|
+
try:
|
|
13747
|
+
conn = _timeline_brain_db.connect(project_root)
|
|
13748
|
+
clip_id_cache: Dict[str, Any] = {}
|
|
13749
|
+
|
|
13750
|
+
def _enrich(row: Dict[str, Any]) -> None:
|
|
13751
|
+
clip_uuid = str(row.get("clip_uuid") or "")
|
|
13752
|
+
if not row.get("resolve_clip_id") and clip_uuid:
|
|
13753
|
+
if clip_uuid not in clip_id_cache:
|
|
13754
|
+
hit = conn.execute(
|
|
13755
|
+
"SELECT resolve_clip_id FROM clips WHERE clip_uuid = ?",
|
|
13756
|
+
(clip_uuid,),
|
|
13757
|
+
).fetchone()
|
|
13758
|
+
clip_id_cache[clip_uuid] = hit["resolve_clip_id"] if hit else None
|
|
13759
|
+
if clip_id_cache[clip_uuid]:
|
|
13760
|
+
row["resolve_clip_id"] = clip_id_cache[clip_uuid]
|
|
13761
|
+
shot_uuid = row.get("shot_uuid")
|
|
13762
|
+
if shot_uuid and row.get("thumb_frame_index") is None:
|
|
13763
|
+
hit = conn.execute(
|
|
13764
|
+
"SELECT MIN(frame_index) AS frame_index FROM frames WHERE shot_uuid = ?",
|
|
13765
|
+
(str(shot_uuid),),
|
|
13766
|
+
).fetchone()
|
|
13767
|
+
if hit and hit["frame_index"] is not None:
|
|
13768
|
+
row["thumb_frame_index"] = int(hit["frame_index"])
|
|
13769
|
+
|
|
13770
|
+
for decision in plan.get("decisions") or []:
|
|
13771
|
+
if isinstance(decision, dict):
|
|
13772
|
+
_enrich(decision)
|
|
13773
|
+
for alternate in plan.get("alternates") or []:
|
|
13774
|
+
if isinstance(alternate, dict):
|
|
13775
|
+
_enrich(alternate)
|
|
13776
|
+
except Exception: # noqa: BLE001 — thumbnails are progressive enhancement
|
|
13777
|
+
pass
|
|
13778
|
+
return {"success": True, "corrupt": False, "plan": plan}
|
|
13779
|
+
|
|
13780
|
+
|
|
13364
13781
|
def proxy_timeline_versioning_action(body: Dict[str, Any]) -> Dict[str, Any]:
|
|
13365
13782
|
"""Bridge dashboard → MCP server timeline_versioning tool.
|
|
13366
13783
|
|
|
@@ -14473,6 +14890,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14473
14890
|
return
|
|
14474
14891
|
self._json(get_timeline_history_payload(self.state.project_root, timeline_name))
|
|
14475
14892
|
return
|
|
14893
|
+
# ─── Edit-engine plan browser (DB/file only — no Resolve) ───────
|
|
14894
|
+
if path == "/api/edit_plans":
|
|
14895
|
+
self._json(list_edit_plans_payload(self.state.project_root))
|
|
14896
|
+
return
|
|
14897
|
+
if path.startswith("/api/edit_plans/"):
|
|
14898
|
+
plan_id = unquote(path[len("/api/edit_plans/"):])
|
|
14899
|
+
if not plan_id:
|
|
14900
|
+
self._json({"success": False, "error": "plan_id required"}, HTTPStatus.BAD_REQUEST)
|
|
14901
|
+
return
|
|
14902
|
+
payload = get_edit_plan_payload(self.state.project_root, plan_id)
|
|
14903
|
+
if not payload.get("success") and "not found" in str(payload.get("error", "")):
|
|
14904
|
+
self._json(payload, HTTPStatus.NOT_FOUND)
|
|
14905
|
+
return
|
|
14906
|
+
self._json(payload)
|
|
14907
|
+
return
|
|
14476
14908
|
if path == "/api/brain_edits/registry":
|
|
14477
14909
|
self._json({"success": True, **_brain_edits.read_brain_edits_registry(self.state.project_root)})
|
|
14478
14910
|
return
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.48.0"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.48.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -16386,6 +16386,10 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16386
16386
|
list_versions(timeline_name) -> [{version, archived_timeline_name, created_at, ...}]
|
|
16387
16387
|
Version chain for `timeline_name`, oldest first. Includes any retention-
|
|
16388
16388
|
collapsed versions (drt_export_path populated).
|
|
16389
|
+
diff_timelines(from_timeline, to_timeline) -> {added, removed, moved, trimmed, summary}
|
|
16390
|
+
Structural diff between two LIVE timelines by name (read-only; no
|
|
16391
|
+
archived versions needed). Built for edit-engine variants — tighten/
|
|
16392
|
+
selects produce new-name timelines with no shared version chain.
|
|
16389
16393
|
get_history(timeline_name?, analysis_run_id?, limit?) -> [{edit_type, target_metric, before_value, after_value, delta, ...}]
|
|
16390
16394
|
Brain-edit history. Filter by timeline_name or analysis_run_id; defaults
|
|
16391
16395
|
to the most recent 50 across the project.
|
|
@@ -16453,6 +16457,14 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16453
16457
|
to_version=int(p["to_version"]),
|
|
16454
16458
|
),
|
|
16455
16459
|
}
|
|
16460
|
+
if action == "diff_timelines":
|
|
16461
|
+
if not p.get("from_timeline") or not p.get("to_timeline"):
|
|
16462
|
+
return _err("from_timeline and to_timeline required")
|
|
16463
|
+
return _timeline_versioning.diff_timelines(
|
|
16464
|
+
project=project_h,
|
|
16465
|
+
from_timeline=str(p["from_timeline"]),
|
|
16466
|
+
to_timeline=str(p["to_timeline"]),
|
|
16467
|
+
)
|
|
16456
16468
|
if action == "get_history":
|
|
16457
16469
|
rows = _brain_edits.get_brain_edit_history(
|
|
16458
16470
|
project_root=project_root,
|
|
@@ -16562,6 +16574,105 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
|
|
|
16562
16574
|
return out
|
|
16563
16575
|
|
|
16564
16576
|
|
|
16577
|
+
def _edit_engine_track_counts(tl) -> Dict[str, int]:
|
|
16578
|
+
"""Per-track-type item counts — the swap symmetry signal in readback."""
|
|
16579
|
+
counts: Dict[str, int] = {}
|
|
16580
|
+
for tt in ("video", "audio"):
|
|
16581
|
+
total = 0
|
|
16582
|
+
try:
|
|
16583
|
+
n = int(tl.GetTrackCount(tt) or 0)
|
|
16584
|
+
except Exception:
|
|
16585
|
+
n = 0
|
|
16586
|
+
for ti in range(1, n + 1):
|
|
16587
|
+
try:
|
|
16588
|
+
total += len(tl.GetItemListInTrack(tt, ti) or [])
|
|
16589
|
+
except Exception:
|
|
16590
|
+
continue
|
|
16591
|
+
counts[tt] = total
|
|
16592
|
+
return counts
|
|
16593
|
+
|
|
16594
|
+
|
|
16595
|
+
def _edit_engine_find_slot_item(tl, track_index: int, slot_start: int, slot_end: int):
|
|
16596
|
+
"""The video item occupying exactly [slot_start, slot_end] on a track."""
|
|
16597
|
+
try:
|
|
16598
|
+
for cand in (tl.GetItemListInTrack("video", int(track_index)) or []):
|
|
16599
|
+
if _frame_int(cand.GetStart()) == slot_start and _frame_int(cand.GetEnd()) == slot_end:
|
|
16600
|
+
return cand
|
|
16601
|
+
except Exception:
|
|
16602
|
+
pass
|
|
16603
|
+
return None
|
|
16604
|
+
|
|
16605
|
+
|
|
16606
|
+
def _edit_engine_linked_audio_tracks(
|
|
16607
|
+
tl, target_item, slot_start: int, slot_end: int,
|
|
16608
|
+
) -> Tuple[List[int], str]:
|
|
16609
|
+
"""Audio track indices carrying the target item's linked audio.
|
|
16610
|
+
|
|
16611
|
+
Prefers GetLinkedItems; falls back to matching slot-overlapping audio
|
|
16612
|
+
items that share the target's media-pool source. ([], note) when there is
|
|
16613
|
+
no linked audio (audio-only handling stays untouched) or no target item.
|
|
16614
|
+
"""
|
|
16615
|
+
if target_item is None:
|
|
16616
|
+
return [], "target video item not found on its track; audio left untouched"
|
|
16617
|
+
linked_ids: set = set()
|
|
16618
|
+
get_linked = getattr(target_item, "GetLinkedItems", None)
|
|
16619
|
+
if callable(get_linked):
|
|
16620
|
+
try:
|
|
16621
|
+
for linked_item in (get_linked() or []):
|
|
16622
|
+
uid = getattr(linked_item, "GetUniqueId", None)
|
|
16623
|
+
if callable(uid):
|
|
16624
|
+
linked_ids.add(str(uid()))
|
|
16625
|
+
except Exception:
|
|
16626
|
+
linked_ids = set()
|
|
16627
|
+
target_media_id = None
|
|
16628
|
+
try:
|
|
16629
|
+
mpi = target_item.GetMediaPoolItem()
|
|
16630
|
+
if mpi is not None:
|
|
16631
|
+
target_media_id = str(mpi.GetUniqueId())
|
|
16632
|
+
except Exception:
|
|
16633
|
+
target_media_id = None
|
|
16634
|
+
if not linked_ids and not target_media_id:
|
|
16635
|
+
return [], "GetLinkedItems unavailable and no media id to match; audio left untouched"
|
|
16636
|
+
|
|
16637
|
+
indices: List[int] = []
|
|
16638
|
+
try:
|
|
16639
|
+
audio_tracks = int(tl.GetTrackCount("audio") or 0)
|
|
16640
|
+
except Exception:
|
|
16641
|
+
audio_tracks = 0
|
|
16642
|
+
for ti in range(1, audio_tracks + 1):
|
|
16643
|
+
try:
|
|
16644
|
+
items = tl.GetItemListInTrack("audio", ti) or []
|
|
16645
|
+
except Exception:
|
|
16646
|
+
continue
|
|
16647
|
+
for cand in items:
|
|
16648
|
+
try:
|
|
16649
|
+
c_start = _frame_int(cand.GetStart())
|
|
16650
|
+
c_end = _frame_int(cand.GetEnd())
|
|
16651
|
+
except Exception:
|
|
16652
|
+
continue
|
|
16653
|
+
if c_start is None or c_end is None or c_start >= slot_end or c_end <= slot_start:
|
|
16654
|
+
continue
|
|
16655
|
+
is_linked = False
|
|
16656
|
+
if linked_ids:
|
|
16657
|
+
uid = getattr(cand, "GetUniqueId", None)
|
|
16658
|
+
if callable(uid):
|
|
16659
|
+
try:
|
|
16660
|
+
is_linked = str(uid()) in linked_ids
|
|
16661
|
+
except Exception:
|
|
16662
|
+
is_linked = False
|
|
16663
|
+
if not is_linked and target_media_id:
|
|
16664
|
+
try:
|
|
16665
|
+
c_mpi = cand.GetMediaPoolItem()
|
|
16666
|
+
is_linked = c_mpi is not None and str(c_mpi.GetUniqueId()) == target_media_id
|
|
16667
|
+
except Exception:
|
|
16668
|
+
is_linked = False
|
|
16669
|
+
if is_linked and ti not in indices:
|
|
16670
|
+
indices.append(ti)
|
|
16671
|
+
if not indices:
|
|
16672
|
+
return [], "no linked audio found for the target item"
|
|
16673
|
+
return indices, ""
|
|
16674
|
+
|
|
16675
|
+
|
|
16565
16676
|
@mcp.tool()
|
|
16566
16677
|
@_destructive_op("edit_engine")
|
|
16567
16678
|
def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -16743,6 +16854,16 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16743
16854
|
record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
|
|
16744
16855
|
appended = mp.AppendToTimeline(built) if built else None
|
|
16745
16856
|
readback = _edit_engine_capture(tl)
|
|
16857
|
+
# A diff against a source timeline is meaningless for a fresh assembly;
|
|
16858
|
+
# a usage-snapshot summary is the structural readback that fits.
|
|
16859
|
+
try:
|
|
16860
|
+
usage = _timeline_versioning.capture_timeline_clip_usage(tl)
|
|
16861
|
+
by_type: Dict[str, int] = {}
|
|
16862
|
+
for usage_row in usage:
|
|
16863
|
+
by_type[usage_row["track_type"]] = by_type.get(usage_row["track_type"], 0) + 1
|
|
16864
|
+
readback["usage_summary"] = {"items": len(usage), "by_track_type": by_type}
|
|
16865
|
+
except Exception:
|
|
16866
|
+
pass
|
|
16746
16867
|
run_id = _analysis_runs.current_run_id()
|
|
16747
16868
|
try:
|
|
16748
16869
|
_brain_edits.log_brain_edit(
|
|
@@ -16820,6 +16941,17 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16820
16941
|
return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
|
|
16821
16942
|
new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
|
|
16822
16943
|
after = _edit_engine_capture(new_tl) if new_tl else {}
|
|
16944
|
+
# Cross-name structural diff (source vs variant): trustworthy readback
|
|
16945
|
+
# without archived version rows — variants are new-name timelines.
|
|
16946
|
+
structural_diff = None
|
|
16947
|
+
if new_tl is not None:
|
|
16948
|
+
try:
|
|
16949
|
+
structural_diff = _timeline_versioning.compare_usage_snapshots(
|
|
16950
|
+
_timeline_versioning.capture_timeline_clip_usage(source_tl),
|
|
16951
|
+
_timeline_versioning.capture_timeline_clip_usage(new_tl),
|
|
16952
|
+
)
|
|
16953
|
+
except Exception as diff_exc:
|
|
16954
|
+
structural_diff = {"error": f"{type(diff_exc).__name__}: {diff_exc}"}
|
|
16823
16955
|
run_id = _analysis_runs.current_run_id()
|
|
16824
16956
|
try:
|
|
16825
16957
|
_brain_edits.log_brain_edit(
|
|
@@ -16865,6 +16997,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16865
16997
|
if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
|
|
16866
16998
|
else None
|
|
16867
16999
|
),
|
|
17000
|
+
"structural_diff": structural_diff,
|
|
16868
17001
|
},
|
|
16869
17002
|
"plan_id": plan.get("plan_id"),
|
|
16870
17003
|
}
|
|
@@ -16916,14 +17049,44 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16916
17049
|
except Exception:
|
|
16917
17050
|
pass
|
|
16918
17051
|
before = _edit_engine_capture(tl)
|
|
17052
|
+
before["track_counts"] = _edit_engine_track_counts(tl)
|
|
17053
|
+
slot_start = int(item_block.get("timeline_start_frame"))
|
|
17054
|
+
slot_end = int(item_block.get("timeline_end_frame"))
|
|
17055
|
+
target_track = int(item_block.get("track_index") or 1)
|
|
17056
|
+
# Capture the target's linked audio BEFORE the lift so the audio lift
|
|
17057
|
+
# is scoped to exactly those tracks (the replacement appends linked
|
|
17058
|
+
# video+audio, so both sides of the swap stay symmetric).
|
|
17059
|
+
target_item = _edit_engine_find_slot_item(tl, target_track, slot_start, slot_end)
|
|
17060
|
+
linked_audio_indices, audio_note = _edit_engine_linked_audio_tracks(
|
|
17061
|
+
tl, target_item, slot_start, slot_end,
|
|
17062
|
+
)
|
|
16919
17063
|
lift = _timeline_lift_range_impl(tl, {
|
|
16920
|
-
"start_frame":
|
|
16921
|
-
"end_frame":
|
|
17064
|
+
"start_frame": slot_start,
|
|
17065
|
+
"end_frame": slot_end,
|
|
17066
|
+
"track_types": ["video"],
|
|
17067
|
+
"track_indices": [target_track],
|
|
16922
17068
|
"allow_partial_item_delete": True,
|
|
16923
17069
|
"ripple": False,
|
|
16924
17070
|
})
|
|
16925
17071
|
if not lift.get("success"):
|
|
16926
17072
|
return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
|
|
17073
|
+
audio_lift = None
|
|
17074
|
+
if linked_audio_indices:
|
|
17075
|
+
audio_lift = _timeline_lift_range_impl(tl, {
|
|
17076
|
+
"start_frame": slot_start,
|
|
17077
|
+
"end_frame": slot_end,
|
|
17078
|
+
"track_types": ["audio"],
|
|
17079
|
+
"track_indices": linked_audio_indices,
|
|
17080
|
+
"allow_partial_item_delete": True,
|
|
17081
|
+
"ripple": False,
|
|
17082
|
+
})
|
|
17083
|
+
if not audio_lift.get("success"):
|
|
17084
|
+
return {
|
|
17085
|
+
"success": False,
|
|
17086
|
+
"error": f"linked-audio lift failed: {audio_lift.get('error')}",
|
|
17087
|
+
"lift": lift,
|
|
17088
|
+
"audio_lift": audio_lift,
|
|
17089
|
+
}
|
|
16927
17090
|
mp = proj.GetMediaPool()
|
|
16928
17091
|
root_folder = mp.GetRootFolder()
|
|
16929
17092
|
timeline_start = _timeline_start_frame(tl)
|
|
@@ -16940,8 +17103,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16940
17103
|
row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
|
|
16941
17104
|
if row_err:
|
|
16942
17105
|
return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
|
|
17106
|
+
# No mediaType on the clip info: the replacement appends video+audio
|
|
17107
|
+
# linked, mirroring the video + linked-audio lift above.
|
|
16943
17108
|
appended = mp.AppendToTimeline([row])
|
|
16944
17109
|
after = _edit_engine_capture(tl)
|
|
17110
|
+
after["track_counts"] = _edit_engine_track_counts(tl)
|
|
16945
17111
|
run_id = _analysis_runs.current_run_id()
|
|
16946
17112
|
try:
|
|
16947
17113
|
_brain_edits.log_brain_edit(
|
|
@@ -16972,6 +17138,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16972
17138
|
"before": before,
|
|
16973
17139
|
"after": after,
|
|
16974
17140
|
})
|
|
17141
|
+
readback: Dict[str, Any] = {"before": before, "after": after}
|
|
17142
|
+
readback["audio_accounting"] = {
|
|
17143
|
+
"linked_audio_tracks_lifted": linked_audio_indices,
|
|
17144
|
+
"audio_items_lifted": (audio_lift or {}).get("deleted", 0),
|
|
17145
|
+
"video_items_lifted": lift.get("deleted", 0),
|
|
17146
|
+
**({"note": audio_note} if audio_note else {}),
|
|
17147
|
+
}
|
|
16975
17148
|
return {
|
|
16976
17149
|
"success": bool(appended),
|
|
16977
17150
|
"timeline_name": tl.GetName(),
|
|
@@ -16981,7 +17154,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16981
17154
|
"shot_index": alternate.get("shot_index"),
|
|
16982
17155
|
"score": alternate.get("score"),
|
|
16983
17156
|
},
|
|
16984
|
-
"readback":
|
|
17157
|
+
"readback": readback,
|
|
16985
17158
|
"plan_id": plan.get("plan_id"),
|
|
16986
17159
|
}
|
|
16987
17160
|
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -81,7 +81,13 @@ def load_plan(project_root: str, plan_id: str) -> Optional[Dict[str, Any]]:
|
|
|
81
81
|
return plan
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def list_plans(project_root: str, *, limit: int = 20) -> Dict[str, Any]:
|
|
84
|
+
def list_plans(project_root: str, *, limit: int = 20, include_corrupt: bool = False) -> Dict[str, Any]:
|
|
85
|
+
"""List saved plans, newest first.
|
|
86
|
+
|
|
87
|
+
`include_corrupt=True` (panel browser) surfaces fingerprint-mismatched
|
|
88
|
+
plans as ``{"plan_id", "corrupt": True}`` warning rows instead of hiding
|
|
89
|
+
them; the default keeps the MCP-action shape unchanged.
|
|
90
|
+
"""
|
|
85
91
|
directory = _plan_dir(project_root)
|
|
86
92
|
rows: List[Dict[str, Any]] = []
|
|
87
93
|
if os.path.isdir(directory):
|
|
@@ -90,6 +96,8 @@ def list_plans(project_root: str, *, limit: int = 20) -> Dict[str, Any]:
|
|
|
90
96
|
continue
|
|
91
97
|
plan = load_plan(project_root, name[:-5])
|
|
92
98
|
if not plan or plan.get("_corrupt"):
|
|
99
|
+
if include_corrupt:
|
|
100
|
+
rows.append({"plan_id": name[:-5], "corrupt": True})
|
|
93
101
|
continue
|
|
94
102
|
rows.append({
|
|
95
103
|
"plan_id": plan.get("plan_id"),
|
|
@@ -261,22 +261,11 @@ def _resolve_media_pool_item_id(item: Any) -> Optional[str]:
|
|
|
261
261
|
return None
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
timeline_version: int,
|
|
270
|
-
analysis_run_id: Optional[str],
|
|
271
|
-
) -> int:
|
|
272
|
-
"""Walk every track/item on the timeline and INSERT a row per clip.
|
|
273
|
-
|
|
274
|
-
Returns the number of rows written. Safe to call repeatedly for the same
|
|
275
|
-
(timeline, version) — version is part of the row, not unique, so diffs
|
|
276
|
-
between two versions are a SQL JOIN on media_pool_item_id.
|
|
277
|
-
"""
|
|
278
|
-
observed_at = _now_iso()
|
|
279
|
-
rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = []
|
|
264
|
+
def capture_timeline_clip_usage(timeline: Any) -> List[Dict[str, Any]]:
|
|
265
|
+
"""Walk every track/item on a LIVE timeline into structural-usage rows
|
|
266
|
+
(no DB writes). Shared by the version snapshot writer and the cross-name
|
|
267
|
+
live diff."""
|
|
268
|
+
rows: List[Dict[str, Any]] = []
|
|
280
269
|
for tt in ("video", "audio", "subtitle"):
|
|
281
270
|
try:
|
|
282
271
|
count = timeline.GetTrackCount(tt)
|
|
@@ -298,10 +287,39 @@ def _snapshot_timeline_clip_usage(
|
|
|
298
287
|
out_frame = int(item.GetEnd())
|
|
299
288
|
except Exception:
|
|
300
289
|
continue
|
|
301
|
-
rows.append(
|
|
302
|
-
mpi_id,
|
|
303
|
-
tt,
|
|
304
|
-
|
|
290
|
+
rows.append({
|
|
291
|
+
"media_pool_item_id": mpi_id,
|
|
292
|
+
"track_type": tt,
|
|
293
|
+
"track_index": ti,
|
|
294
|
+
"in_frame": in_frame,
|
|
295
|
+
"out_frame": out_frame,
|
|
296
|
+
})
|
|
297
|
+
return rows
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _snapshot_timeline_clip_usage(
|
|
301
|
+
*,
|
|
302
|
+
project_root: str,
|
|
303
|
+
timeline: Any,
|
|
304
|
+
timeline_name: str,
|
|
305
|
+
timeline_version: int,
|
|
306
|
+
analysis_run_id: Optional[str],
|
|
307
|
+
) -> int:
|
|
308
|
+
"""Walk every track/item on the timeline and INSERT a row per clip.
|
|
309
|
+
|
|
310
|
+
Returns the number of rows written. Safe to call repeatedly for the same
|
|
311
|
+
(timeline, version) — version is part of the row, not unique, so diffs
|
|
312
|
+
between two versions are a SQL JOIN on media_pool_item_id.
|
|
313
|
+
"""
|
|
314
|
+
observed_at = _now_iso()
|
|
315
|
+
rows: List[Tuple[str, str, int, str, int, int, int, Optional[str], str]] = [
|
|
316
|
+
(
|
|
317
|
+
usage["media_pool_item_id"], timeline_name, timeline_version,
|
|
318
|
+
usage["track_type"], usage["track_index"],
|
|
319
|
+
usage["in_frame"], usage["out_frame"], analysis_run_id, observed_at,
|
|
320
|
+
)
|
|
321
|
+
for usage in capture_timeline_clip_usage(timeline)
|
|
322
|
+
]
|
|
305
323
|
|
|
306
324
|
if not rows:
|
|
307
325
|
return 0
|
|
@@ -348,9 +366,22 @@ def diff_versions(
|
|
|
348
366
|
|
|
349
367
|
before = _snapshot(from_version)
|
|
350
368
|
after = _snapshot(to_version)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
369
|
+
return {
|
|
370
|
+
"from_version": from_version,
|
|
371
|
+
"to_version": to_version,
|
|
372
|
+
**compare_usage_snapshots(before, after),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def compare_usage_snapshots(
|
|
377
|
+
before: List[Dict[str, Any]], after: List[Dict[str, Any]],
|
|
378
|
+
) -> Dict[str, Any]:
|
|
379
|
+
"""added/removed/moved/trimmed between two structural-usage snapshots.
|
|
380
|
+
|
|
381
|
+
Position key: (media id, track type, track index, in_frame). Same key in
|
|
382
|
+
both snapshots = same placement; a differing out_frame on that same key is
|
|
383
|
+
a *trim*. A differing track/in_frame for the same media id is a *move*.
|
|
384
|
+
"""
|
|
354
385
|
def _key(row: Dict[str, Any]) -> Tuple[str, str, int, int]:
|
|
355
386
|
return (row["media_pool_item_id"], row["track_type"], row["track_index"], row["in_frame"])
|
|
356
387
|
|
|
@@ -382,8 +413,6 @@ def diff_versions(
|
|
|
382
413
|
moved.append(r)
|
|
383
414
|
|
|
384
415
|
return {
|
|
385
|
-
"from_version": from_version,
|
|
386
|
-
"to_version": to_version,
|
|
387
416
|
"added": added,
|
|
388
417
|
"removed": removed,
|
|
389
418
|
"moved": moved,
|
|
@@ -399,6 +428,36 @@ def diff_versions(
|
|
|
399
428
|
}
|
|
400
429
|
|
|
401
430
|
|
|
431
|
+
def diff_timelines(
|
|
432
|
+
*,
|
|
433
|
+
project: Any,
|
|
434
|
+
from_timeline: str,
|
|
435
|
+
to_timeline: str,
|
|
436
|
+
) -> Dict[str, Any]:
|
|
437
|
+
"""Structural diff between two LIVE timelines by name (read-only).
|
|
438
|
+
|
|
439
|
+
Unlike diff_versions this needs no archived snapshots and works across
|
|
440
|
+
timeline NAMES — built for edit-engine variants (tighten/selects produce
|
|
441
|
+
new-name timelines that have no shared version chain with their source).
|
|
442
|
+
For moved/trimmed to mean anything the timelines should share source
|
|
443
|
+
clips; for unrelated timelines everything reports as added/removed.
|
|
444
|
+
"""
|
|
445
|
+
from_tl = _find_timeline_by_name(project, from_timeline)
|
|
446
|
+
if from_tl is None:
|
|
447
|
+
return {"success": False, "error": f"Timeline '{from_timeline}' not found"}
|
|
448
|
+
to_tl = _find_timeline_by_name(project, to_timeline)
|
|
449
|
+
if to_tl is None:
|
|
450
|
+
return {"success": False, "error": f"Timeline '{to_timeline}' not found"}
|
|
451
|
+
before = capture_timeline_clip_usage(from_tl)
|
|
452
|
+
after = capture_timeline_clip_usage(to_tl)
|
|
453
|
+
return {
|
|
454
|
+
"success": True,
|
|
455
|
+
"from_timeline": from_timeline,
|
|
456
|
+
"to_timeline": to_timeline,
|
|
457
|
+
**compare_usage_snapshots(before, after),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
402
461
|
def ensure_versioned_before_mutation(
|
|
403
462
|
*,
|
|
404
463
|
resolve: Any,
|