davinci-resolve-mcp 2.59.0 → 2.61.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 +134 -0
- package/README.md +2 -2
- package/docs/SKILL.md +42 -4
- package/docs/install.md +8 -0
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +268 -6
- package/src/utils/analysis_store.py +36 -1
- package/src/utils/api_truth.py +56 -0
- package/src/utils/destructive_hook.py +4 -1
- package/src/utils/edit_engine.py +34 -3
- package/src/utils/strata.py +565 -0
- package/src/utils/strata_analyzers.py +684 -0
- package/src/utils/strata_faces.py +347 -0
- package/src/utils/strata_queries.py +643 -0
- package/src/utils/strata_story.py +311 -0
- package/src/utils/timeline_brain_db.py +121 -1
- package/src/utils/timeline_versioning.py +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,140 @@
|
|
|
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.61.0
|
|
6
|
+
|
|
7
|
+
Perception strata — a timecoded track model over every analyzed clip, plus the
|
|
8
|
+
query layer that turns it into editorial answers. Ten new `media_analysis`
|
|
9
|
+
actions, all local compute (ffmpeg + numpy; the face tier detects opencv +
|
|
10
|
+
mediapipe and degrades honestly when absent). Everything measures, compares,
|
|
11
|
+
flags, or aims — nothing decides; the cut is the editor's.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Schema v13: perception strata** — two generic track shapes instead of a
|
|
16
|
+
table per sensor: `events` (point/span occurrences: pause, breath,
|
|
17
|
+
hesitation, blink, beat, downbeat, …) and `curves` (float32 time series:
|
|
18
|
+
pitch, vocal_energy, speech_rate, motion_energy, face curves), plus two
|
|
19
|
+
promotions out of the report blob: `transcript_words` (per-word timestamps
|
|
20
|
+
as queryable rows) and `story_beats` (units of meaning over the transcript).
|
|
21
|
+
Machine re-runs replace their own rows; `source='human'` rows are append-only
|
|
22
|
+
and always win.
|
|
23
|
+
- **Local analyzers (`strata_run`)** — prosody (pitch/energy/speech-rate curves
|
|
24
|
+
+ pause/breath/hesitation events), beat grid (beat/downbeat + tempo), motion
|
|
25
|
+
energy (per-frame luma difference), and face strata (blink/gaze/expression
|
|
26
|
+
via opencv + mediapipe when installed). `strata_status` reports the per-clip
|
|
27
|
+
track inventory and what this machine can run; `backfill_words` promotes
|
|
28
|
+
word timestamps already sitting in stored report blobs — no re-analysis.
|
|
29
|
+
- **`take_diff(clip_a, clip_b, text?)`** — align two deliveries of the same
|
|
30
|
+
line on transcript words and diff pace, pauses, pitch, and energy. Deltas
|
|
31
|
+
only; it never picks a winner.
|
|
32
|
+
- **`cut_candidates(clip_id, time_seconds)`** — the joint solver: scores every
|
|
33
|
+
frame around an intended cut on the cut-point grammar (cut on the blink,
|
|
34
|
+
don't cut mid-word, don't bisect a breath, pauses are doors, land on the
|
|
35
|
+
beat, cut inside movement) with human-readable reasons per candidate and
|
|
36
|
+
honest reporting of missing tracks.
|
|
37
|
+
- **`strata_query`** — the strata as one queryable surface: windowed
|
|
38
|
+
cross-track bundles per clip, or project-wide word find with a joined
|
|
39
|
+
±context bundle per hit. **`timeline_strata`** projects clip strata through
|
|
40
|
+
a versioned timeline's recorded placements.
|
|
41
|
+
- **Story beats (host-LLM pass)** — `plan_story_beats` assembles a timecoded
|
|
42
|
+
digest + JSON schema (the server never calls an LLM), the host chat produces
|
|
43
|
+
the beats, `commit_story_beats` validates and persists them append-only;
|
|
44
|
+
`list_story_beats` reads them back.
|
|
45
|
+
- **Schema v14: timeline timebase snapshot** — `timeline_versions` now records
|
|
46
|
+
the timeline's fps and start frame at archive time, so `timeline_strata`
|
|
47
|
+
returns timeline-relative frames/seconds instead of assuming 24fps against
|
|
48
|
+
absolute (start-timecode-inclusive) snapshot frames.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
Pre-release review (multi-agent, 8 finder angles + adversarial verification)
|
|
53
|
+
caught and fixed before anything shipped:
|
|
54
|
+
|
|
55
|
+
- **Re-ingest cascade wipe** — the ingest `INSERT OR REPLACE` on the clips row
|
|
56
|
+
cascade-deleted every clip-child table under `PRAGMA foreign_keys=ON`. The
|
|
57
|
+
legacy children are rebuilt during ingest, so this was invisible until the
|
|
58
|
+
strata tables (which ingest does NOT rebuild — human annotations included)
|
|
59
|
+
landed on the same cascade. The clips row is now a true upsert and is never
|
|
60
|
+
deleted.
|
|
61
|
+
- **Words-less re-analysis wiped word rows** — `ingest_transcript_words`
|
|
62
|
+
deleted before validating; a re-analysis without transcription (or a mixed
|
|
63
|
+
report whose words lived only at the top level) silently destroyed
|
|
64
|
+
previously backfilled rows. Now parses first, preserves existing rows when
|
|
65
|
+
the incoming report has no words, and falls back per-segment to the
|
|
66
|
+
top-level words list.
|
|
67
|
+
- **Machine story-beat commits could replace human rows** — the content-hash
|
|
68
|
+
`beat_uuid` let `INSERT OR REPLACE` overwrite a human beat sharing the same
|
|
69
|
+
span+label and erased supersede history on identical recommits. Beat rows
|
|
70
|
+
now get random UUIDs and plain INSERTs; duplicate spans within one commit
|
|
71
|
+
are skipped and reported.
|
|
72
|
+
- **Dispatch hardening** — stringified numbers from LLM clients no longer
|
|
73
|
+
crash `strata_query` (TypeError in curve slicing) or silently redirect
|
|
74
|
+
`timeline_strata` to the latest version; `cut_candidates` rejects
|
|
75
|
+
non-positive fps instead of ZeroDivisionError; word matches escape SQL LIKE
|
|
76
|
+
metacharacters; clip bundles fetch every recorded track (gesture_boundary,
|
|
77
|
+
loudness, custom human tracks) instead of a hardcoded subset; analyzer
|
|
78
|
+
ffmpeg calls go through the stdio-safe `proc.safe_run`; a broken
|
|
79
|
+
mediapipe/protobuf install degrades to "face unavailable" instead of
|
|
80
|
+
crashing `strata_status`.
|
|
81
|
+
|
|
82
|
+
### Validation
|
|
83
|
+
|
|
84
|
+
- Full offline suite: 1,418 tests green (17 new regression tests covering the
|
|
85
|
+
fixes above). Static checks, drift guards, npm pack, and API-parity audit
|
|
86
|
+
all pass. No live Resolve behavior changed beyond two defensive getter reads
|
|
87
|
+
(`GetSetting("timelineFrameRate")`, `GetStartFrame()`) at archive time, both
|
|
88
|
+
try/except-guarded and unit-tested against mock timelines.
|
|
89
|
+
|
|
90
|
+
## What's New in v2.60.0
|
|
91
|
+
|
|
92
|
+
A community bug-fix bundle — three external contributions plus the three issues
|
|
93
|
+
they filed, integrated with the contributors credited as co-authors.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- **Frame accuracy (`edit_engine`)** — `MediaPool.AppendToTimeline` clipInfo
|
|
98
|
+
`endFrame` is an *exclusive* bound (duration = `endFrame - startFrame`), but
|
|
99
|
+
three plan builders wrote an inclusive end (`round(t*fps) - 1`) and
|
|
100
|
+
`execute_selects` advanced its record cursor by `end - start + 1`. Result:
|
|
101
|
+
`plan_tighten` kept ranges were one frame short per segment (~4.3s across 130
|
|
102
|
+
segments), `plan_selects`/`plan_swap` source ranges were one frame short, and
|
|
103
|
+
`execute_selects` left a 1-frame gap between selects. Now half-open everywhere.
|
|
104
|
+
(#82, thanks @chenyuxiaojin)
|
|
105
|
+
- **Timeline rename no longer archives (`destructive_hook`)** — `timeline.set_name`
|
|
106
|
+
was version-on-mutate, so renames spawned redundant `_archived` timelines
|
|
107
|
+
(and renaming an archive archived the archive). A rename is content-preserving,
|
|
108
|
+
so it's out of the destructive registry. (#83, thanks @chenyuxiaojin)
|
|
109
|
+
- **Windows startup (`server`)** — initialize the Resolve scripting env
|
|
110
|
+
(PYTHONHOME, PATH, `os.add_dll_directory`) before importing the fusionscript
|
|
111
|
+
bridge, avoiding a native access violation that crashed network transports
|
|
112
|
+
before bind. No-op off Windows. (#78, thanks @POLEPALLIANVESH)
|
|
113
|
+
- Fixed a stale offline test that patched a refactored-away seam
|
|
114
|
+
(`project_manager` delete routing), restoring a fully green baseline.
|
|
115
|
+
|
|
116
|
+
### Added
|
|
117
|
+
|
|
118
|
+
- **`execute_tighten(..., include_details?)`** — the readback `structural_diff`
|
|
119
|
+
is compact by default (counts + a small head/tail sample) instead of embedding
|
|
120
|
+
every before/after item id (226 KB for a 130-segment tighten). The full
|
|
121
|
+
per-item diff is persisted in the plan record (`get_plan` →
|
|
122
|
+
`execution_summary.structural_diff`) and returned inline with
|
|
123
|
+
`include_details=true`. (#84, thanks @chenyuxiaojin)
|
|
124
|
+
- **`plan_tighten` skip dedup** — identical `(item, reason)` skip rows collapse
|
|
125
|
+
into one entry with a `count` (an unanalyzed layer no longer repeats the same
|
|
126
|
+
row once per segment). (#81, thanks @chenyuxiaojin)
|
|
127
|
+
|
|
128
|
+
### Documentation
|
|
129
|
+
|
|
130
|
+
- Recorded the `AppendToTimeline` `endFrame` exclusive-bound semantics in the
|
|
131
|
+
`api_truth` ledger (internal quirk entry). (#80, thanks @chenyuxiaojin)
|
|
132
|
+
|
|
133
|
+
### Validation
|
|
134
|
+
|
|
135
|
+
- Full offline suite green (1343 tests). Static/drift guards pass.
|
|
136
|
+
- Live Resolve validation of the #82 frame-accuracy fix (selects butt-join +
|
|
137
|
+
tighten frame-exact keep ranges) on a disposable project.
|
|
138
|
+
|
|
5
139
|
## What's New in v2.59.0
|
|
6
140
|
|
|
7
141
|
First-class conform ingest for **AAF** and **DRP**, an offline **Premiere `.prproj`** reader with
|
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)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
A Model Context Protocol (MCP) server that lets AI assistants control DaVinci Resolve Studio through the official Scripting API. It provides full API coverage plus guarded workflow helpers for editing, media pool organization, render setup, review markers, grading, Fusion, Fairlight, project lifecycle tasks, extension authoring, and source-safe media analysis.
|
|
14
14
|
|
|
15
|
-
[](docs/guides/control-panel.md)
|
|
15
|
+
[](docs/guides/control-panel.md)
|
|
16
16
|
|
|
17
17
|
A local browser control panel ships with the server for inspecting Resolve state, running source-safe analysis, drilling into analyzed clips and shots, and editing analysis output inline. See the [Control Panel Guide](docs/guides/control-panel.md) for the full tour.
|
|
18
18
|
|
package/docs/SKILL.md
CHANGED
|
@@ -551,8 +551,10 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
|
|
|
551
551
|
`list_corrections`, `deepen`, `commit_shot_vision`, `vision_pending_sweep`,
|
|
552
552
|
`build_embeddings`, `find_similar`, `detect_entities`, `commit_entities`,
|
|
553
553
|
`list_entities`, `prepare_bin_briefing`, `commit_bin_summary`,
|
|
554
|
-
`detect_shot_relationships`, `commit_shot_relationships`,
|
|
555
|
-
`list_shot_relationships
|
|
554
|
+
`detect_shot_relationships`, `commit_shot_relationships`,
|
|
555
|
+
`list_shot_relationships`, `strata_status`, `backfill_words`, `strata_run`,
|
|
556
|
+
`take_diff`, `cut_candidates`, `strata_query`, `timeline_strata`,
|
|
557
|
+
`plan_story_beats`, `commit_story_beats`, and `list_story_beats`.
|
|
556
558
|
|
|
557
559
|
**Cross-clip entities + bin briefing v2 (v2.44.0+).** Recurring
|
|
558
560
|
people/places/props across a project's media, found cheaply and confirmed
|
|
@@ -596,6 +598,40 @@ no editorial suggestions): `same_setup_as` / `alt_take_of` (symmetric) and
|
|
|
596
598
|
confirmed `alt_take_of` alternates over raw cosine (the rationale states
|
|
597
599
|
which basis ranked each alternate).
|
|
598
600
|
|
|
601
|
+
**Perception strata (v2.61.0+, schema v13/v14).** A timecoded track model over
|
|
602
|
+
each analyzed clip — events (pause/breath/hesitation/blink/beat/downbeat/…),
|
|
603
|
+
sampled curves (pitch/vocal_energy/speech_rate/motion_energy/face curves),
|
|
604
|
+
per-word transcript rows, and story beats. Local compute only (ffmpeg + numpy;
|
|
605
|
+
face tier needs opencv + mediapipe); machine re-runs replace their own rows,
|
|
606
|
+
human rows are append-only and always win. These measure and rank — they never
|
|
607
|
+
decide; the editor picks.
|
|
608
|
+
- `strata_status(clip_id?)` — project or per-clip track inventory plus what
|
|
609
|
+
this machine can run (`analyzer_capabilities`).
|
|
610
|
+
- `strata_run(clip_id, analyzers?)` — run prosody / beat_grid / motion_energy
|
|
611
|
+
/ face on one clip (default: whatever is available locally).
|
|
612
|
+
- `backfill_words()` — promote word timestamps already inside stored report
|
|
613
|
+
blobs into queryable `transcript_words` rows; idempotent, no re-analysis.
|
|
614
|
+
- `take_diff(clip_a, clip_b, text?)` — align two takes on transcript words
|
|
615
|
+
and diff their delivery (pace, pauses, pitch, energy). Deltas only, no
|
|
616
|
+
winner.
|
|
617
|
+
- `cut_candidates(clip_id, time_seconds, window_seconds?, fps?, limit?)` —
|
|
618
|
+
rank cut frames around an intended joint with human-readable reasons
|
|
619
|
+
(blink / word-gap / pause / breath / beat / motion); missing tracks are
|
|
620
|
+
reported, never treated as "no signal".
|
|
621
|
+
- `strata_query(clip_id?, start_seconds?, end_seconds?, match_word?, …)` —
|
|
622
|
+
one queryable surface: a windowed cross-track bundle for a clip, or a
|
|
623
|
+
project-wide word find with a joined ±context bundle per hit.
|
|
624
|
+
- `timeline_strata(timeline_name, timeline_version?, …)` — project clip
|
|
625
|
+
strata through a versioned timeline's recorded placements. Snapshot frames
|
|
626
|
+
are absolute record frames (start-timecode-inclusive); snapshots from
|
|
627
|
+
schema v14+ carry the timeline's fps/start frame so placements also get
|
|
628
|
+
timeline-relative frames and seconds.
|
|
629
|
+
- `plan_story_beats(clip_id)` / `commit_story_beats(clip_id, beats)` /
|
|
630
|
+
`list_story_beats(clip_id)` — host-LLM pass over the transcript digest
|
|
631
|
+
(the server never calls an LLM): beats are units of meaning with types
|
|
632
|
+
(topic/claim/revelation/emotional/anecdote/question/callback), links, and
|
|
633
|
+
supersede semantics.
|
|
634
|
+
|
|
599
635
|
**Embeddings + similarity (v2.43.0+).** Local-compute semantic search; no
|
|
600
636
|
vendor tokens, so nothing here touches the caps ledger. Backends are
|
|
601
637
|
detected, never installed (capabilities lists them with install guidance):
|
|
@@ -794,8 +830,10 @@ clip-count readback plus `brain_edits` rationale rows.
|
|
|
794
830
|
media-id fallback), and `readback` carries per-track-type
|
|
795
831
|
`track_counts` plus an `audio_accounting` block so swap symmetry is
|
|
796
832
|
verifiable. `execute_tighten` readback gains `structural_diff` (source
|
|
797
|
-
vs variant, via the same engine as `diff_timelines`)
|
|
798
|
-
|
|
833
|
+
vs variant, via the same engine as `diff_timelines`) — compact by
|
|
834
|
+
default (counts + a small sample; full per-item diff persisted in the
|
|
835
|
+
plan record via `get_plan`, or inline with `include_details=true`);
|
|
836
|
+
`execute_selects` readback gains a `usage_summary`.
|
|
799
837
|
- `list_plans(limit?)` / `get_plan(plan_id)`.
|
|
800
838
|
|
|
801
839
|
The engine needs the analysis substrate: analyzed clips in the DB (run
|
package/docs/install.md
CHANGED
|
@@ -64,6 +64,14 @@ The installer can automatically configure any of these clients:
|
|
|
64
64
|
|
|
65
65
|
You can configure multiple clients at once, or use `--clients manual` to get copy-paste config snippets.
|
|
66
66
|
|
|
67
|
+
For [Autohand Code](https://github.com/autohandai/code-cli/), register the managed launcher after setup:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
autohand mcp add davinci-resolve npx davinci-resolve-mcp server
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Autohand adds npx's `-y` flag automatically. Add `--scope project` after `mcp add` to keep the registration in the current workspace.
|
|
74
|
+
|
|
67
75
|
### Installer Options
|
|
68
76
|
|
|
69
77
|
```bash
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.0
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 19 missing capabilities, 11 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -134,6 +134,14 @@ equivalent, blocking full automation.
|
|
|
134
134
|
- **Workaround / current handling:** Position clips with AppendToTimeline clipInfo recordFrame, or perform insert/overwrite/replace edits in the Resolve UI.
|
|
135
135
|
- **Tags:** missing-method, timeline, edit
|
|
136
136
|
|
|
137
|
+
### Render in Place / bake a timeline clip to new media
|
|
138
|
+
|
|
139
|
+
- **Object:** `Timeline / TimelineItem / MediaPool`
|
|
140
|
+
- **Behavior:** There is no scripting method for the Edit-page clip context-menu action 'Render in Place', which bakes a clip (including its Fusion composition and effects) into a NEW rendered media file and drops that file back on the timeline at the same position, replacing the source clip. No Render*/Bake*/Freeze* method exists on Timeline, TimelineItem or MediaPool in the Resolve scripting API reference (BMD docs) or a dir() audit. NOTE the frequently-confused-but-distinct sibling: the render *cache* (a temporary, non-destructive cache of a clip's Color/Fusion output that reduces playback load WITHOUT creating a new media file) IS scriptable — TimelineItem.SetColorOutputCache / SetFusionOutputCache ('Render Cache Color/Fusion Output' menu actions) and Graph.SetNodeCacheMode. Render in Place is the permanent, media-producing bake; the render cache is the transient one.
|
|
141
|
+
- **Workaround / current handling:** If the goal is only to reduce playback/render load, use the render cache — exposed as timeline_item get_color_cache/set_color_cache/get_fusion_cache/set_fusion_cache and the Color-page graph node cache_mode (no new media, fully reversible). If you genuinely need a baked media file, render the clip's in/out range from the Deliver page (proj.AddRenderJob with MarkIn/MarkOut) and relink/append the result yourself, or run Render in Place from the Resolve UI. There is no one-call API equivalent. See issue #86.
|
|
142
|
+
- **Reference:** [issue #86](https://github.com/samuelgursky/davinci-resolve-mcp/issues/86)
|
|
143
|
+
- **Tags:** missing-method, timeline, render, cache, render-in-place, bake
|
|
144
|
+
|
|
137
145
|
### Smart Bins / Power Bins creation
|
|
138
146
|
|
|
139
147
|
- **Object:** `MediaPool`
|
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.61.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
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.61.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.61.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -745,6 +745,32 @@ resolve = None
|
|
|
745
745
|
dvr_script = None
|
|
746
746
|
_resolve_lock = threading.RLock()
|
|
747
747
|
|
|
748
|
+
# On Windows the fusionscript native bridge DLL must be locatable before the
|
|
749
|
+
# Python import machinery attempts to load it. Setting PYTHONHOME, prepending
|
|
750
|
+
# the Resolve install directory to PATH, and registering it with
|
|
751
|
+
# os.add_dll_directory() ensures the dynamic loader can find fusionscript.dll
|
|
752
|
+
# and its dependencies even when the server is launched from a virtual-env or
|
|
753
|
+
# a working directory that is not the Resolve install directory. These steps
|
|
754
|
+
# MUST happen before `import DaVinciResolveScript` or the import triggers a
|
|
755
|
+
# native access-violation on Windows (crash before port bind in network mode).
|
|
756
|
+
if sys.platform.startswith("win") and RESOLVE_LIB_PATH:
|
|
757
|
+
_resolve_install_dir = os.path.dirname(RESOLVE_LIB_PATH)
|
|
758
|
+
if os.path.isdir(_resolve_install_dir):
|
|
759
|
+
# Ensure Python's own runtime DLLs are discoverable by the bridge.
|
|
760
|
+
if not os.environ.get("PYTHONHOME"):
|
|
761
|
+
os.environ["PYTHONHOME"] = sys.base_prefix
|
|
762
|
+
# Prepend Resolve's install dir to PATH so the loader finds
|
|
763
|
+
# fusionscript.dll and sibling DLLs even without a system-wide install.
|
|
764
|
+
_cur_path = os.environ.get("PATH", "")
|
|
765
|
+
if _resolve_install_dir.lower() not in _cur_path.lower():
|
|
766
|
+
os.environ["PATH"] = _resolve_install_dir + os.pathsep + _cur_path
|
|
767
|
+
# os.add_dll_directory is available on Python 3.8+ (Windows only).
|
|
768
|
+
if hasattr(os, "add_dll_directory"):
|
|
769
|
+
try:
|
|
770
|
+
os.add_dll_directory(_resolve_install_dir)
|
|
771
|
+
except OSError:
|
|
772
|
+
pass
|
|
773
|
+
|
|
748
774
|
try:
|
|
749
775
|
import DaVinciResolveScript as dvr_script
|
|
750
776
|
logger.info("DaVinciResolveScript module loaded")
|
|
@@ -16654,6 +16680,40 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
16654
16680
|
# TOOL 15: media_analysis
|
|
16655
16681
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
16656
16682
|
|
|
16683
|
+
_STRATA_CLIP_REF_KEYS = (
|
|
16684
|
+
"clip_id", "clipId", "clip_uuid", "clipUuid",
|
|
16685
|
+
"clip_dir", "clipDir", "file_path", "filePath",
|
|
16686
|
+
)
|
|
16687
|
+
|
|
16688
|
+
|
|
16689
|
+
def _strata_clip_ref(p: Dict[str, Any]) -> Any:
|
|
16690
|
+
"""The clip ref from any accepted alias — one place, so aliases can't drift
|
|
16691
|
+
between action handlers."""
|
|
16692
|
+
for key in _STRATA_CLIP_REF_KEYS:
|
|
16693
|
+
value = p.get(key)
|
|
16694
|
+
if value:
|
|
16695
|
+
return value
|
|
16696
|
+
return None
|
|
16697
|
+
|
|
16698
|
+
|
|
16699
|
+
def _opt_number(value: Any) -> Optional[float]:
|
|
16700
|
+
"""Coerce int/float/numeric-string to float; anything else → None.
|
|
16701
|
+
|
|
16702
|
+
LLM clients routinely stringify numbers; params must not crash (or be
|
|
16703
|
+
silently dropped) because "12.5" arrived instead of 12.5.
|
|
16704
|
+
"""
|
|
16705
|
+
if isinstance(value, bool):
|
|
16706
|
+
return None
|
|
16707
|
+
if isinstance(value, (int, float)):
|
|
16708
|
+
return float(value)
|
|
16709
|
+
if isinstance(value, str):
|
|
16710
|
+
try:
|
|
16711
|
+
return float(value.strip())
|
|
16712
|
+
except ValueError:
|
|
16713
|
+
return None
|
|
16714
|
+
return None
|
|
16715
|
+
|
|
16716
|
+
|
|
16657
16717
|
@mcp.tool()
|
|
16658
16718
|
async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]:
|
|
16659
16719
|
"""Project-scoped media analysis and guarded metadata publishing.
|
|
@@ -16719,6 +16779,16 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
16719
16779
|
cancel_batch_job(job_id) -> stop future slices
|
|
16720
16780
|
resume_batch_job(job_id) -> requeue a canceled or interrupted job
|
|
16721
16781
|
cleanup_artifacts(frames_only=true) -> remove generated frame artifacts only
|
|
16782
|
+
strata_status(clip_id?) -> perception-strata inventory (events/curves/words/story beats) for the project or one clip
|
|
16783
|
+
backfill_words(analysis_root?) -> promote word timestamps from stored report blobs into transcript_words rows
|
|
16784
|
+
strata_run(clip_id, analyzers?) -> run local strata analyzers (prosody | beat_grid | motion_energy | face) on one clip; default = whatever this machine can run
|
|
16785
|
+
take_diff(clip_a, clip_b, text?) -> align two takes on transcript words and diff their delivery (pace, pauses, pitch, energy) — measures only, never picks
|
|
16786
|
+
cut_candidates(clip_id, time_seconds, window_seconds?, fps?, limit?) -> rank cut frames around an intended joint with reasons (blink/pause/word-gap/breath/beat/motion) — aims, never decides
|
|
16787
|
+
strata_query(clip_id?, start_seconds?, end_seconds?, match_word?, context_seconds?, include_curve_values?, limit?) -> cross-track window bundle for a clip, or project-wide word find with joined context per hit
|
|
16788
|
+
timeline_strata(timeline_name, timeline_version?, record_start_frame?, record_end_frame?, fps?) -> project clip strata through a timeline's recorded placements (whole-clip bundles; frames are absolute record frames incl. start-timecode offset; fps defaults to the snapshot's stored value; source-offset mapping needs the live item)
|
|
16789
|
+
plan_story_beats(clip_id) -> timecoded transcript digest + prosody evidence + JSON schema for the host model to segment into story beats (no LLM call server-side)
|
|
16790
|
+
commit_story_beats(clip_id, beats, source_model?) -> validate + persist host-produced story beats; machine commits supersede machine rows only, human rows always win
|
|
16791
|
+
list_story_beats(clip_id) -> current (non-superseded) story beats for a clip
|
|
16722
16792
|
|
|
16723
16793
|
Analysis outputs stay under a davinci-resolve-mcp-analysis project root
|
|
16724
16794
|
and are validated so they are never written beside source media. Executed
|
|
@@ -17017,6 +17087,17 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17017
17087
|
"detect_shot_relationships",
|
|
17018
17088
|
"commit_shot_relationships",
|
|
17019
17089
|
"list_shot_relationships",
|
|
17090
|
+
# Perception strata (schema v13) — timecoded track model.
|
|
17091
|
+
"strata_status",
|
|
17092
|
+
"backfill_words",
|
|
17093
|
+
"strata_run",
|
|
17094
|
+
"take_diff",
|
|
17095
|
+
"cut_candidates",
|
|
17096
|
+
"strata_query",
|
|
17097
|
+
"timeline_strata",
|
|
17098
|
+
"plan_story_beats",
|
|
17099
|
+
"commit_story_beats",
|
|
17100
|
+
"list_story_beats",
|
|
17020
17101
|
}:
|
|
17021
17102
|
root = resolve_media_analysis_output_root(
|
|
17022
17103
|
project_name=project_name,
|
|
@@ -17076,6 +17157,119 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17076
17157
|
if action == "db_ingest":
|
|
17077
17158
|
from src.utils import analysis_store
|
|
17078
17159
|
return analysis_store.ingest_project(project_root)
|
|
17160
|
+
# Perception strata (schema v13).
|
|
17161
|
+
if action == "strata_status":
|
|
17162
|
+
from src.utils import strata, strata_analyzers
|
|
17163
|
+
clip_ref = _strata_clip_ref(p)
|
|
17164
|
+
status = strata.strata_status(project_root, clip_ref)
|
|
17165
|
+
if status.get("success") and clip_ref is None:
|
|
17166
|
+
status["analyzer_capabilities"] = strata_analyzers.capabilities()
|
|
17167
|
+
return status
|
|
17168
|
+
if action == "backfill_words":
|
|
17169
|
+
from src.utils import strata
|
|
17170
|
+
return strata.backfill_transcript_words(project_root)
|
|
17171
|
+
if action == "strata_run":
|
|
17172
|
+
from src.utils import strata_analyzers
|
|
17173
|
+
clip_ref = _strata_clip_ref(p)
|
|
17174
|
+
if not clip_ref:
|
|
17175
|
+
return _err("strata_run requires clip_id (or clip_uuid / clip_dir / file_path)")
|
|
17176
|
+
analyzers = p.get("analyzers") or p.get("analyzer")
|
|
17177
|
+
if isinstance(analyzers, str):
|
|
17178
|
+
analyzers = [analyzers]
|
|
17179
|
+
return strata_analyzers.run_analyzers(project_root, clip_ref, analyzers)
|
|
17180
|
+
if action == "take_diff":
|
|
17181
|
+
from src.utils import strata_queries
|
|
17182
|
+
clip_a = p.get("clip_a") or p.get("clipA") or p.get("take_a") or p.get("takeA")
|
|
17183
|
+
clip_b = p.get("clip_b") or p.get("clipB") or p.get("take_b") or p.get("takeB")
|
|
17184
|
+
if not clip_a or not clip_b:
|
|
17185
|
+
return _err("take_diff requires clip_a and clip_b")
|
|
17186
|
+
return strata_queries.take_diff(project_root, clip_a, clip_b, text=p.get("text") or p.get("line"))
|
|
17187
|
+
if action == "cut_candidates":
|
|
17188
|
+
from src.utils import strata_queries
|
|
17189
|
+
clip_ref = _strata_clip_ref(p)
|
|
17190
|
+
t = _opt_number(p.get("time_seconds", p.get("timeSeconds")))
|
|
17191
|
+
if not clip_ref or t is None:
|
|
17192
|
+
return _err("cut_candidates requires clip_id and a numeric time_seconds")
|
|
17193
|
+
fps_raw = p.get("fps")
|
|
17194
|
+
fps = _opt_number(fps_raw)
|
|
17195
|
+
if fps_raw is not None and fps is None:
|
|
17196
|
+
return _err(f"fps must be a number, got {fps_raw!r}")
|
|
17197
|
+
return strata_queries.cut_candidates(
|
|
17198
|
+
project_root,
|
|
17199
|
+
clip_ref,
|
|
17200
|
+
t,
|
|
17201
|
+
window_seconds=_opt_number(p.get("window_seconds", p.get("windowSeconds"))) or 0.35,
|
|
17202
|
+
fps=fps,
|
|
17203
|
+
limit=int(_opt_number(p.get("limit")) or 7),
|
|
17204
|
+
)
|
|
17205
|
+
if action == "strata_query":
|
|
17206
|
+
from src.utils import strata_queries
|
|
17207
|
+
start_raw = p.get("start_seconds", p.get("startSeconds"))
|
|
17208
|
+
end_raw = p.get("end_seconds", p.get("endSeconds"))
|
|
17209
|
+
start = _opt_number(start_raw)
|
|
17210
|
+
end = _opt_number(end_raw)
|
|
17211
|
+
if (start_raw is not None and start is None) or (end_raw is not None and end is None):
|
|
17212
|
+
return _err("start_seconds/end_seconds must be numbers")
|
|
17213
|
+
return strata_queries.strata_query(
|
|
17214
|
+
project_root,
|
|
17215
|
+
clip_ref=_strata_clip_ref(p),
|
|
17216
|
+
start_seconds=start,
|
|
17217
|
+
end_seconds=end,
|
|
17218
|
+
match_word=p.get("match_word") or p.get("matchWord") or p.get("word"),
|
|
17219
|
+
context_seconds=_opt_number(p.get("context_seconds", p.get("contextSeconds"))) or 2.0,
|
|
17220
|
+
include_curve_values=bool(p.get("include_curve_values", p.get("includeCurveValues", False))),
|
|
17221
|
+
limit=int(_opt_number(p.get("limit")) or 20),
|
|
17222
|
+
)
|
|
17223
|
+
if action == "timeline_strata":
|
|
17224
|
+
from src.utils import strata_queries
|
|
17225
|
+
timeline_name = p.get("timeline_name") or p.get("timelineName") or p.get("timeline")
|
|
17226
|
+
if not timeline_name:
|
|
17227
|
+
return _err("timeline_strata requires timeline_name")
|
|
17228
|
+
version_raw = p.get("timeline_version", p.get("timelineVersion"))
|
|
17229
|
+
version = _opt_number(version_raw)
|
|
17230
|
+
if version_raw is not None and version is None:
|
|
17231
|
+
return _err(f"timeline_version must be an integer, got {version_raw!r}")
|
|
17232
|
+
fps_raw = p.get("fps")
|
|
17233
|
+
fps = _opt_number(fps_raw)
|
|
17234
|
+
if fps_raw is not None and fps is None:
|
|
17235
|
+
return _err(f"fps must be a number, got {fps_raw!r}")
|
|
17236
|
+
start_frame = _opt_number(p.get("record_start_frame", p.get("recordStartFrame")))
|
|
17237
|
+
end_frame = _opt_number(p.get("record_end_frame", p.get("recordEndFrame")))
|
|
17238
|
+
return strata_queries.timeline_strata(
|
|
17239
|
+
project_root,
|
|
17240
|
+
str(timeline_name),
|
|
17241
|
+
timeline_version=int(version) if version is not None else None,
|
|
17242
|
+
record_start_frame=int(start_frame) if start_frame is not None else None,
|
|
17243
|
+
record_end_frame=int(end_frame) if end_frame is not None else None,
|
|
17244
|
+
fps=fps,
|
|
17245
|
+
include_curve_values=bool(p.get("include_curve_values", p.get("includeCurveValues", False))),
|
|
17246
|
+
)
|
|
17247
|
+
if action == "plan_story_beats":
|
|
17248
|
+
from src.utils import strata_story
|
|
17249
|
+
clip_ref = _strata_clip_ref(p)
|
|
17250
|
+
if not clip_ref:
|
|
17251
|
+
return _err("plan_story_beats requires clip_id")
|
|
17252
|
+
return strata_story.plan_story_beats(project_root, clip_ref)
|
|
17253
|
+
if action == "commit_story_beats":
|
|
17254
|
+
from src.utils import strata_story
|
|
17255
|
+
clip_ref = _strata_clip_ref(p)
|
|
17256
|
+
if not clip_ref:
|
|
17257
|
+
return _err("commit_story_beats requires clip_id")
|
|
17258
|
+
beats = p.get("beats")
|
|
17259
|
+
if beats is None:
|
|
17260
|
+
return _err("commit_story_beats requires beats")
|
|
17261
|
+
return strata_story.commit_story_beats(
|
|
17262
|
+
project_root,
|
|
17263
|
+
clip_ref,
|
|
17264
|
+
beats,
|
|
17265
|
+
source_model=p.get("source_model") or p.get("sourceModel"),
|
|
17266
|
+
)
|
|
17267
|
+
if action == "list_story_beats":
|
|
17268
|
+
from src.utils import strata_story
|
|
17269
|
+
clip_ref = _strata_clip_ref(p)
|
|
17270
|
+
if not clip_ref:
|
|
17271
|
+
return _err("list_story_beats requires clip_id")
|
|
17272
|
+
return strata_story.list_story_beats(project_root, clip_ref)
|
|
17079
17273
|
# Phase B — deep shot-level vision tier.
|
|
17080
17274
|
if action == "deepen":
|
|
17081
17275
|
from src.utils import deep_vision
|
|
@@ -17634,6 +17828,16 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
17634
17828
|
"detect_shot_relationships",
|
|
17635
17829
|
"commit_shot_relationships",
|
|
17636
17830
|
"list_shot_relationships",
|
|
17831
|
+
"strata_status",
|
|
17832
|
+
"backfill_words",
|
|
17833
|
+
"strata_run",
|
|
17834
|
+
"take_diff",
|
|
17835
|
+
"cut_candidates",
|
|
17836
|
+
"strata_query",
|
|
17837
|
+
"timeline_strata",
|
|
17838
|
+
"plan_story_beats",
|
|
17839
|
+
"commit_story_beats",
|
|
17840
|
+
"list_story_beats",
|
|
17637
17841
|
"get_caps",
|
|
17638
17842
|
"set_caps_preset",
|
|
17639
17843
|
"get_usage",
|
|
@@ -17880,6 +18084,47 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
|
|
|
17880
18084
|
return out
|
|
17881
18085
|
|
|
17882
18086
|
|
|
18087
|
+
def _compact_structural_diff(diff: Any, *, sample_n: int = 3) -> Any:
|
|
18088
|
+
"""Trim a compare_usage_snapshots result for the default execute response.
|
|
18089
|
+
|
|
18090
|
+
The full added/removed/moved/trimmed rows (one per timeline item, each with
|
|
18091
|
+
media_pool_item_id + track + in/out frames) dominate large tighten
|
|
18092
|
+
responses — 226 KB for a 130-segment tighten (issue #84). The counts already
|
|
18093
|
+
live in `summary`; keep those plus a small head/tail sample per change kind.
|
|
18094
|
+
The full diff is persisted in the executed plan record (edit_engine
|
|
18095
|
+
get_plan(plan_id) → plan.execution_summary.structural_diff) and returned
|
|
18096
|
+
inline when the caller passes include_details=true.
|
|
18097
|
+
"""
|
|
18098
|
+
if not isinstance(diff, dict):
|
|
18099
|
+
return diff
|
|
18100
|
+
# An error stand-in (diff failed) or an already-compact payload: pass through.
|
|
18101
|
+
if "summary" not in diff:
|
|
18102
|
+
return diff
|
|
18103
|
+
sample: Dict[str, Any] = {}
|
|
18104
|
+
omitted = 0
|
|
18105
|
+
for kind in ("added", "removed", "moved", "trimmed"):
|
|
18106
|
+
rows = diff.get(kind)
|
|
18107
|
+
if not isinstance(rows, list) or not rows:
|
|
18108
|
+
continue
|
|
18109
|
+
if len(rows) <= sample_n:
|
|
18110
|
+
sample[kind] = list(rows)
|
|
18111
|
+
continue
|
|
18112
|
+
# Head sample + final row so the caller sees both ends of the change set.
|
|
18113
|
+
sample[kind] = list(rows[: sample_n - 1]) + [rows[-1]]
|
|
18114
|
+
omitted += len(rows) - sample_n
|
|
18115
|
+
return {
|
|
18116
|
+
"summary": diff.get("summary"),
|
|
18117
|
+
"sample": sample,
|
|
18118
|
+
"truncated": True,
|
|
18119
|
+
"omitted_rows": omitted,
|
|
18120
|
+
"detail_hint": (
|
|
18121
|
+
"Full per-item diff persisted in the plan record — retrieve via "
|
|
18122
|
+
"edit_engine get_plan(plan_id) → plan.execution_summary.structural_diff, "
|
|
18123
|
+
"or re-run with include_details=true to inline it."
|
|
18124
|
+
),
|
|
18125
|
+
}
|
|
18126
|
+
|
|
18127
|
+
|
|
17883
18128
|
def _edit_engine_track_counts(tl) -> Dict[str, int]:
|
|
17884
18129
|
"""Per-track-type item counts — the swap symmetry signal in readback."""
|
|
17885
18130
|
counts: Dict[str, int] = {}
|
|
@@ -18001,9 +18246,12 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18001
18246
|
evidence for the current (or named) timeline. Kept ranges mirror onto the
|
|
18002
18247
|
items' linked audio tracks by default so the variant is audible; pass
|
|
18003
18248
|
include_audio=false for a video-only (silent) assembly.
|
|
18004
|
-
- execute_tighten(plan_id, confirm_token?) — assembles a
|
|
18005
|
-
timeline from the plan's keep ranges (video + mirrored
|
|
18006
|
-
mutating the original. Readback includes an audio_accounting
|
|
18249
|
+
- execute_tighten(plan_id, confirm_token?, include_details?) — assembles a
|
|
18250
|
+
tightened VARIANT timeline from the plan's keep ranges (video + mirrored
|
|
18251
|
+
audio), never mutating the original. Readback includes an audio_accounting
|
|
18252
|
+
block and a compact structural_diff (counts + a small sample); the full
|
|
18253
|
+
per-item diff is persisted in the plan record (get_plan → execution_summary)
|
|
18254
|
+
and returned inline only when include_details=true.
|
|
18007
18255
|
- plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
|
|
18008
18256
|
— alternates for one timeline item via the similarity index.
|
|
18009
18257
|
- execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
|
|
@@ -18161,7 +18409,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18161
18409
|
build_errors.append({"index": i, "error": row_err.get("error")})
|
|
18162
18410
|
continue
|
|
18163
18411
|
built.append(row)
|
|
18164
|
-
|
|
18412
|
+
# AppendToTimeline endFrame is exclusive: the item occupies
|
|
18413
|
+
# (end - start) frames. Advancing by +1 more leaves a 1-frame gap
|
|
18414
|
+
# (black flash) between consecutive selects. See api_truth endFrame entry.
|
|
18415
|
+
record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0))
|
|
18165
18416
|
appended = mp.AppendToTimeline(built) if built else None
|
|
18166
18417
|
readback = _edit_engine_capture(tl)
|
|
18167
18418
|
# A diff against a source timeline is meaningless for a fresh assembly;
|
|
@@ -18291,12 +18542,20 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18291
18542
|
)
|
|
18292
18543
|
except Exception:
|
|
18293
18544
|
pass
|
|
18545
|
+
# The full per-item structural diff is persisted in the plan record so it
|
|
18546
|
+
# stays retrievable (edit_engine get_plan) without bloating every
|
|
18547
|
+
# response — see issue #84. The MCP response carries a compact form by
|
|
18548
|
+
# default and the full diff only when include_details=true.
|
|
18549
|
+
include_details = _media_analysis_bool(
|
|
18550
|
+
p.get("include_details", p.get("includeDetails")), False
|
|
18551
|
+
)
|
|
18294
18552
|
_edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
|
|
18295
18553
|
"variant_timeline": variant.get("name") or variant_name,
|
|
18296
18554
|
"keep_ranges": len(keep_ranges),
|
|
18297
18555
|
"lifts": len(lifts),
|
|
18298
18556
|
"before": before,
|
|
18299
18557
|
"after": after,
|
|
18558
|
+
"structural_diff": structural_diff,
|
|
18300
18559
|
})
|
|
18301
18560
|
return {
|
|
18302
18561
|
"success": True,
|
|
@@ -18316,7 +18575,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18316
18575
|
if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
|
|
18317
18576
|
else None
|
|
18318
18577
|
),
|
|
18319
|
-
"structural_diff":
|
|
18578
|
+
"structural_diff": (
|
|
18579
|
+
structural_diff if include_details
|
|
18580
|
+
else _compact_structural_diff(structural_diff)
|
|
18581
|
+
),
|
|
18320
18582
|
"audio_accounting": {
|
|
18321
18583
|
"planned_audio_ranges": audio_keep_ranges,
|
|
18322
18584
|
"planned_video_ranges": video_keep_ranges,
|