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 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
- [![Version](https://img.shields.io/badge/version-2.59.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.61.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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
- [![Local control panel](docs/images/control-panel/01-overview.png)](docs/guides/control-panel.md)
15
+ [![Local control panel](https://raw.githubusercontent.com/samuelgursky/davinci-resolve-mcp/main/docs/images/control-panel/01-overview.png)](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`, and
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`); `execute_selects`
798
- readback gains a `usage_summary`.
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:** 18 missing capabilities, 11 bugs / unreliable behaviors.
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.59.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.59.0",
3
+ "version": "2.61.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.59.0"
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.59.0"
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 tightened VARIANT
18005
- timeline from the plan's keep ranges (video + mirrored audio), never
18006
- mutating the original. Readback includes an audio_accounting block.
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
- record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
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": 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,