davinci-resolve-mcp 2.60.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,91 @@
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
+
5
90
  ## What's New in v2.60.0
6
91
 
7
92
  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.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):
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.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.60.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.60.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.60.0"
14
+ VERSION = "2.61.0"
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",
@@ -335,9 +335,15 @@ def ingest_report(
335
335
  "SELECT created_at FROM clips WHERE clip_uuid = ?", (clip_uuid,)
336
336
  ).fetchone()
337
337
  created_at = str(existing["created_at"]) if existing else now
338
+ # True upsert — never INSERT OR REPLACE: REPLACE deletes the existing
339
+ # row first, and with PRAGMA foreign_keys=ON that cascade-deletes every
340
+ # child table (events/curves/transcript_words/story_beats included),
341
+ # wiping strata rows — human annotations among them — on every
342
+ # re-ingest. The v13 strata tables are NOT rebuilt by ingest, so the
343
+ # clips row must be updated in place.
338
344
  conn.execute(
339
345
  """
340
- INSERT OR REPLACE INTO clips
346
+ INSERT INTO clips
341
347
  (clip_uuid, clip_dir, resolve_clip_id, media_id, clip_name,
342
348
  file_path, bin_path, duration_seconds, fps, resolution,
343
349
  media_type, summary, overall_motion_level, cut_count,
@@ -345,6 +351,28 @@ def ingest_report(
345
351
  analyzed_at, vision_status, vision_committed_at,
346
352
  created_at, updated_at)
347
353
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
354
+ ON CONFLICT(clip_uuid) DO UPDATE SET
355
+ clip_dir=excluded.clip_dir,
356
+ resolve_clip_id=excluded.resolve_clip_id,
357
+ media_id=excluded.media_id,
358
+ clip_name=excluded.clip_name,
359
+ file_path=excluded.file_path,
360
+ bin_path=excluded.bin_path,
361
+ duration_seconds=excluded.duration_seconds,
362
+ fps=excluded.fps,
363
+ resolution=excluded.resolution,
364
+ media_type=excluded.media_type,
365
+ summary=excluded.summary,
366
+ overall_motion_level=excluded.overall_motion_level,
367
+ cut_count=excluded.cut_count,
368
+ shot_count=excluded.shot_count,
369
+ analysis_version=excluded.analysis_version,
370
+ depth=excluded.depth,
371
+ signature_hash=excluded.signature_hash,
372
+ analyzed_at=excluded.analyzed_at,
373
+ vision_status=excluded.vision_status,
374
+ vision_committed_at=excluded.vision_committed_at,
375
+ updated_at=excluded.updated_at
348
376
  """,
349
377
  (
350
378
  clip_uuid,
@@ -473,6 +501,12 @@ def ingest_report(
473
501
  ),
474
502
  )
475
503
 
504
+ # Word-level timestamps (v13): promote segments[*].words to rows so
505
+ # word-boundary queries never have to parse the report blob.
506
+ from src.utils import strata as _strata
507
+
508
+ words_written = _strata.ingest_transcript_words(conn, clip_uuid, transcription)
509
+
476
510
  # Sampled frames. Shot mapping uses frame_indices_used when present,
477
511
  # with a time-containment fallback (some commit paths don't record
478
512
  # which frame indices fed which shot).
@@ -576,6 +610,7 @@ def ingest_report(
576
610
  "shot_count": len(shot_entries),
577
611
  "subjective_fields_written": subj_written,
578
612
  "subjective_fields_preserved_human": subj_skipped_human,
613
+ "transcript_words_written": words_written,
579
614
  }
580
615
 
581
616
 
@@ -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",