davinci-resolve-mcp 2.60.0 → 2.61.1

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,131 @@
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.1
6
+
7
+ The strata cleanup batch deferred from the v2.61.0 review — behavior-neutral
8
+ consolidation and hot-path efficiency. No new actions or parameters.
9
+
10
+ ### Changed
11
+
12
+ - **One clip resolver** — `strata.resolve_clip()` replaces the four drifting
13
+ per-module wrappers, and rides the pre-v9 auto-ingest fallback hoisted out
14
+ of deep_vision into `analysis_store.resolve_clip_uuid_ingesting()`: a clip
15
+ ref that resolves for `deepen` now resolves identically for every strata
16
+ action, including on older analysis roots.
17
+ - **One float32 codec** — `pack_curve`/`unpack_curve` delegate to
18
+ `embeddings.pack_vector`/`unpack_vector` instead of duplicating the BLOB
19
+ convention.
20
+ - **Decode once** — `strata_run` decodes the media file a single time when
21
+ several audio analyzers run (prosody + beat_grid previously each ran a full
22
+ ffmpeg decode); the shared `_audio_context` preamble also collapses their
23
+ duplicated require/resolve/decode blocks.
24
+ - **Registry-derived capabilities** — `ANALYZERS` carries run function plus
25
+ requires/writes metadata; `capabilities()` derives from it, so adding an
26
+ analyzer is one entry.
27
+ - **Query-layer caching** — word-find hits clustering in one clip unpack each
28
+ curve blob once, and `timeline_strata` resolves + bundles a source clip
29
+ reused across many placements once.
30
+ - **Sargable event windows (schema v15)** — windowed `read_events` bounds its
31
+ span-overlap lower edge by the track's `MAX(duration_seconds)` (a b-tree
32
+ descent via the new `ix_events_clip_track_span` index) so
33
+ `ix_events_clip_track` range-seeks instead of scanning the track.
34
+ - `backfill_words` iterates report blobs lazily with a transcription
35
+ prefilter instead of loading every blob into memory; `detect_breaths`
36
+ drops its unreachable non-numpy fallbacks.
37
+
38
+ ### Validation
39
+
40
+ - Full offline suite: 1,431 tests green (13 new covering the shared resolver
41
+ + pre-v9 fallback, codec identity, decode-once, per-clip caching, long-span
42
+ window overlap, and the index range-seek query plan). No Resolve behavior
43
+ changed; live test not required.
44
+
45
+ ## What's New in v2.61.0
46
+
47
+ Perception strata — a timecoded track model over every analyzed clip, plus the
48
+ query layer that turns it into editorial answers. Ten new `media_analysis`
49
+ actions, all local compute (ffmpeg + numpy; the face tier detects opencv +
50
+ mediapipe and degrades honestly when absent). Everything measures, compares,
51
+ flags, or aims — nothing decides; the cut is the editor's.
52
+
53
+ ### Added
54
+
55
+ - **Schema v13: perception strata** — two generic track shapes instead of a
56
+ table per sensor: `events` (point/span occurrences: pause, breath,
57
+ hesitation, blink, beat, downbeat, …) and `curves` (float32 time series:
58
+ pitch, vocal_energy, speech_rate, motion_energy, face curves), plus two
59
+ promotions out of the report blob: `transcript_words` (per-word timestamps
60
+ as queryable rows) and `story_beats` (units of meaning over the transcript).
61
+ Machine re-runs replace their own rows; `source='human'` rows are append-only
62
+ and always win.
63
+ - **Local analyzers (`strata_run`)** — prosody (pitch/energy/speech-rate curves
64
+ + pause/breath/hesitation events), beat grid (beat/downbeat + tempo), motion
65
+ energy (per-frame luma difference), and face strata (blink/gaze/expression
66
+ via opencv + mediapipe when installed). `strata_status` reports the per-clip
67
+ track inventory and what this machine can run; `backfill_words` promotes
68
+ word timestamps already sitting in stored report blobs — no re-analysis.
69
+ - **`take_diff(clip_a, clip_b, text?)`** — align two deliveries of the same
70
+ line on transcript words and diff pace, pauses, pitch, and energy. Deltas
71
+ only; it never picks a winner.
72
+ - **`cut_candidates(clip_id, time_seconds)`** — the joint solver: scores every
73
+ frame around an intended cut on the cut-point grammar (cut on the blink,
74
+ don't cut mid-word, don't bisect a breath, pauses are doors, land on the
75
+ beat, cut inside movement) with human-readable reasons per candidate and
76
+ honest reporting of missing tracks.
77
+ - **`strata_query`** — the strata as one queryable surface: windowed
78
+ cross-track bundles per clip, or project-wide word find with a joined
79
+ ±context bundle per hit. **`timeline_strata`** projects clip strata through
80
+ a versioned timeline's recorded placements.
81
+ - **Story beats (host-LLM pass)** — `plan_story_beats` assembles a timecoded
82
+ digest + JSON schema (the server never calls an LLM), the host chat produces
83
+ the beats, `commit_story_beats` validates and persists them append-only;
84
+ `list_story_beats` reads them back.
85
+ - **Schema v14: timeline timebase snapshot** — `timeline_versions` now records
86
+ the timeline's fps and start frame at archive time, so `timeline_strata`
87
+ returns timeline-relative frames/seconds instead of assuming 24fps against
88
+ absolute (start-timecode-inclusive) snapshot frames.
89
+
90
+ ### Fixed
91
+
92
+ Pre-release review (multi-agent, 8 finder angles + adversarial verification)
93
+ caught and fixed before anything shipped:
94
+
95
+ - **Re-ingest cascade wipe** — the ingest `INSERT OR REPLACE` on the clips row
96
+ cascade-deleted every clip-child table under `PRAGMA foreign_keys=ON`. The
97
+ legacy children are rebuilt during ingest, so this was invisible until the
98
+ strata tables (which ingest does NOT rebuild — human annotations included)
99
+ landed on the same cascade. The clips row is now a true upsert and is never
100
+ deleted.
101
+ - **Words-less re-analysis wiped word rows** — `ingest_transcript_words`
102
+ deleted before validating; a re-analysis without transcription (or a mixed
103
+ report whose words lived only at the top level) silently destroyed
104
+ previously backfilled rows. Now parses first, preserves existing rows when
105
+ the incoming report has no words, and falls back per-segment to the
106
+ top-level words list.
107
+ - **Machine story-beat commits could replace human rows** — the content-hash
108
+ `beat_uuid` let `INSERT OR REPLACE` overwrite a human beat sharing the same
109
+ span+label and erased supersede history on identical recommits. Beat rows
110
+ now get random UUIDs and plain INSERTs; duplicate spans within one commit
111
+ are skipped and reported.
112
+ - **Dispatch hardening** — stringified numbers from LLM clients no longer
113
+ crash `strata_query` (TypeError in curve slicing) or silently redirect
114
+ `timeline_strata` to the latest version; `cut_candidates` rejects
115
+ non-positive fps instead of ZeroDivisionError; word matches escape SQL LIKE
116
+ metacharacters; clip bundles fetch every recorded track (gesture_boundary,
117
+ loudness, custom human tracks) instead of a hardcoded subset; analyzer
118
+ ffmpeg calls go through the stdio-safe `proc.safe_run`; a broken
119
+ mediapipe/protobuf install degrades to "face unavailable" instead of
120
+ crashing `strata_status`.
121
+
122
+ ### Validation
123
+
124
+ - Full offline suite: 1,418 tests green (17 new regression tests covering the
125
+ fixes above). Static checks, drift guards, npm pack, and API-parity audit
126
+ all pass. No live Resolve behavior changed beyond two defensive getter reads
127
+ (`GetSetting("timelineFrameRate")`, `GetStartFrame()`) at archive time, both
128
+ try/except-guarded and unit-tested against mock timelines.
129
+
5
130
  ## What's New in v2.60.0
6
131
 
7
132
  A community bug-fix bundle — three external contributions plus the three issues
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.60.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.61.1-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):
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.60.0"
38
+ VERSION = "2.61.1"
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.60.0",
3
+ "version": "2.61.1",
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.60.0"
83
+ VERSION = "2.61.1"
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.60.0"
14
+ VERSION = "2.61.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -16680,6 +16680,40 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
16680
16680
  # TOOL 15: media_analysis
16681
16681
  # ═══════════════════════════════════════════════════════════════════════════════
16682
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
+
16683
16717
  @mcp.tool()
16684
16718
  async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]:
16685
16719
  """Project-scoped media analysis and guarded metadata publishing.
@@ -16745,6 +16779,16 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
16745
16779
  cancel_batch_job(job_id) -> stop future slices
16746
16780
  resume_batch_job(job_id) -> requeue a canceled or interrupted job
16747
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
16748
16792
 
16749
16793
  Analysis outputs stay under a davinci-resolve-mcp-analysis project root
16750
16794
  and are validated so they are never written beside source media. Executed
@@ -17043,6 +17087,17 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17043
17087
  "detect_shot_relationships",
17044
17088
  "commit_shot_relationships",
17045
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",
17046
17101
  }:
17047
17102
  root = resolve_media_analysis_output_root(
17048
17103
  project_name=project_name,
@@ -17102,6 +17157,119 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17102
17157
  if action == "db_ingest":
17103
17158
  from src.utils import analysis_store
17104
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)
17105
17273
  # Phase B — deep shot-level vision tier.
17106
17274
  if action == "deepen":
17107
17275
  from src.utils import deep_vision
@@ -17660,6 +17828,16 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17660
17828
  "detect_shot_relationships",
17661
17829
  "commit_shot_relationships",
17662
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",
17663
17841
  "get_caps",
17664
17842
  "set_caps_preset",
17665
17843
  "get_usage",
@@ -200,6 +200,45 @@ def resolve_clip_uuid(conn: sqlite3.Connection, ref: Any) -> Optional[str]:
200
200
  return None
201
201
 
202
202
 
203
+ def resolve_clip_uuid_ingesting(
204
+ project_root: str, conn: sqlite3.Connection, clip_ref: Any
205
+ ) -> Optional[str]:
206
+ """resolve_clip_uuid plus the pre-v9 fallback: when the DB has no rows
207
+ for the ref, walk clips/*/analysis.json for a matching report and ingest
208
+ it. Shared by deep_vision and the strata layers so a clip that resolves
209
+ for one resolves for all."""
210
+ clip_uuid = resolve_clip_uuid(conn, clip_ref)
211
+ if clip_uuid:
212
+ return clip_uuid
213
+ ma = _ma()
214
+ clips_root = os.path.join(project_root, "clips")
215
+ candidate = str(clip_ref or "")
216
+ if not os.path.isdir(clips_root):
217
+ return None
218
+ for entry in sorted(os.listdir(clips_root)):
219
+ report_path = os.path.join(clips_root, entry, "analysis.json")
220
+ if not os.path.isfile(report_path):
221
+ continue
222
+ try:
223
+ with open(report_path, "r", encoding="utf-8") as handle:
224
+ report = json.load(handle)
225
+ except (OSError, json.JSONDecodeError):
226
+ continue
227
+ clip_block = report.get("clip") or {}
228
+ if candidate not in (
229
+ entry,
230
+ str(clip_block.get("clip_id") or ""),
231
+ str(clip_block.get("media_id") or ""),
232
+ ma.normalize_path(clip_block.get("file_path") or ""),
233
+ ):
234
+ continue
235
+ ingest = ingest_report(project_root, report, clip_dir=os.path.join(clips_root, entry))
236
+ if ingest.get("success"):
237
+ return str(ingest["clip_uuid"])
238
+ return None
239
+ return None
240
+
241
+
203
242
  # ── ingest ────────────────────────────────────────────────────────────────────
204
243
 
205
244
 
@@ -335,9 +374,15 @@ def ingest_report(
335
374
  "SELECT created_at FROM clips WHERE clip_uuid = ?", (clip_uuid,)
336
375
  ).fetchone()
337
376
  created_at = str(existing["created_at"]) if existing else now
377
+ # True upsert — never INSERT OR REPLACE: REPLACE deletes the existing
378
+ # row first, and with PRAGMA foreign_keys=ON that cascade-deletes every
379
+ # child table (events/curves/transcript_words/story_beats included),
380
+ # wiping strata rows — human annotations among them — on every
381
+ # re-ingest. The v13 strata tables are NOT rebuilt by ingest, so the
382
+ # clips row must be updated in place.
338
383
  conn.execute(
339
384
  """
340
- INSERT OR REPLACE INTO clips
385
+ INSERT INTO clips
341
386
  (clip_uuid, clip_dir, resolve_clip_id, media_id, clip_name,
342
387
  file_path, bin_path, duration_seconds, fps, resolution,
343
388
  media_type, summary, overall_motion_level, cut_count,
@@ -345,6 +390,28 @@ def ingest_report(
345
390
  analyzed_at, vision_status, vision_committed_at,
346
391
  created_at, updated_at)
347
392
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
393
+ ON CONFLICT(clip_uuid) DO UPDATE SET
394
+ clip_dir=excluded.clip_dir,
395
+ resolve_clip_id=excluded.resolve_clip_id,
396
+ media_id=excluded.media_id,
397
+ clip_name=excluded.clip_name,
398
+ file_path=excluded.file_path,
399
+ bin_path=excluded.bin_path,
400
+ duration_seconds=excluded.duration_seconds,
401
+ fps=excluded.fps,
402
+ resolution=excluded.resolution,
403
+ media_type=excluded.media_type,
404
+ summary=excluded.summary,
405
+ overall_motion_level=excluded.overall_motion_level,
406
+ cut_count=excluded.cut_count,
407
+ shot_count=excluded.shot_count,
408
+ analysis_version=excluded.analysis_version,
409
+ depth=excluded.depth,
410
+ signature_hash=excluded.signature_hash,
411
+ analyzed_at=excluded.analyzed_at,
412
+ vision_status=excluded.vision_status,
413
+ vision_committed_at=excluded.vision_committed_at,
414
+ updated_at=excluded.updated_at
348
415
  """,
349
416
  (
350
417
  clip_uuid,
@@ -473,6 +540,12 @@ def ingest_report(
473
540
  ),
474
541
  )
475
542
 
543
+ # Word-level timestamps (v13): promote segments[*].words to rows so
544
+ # word-boundary queries never have to parse the report blob.
545
+ from src.utils import strata as _strata
546
+
547
+ words_written = _strata.ingest_transcript_words(conn, clip_uuid, transcription)
548
+
476
549
  # Sampled frames. Shot mapping uses frame_indices_used when present,
477
550
  # with a time-containment fallback (some commit paths don't record
478
551
  # which frame indices fed which shot).
@@ -576,6 +649,7 @@ def ingest_report(
576
649
  "shot_count": len(shot_entries),
577
650
  "subjective_fields_written": subj_written,
578
651
  "subjective_fields_preserved_human": subj_skipped_human,
652
+ "transcript_words_written": words_written,
579
653
  }
580
654
 
581
655
 
@@ -355,6 +355,38 @@ API_TRUTH: List[Dict[str, Any]] = [
355
355
  "tags": ["missing-method", "timeline", "edit"],
356
356
  "submit": "missing",
357
357
  },
358
+ {
359
+ "symbol": "Render in Place / bake a timeline clip to new media",
360
+ "object": "Timeline / TimelineItem / MediaPool",
361
+ "reality": "There is no scripting method for the Edit-page clip "
362
+ "context-menu action 'Render in Place', which bakes a clip "
363
+ "(including its Fusion composition and effects) into a NEW "
364
+ "rendered media file and drops that file back on the timeline "
365
+ "at the same position, replacing the source clip. No "
366
+ "Render*/Bake*/Freeze* method exists on Timeline, TimelineItem "
367
+ "or MediaPool in the Resolve scripting API reference (BMD docs) "
368
+ "or a dir() audit. NOTE the frequently-confused-but-distinct "
369
+ "sibling: the render *cache* (a temporary, non-destructive "
370
+ "cache of a clip's Color/Fusion output that reduces playback "
371
+ "load WITHOUT creating a new media file) IS scriptable — "
372
+ "TimelineItem.SetColorOutputCache / SetFusionOutputCache "
373
+ "('Render Cache Color/Fusion Output' menu actions) and "
374
+ "Graph.SetNodeCacheMode. Render in Place is the permanent, "
375
+ "media-producing bake; the render cache is the transient one.",
376
+ "recommended": "If the goal is only to reduce playback/render load, use "
377
+ "the render cache — exposed as timeline_item "
378
+ "get_color_cache/set_color_cache/get_fusion_cache/"
379
+ "set_fusion_cache and the Color-page graph node cache_mode "
380
+ "(no new media, fully reversible). If you genuinely need a "
381
+ "baked media file, render the clip's in/out range from the "
382
+ "Deliver page (proj.AddRenderJob with MarkIn/MarkOut) and "
383
+ "relink/append the result yourself, or run Render in Place "
384
+ "from the Resolve UI. There is no one-call API equivalent. "
385
+ "See issue #86.",
386
+ "tags": ["missing-method", "timeline", "render", "cache", "render-in-place", "bake"],
387
+ "submit": "missing",
388
+ "issue": 86,
389
+ },
358
390
  {
359
391
  "symbol": "Smart Bins / Power Bins creation",
360
392
  "object": "MediaPool",