davinci-resolve-mcp 2.41.0 → 2.42.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,34 @@
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.42.0
6
+
7
+ Deep shot-level vision tier — Phase B of the analysis + edit-engine program.
8
+ Opt-in, estimate-first per-shot field filling for the Visual / Content /
9
+ Production / Editorial / Cuttability groups the shot pages already render.
10
+
11
+ - **Added** `src/utils/deep_vision.py` and three `media_analysis` actions:
12
+ `deepen` (estimate → confirm_token → deferred host-vision payload per
13
+ clip/shot), `commit_shot_vision` (writes `vision_deep_v1` provenance rows,
14
+ updates the canonical blob, re-exports analysis.json in lockstep), and
15
+ `vision_pending_sweep` (lists clips stuck in `pending_host_analysis`;
16
+ `reoffer=true` returns the stored payload, `expire=true` stamps them).
17
+ - **Added** deep depth to the analyze flow: `depth="deep"` extends the
18
+ deferred payload with the per-shot schema and requires `confirm_deep=true`
19
+ after a token-cost estimate. Caps pre-call refusal applies on both paths.
20
+ - **Added** panel affordances: `Deepen analysis` (clip view) and `Deepen this
21
+ shot` (shot view) copy ready-made chat prompts, per the chat-first UX.
22
+ Shots with no sampled frames on disk get 1–2 frames re-extracted via
23
+ ffmpeg, downscaled per caps (source media stays read-only).
24
+ - **Fixed** a provenance bug in the analysis store: a source re-deriving an
25
+ unchanged value no longer re-attributes the row (a deep pass would
26
+ otherwise claim every untouched field as `vision_deep_v1`).
27
+ - **Validation**: full offline suite (1081 tests; 15 new), and a real
28
+ end-to-end deep pass on the 2026-05-17 sample clip — estimate → confirm →
29
+ frames read by the host chat → commit → rows/blob/export parity → fields
30
+ visible in the panel shot view. Control-panel guide screenshots
31
+ regenerated from the live panel.
32
+
5
33
  ## What's New in v2.41.0
6
34
 
7
35
  DB-canonical clip analysis (C1) — Phase A of the analysis + edit-engine
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.41.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.42.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-33%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -494,8 +494,29 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
494
494
  `cancel_batch_job`, `resume_batch_job`, `review_timeline_markers`,
495
495
  `cleanup_artifacts`, `db_status`, `db_ingest`, `get_panel_state`,
496
496
  `set_panel_state`, `session_start_context`, `update_clip_field`,
497
- `update_shot_field`, `get_field_history`, `revert_field`, and
498
- `list_corrections`.
497
+ `update_shot_field`, `get_field_history`, `revert_field`,
498
+ `list_corrections`, `deepen`, `commit_shot_vision`, and
499
+ `vision_pending_sweep`.
500
+
501
+ **Deep shot-level vision tier (v2.42.0+).** Opt-in, estimate-first. Two
502
+ entry points share one per-shot schema (Visual / Content / Production /
503
+ Editorial / Cuttability / description / confidence):
504
+ - `depth="deep"` on any analyze action extends the deferred host-vision
505
+ payload with `deep_shot_schema`; each `shot_descriptions` entry must carry
506
+ the field groups. The first deep run returns `confirmation_required` with
507
+ a token-cost estimate — re-call with `confirm_deep=true`. Caps still apply.
508
+ - `deepen(clip_id|clip_dir, shot_index?|shot_indices?)` runs the pass
509
+ post-hoc on an already-analyzed clip. First call returns the estimate +
510
+ `confirm_token`; re-call with the token to get the deferred payload, read
511
+ its `frame_paths`, and commit via
512
+ `commit_shot_vision(clip_id, shots=[{shot_index, ...groups...}],
513
+ vision_token)`. Deep fields land as `vision_deep_v1` provenance rows;
514
+ human corrections always survive. Shots with no sampled frames on disk get
515
+ 1–2 frames re-extracted via ffmpeg (read-only on source media).
516
+ `vision_pending_sweep(expire?, max_age_days?, reoffer?)` lists clips stuck
517
+ in `pending_host_analysis`; `reoffer=true` returns each clip's stored
518
+ deferred payload to finish the run, `expire=true` stamps them
519
+ `expired_host_analysis` so pendings never linger silently.
499
520
 
500
521
  **DB-canonical analysis store (v2.41.0+).** The per-project SQLite DB
501
522
  (`_soul/timeline_brain.sqlite`, schema v9+) is the source of truth for clip
@@ -86,7 +86,10 @@ transcripts once the search index is built.
86
86
 
87
87
  The clip view shows the full summary, tags, star rating, editorial notes, and
88
88
  a contact sheet of every detected shot. From here you can open the clip in
89
- Resolve, jump to the transcript, or click into any shot.
89
+ Resolve, jump to the transcript, or click into any shot. `Deepen analysis`
90
+ copies a ready-made chat prompt that asks your MCP session to run the opt-in
91
+ deep shot pass (cost estimate first, then per-shot Visual / Content /
92
+ Editorial / Cuttability fields).
90
93
 
91
94
  ### Transcript
92
95
 
@@ -107,7 +110,9 @@ start", "After cut", "Flash frame", "Motion peak"). Subjective fields are
107
110
  editable inline; edits are kept with the clip's analysis and merged on top of
108
111
  future re-analysis so human notes survive fresh vision runs. `Open in Resolve`
109
112
  jumps straight to the clip in the source viewer with the shot's mark in/out
110
- set.
113
+ set. The field groups (Visual, Content, Production, Editorial, Cuttability)
114
+ are filled by the opt-in deep vision pass — `Deepen this shot` copies a chat
115
+ prompt that runs it for just this shot, estimate first.
111
116
 
112
117
  ### Media → History
113
118
 
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.41.0"
38
+ VERSION = "2.42.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.41.0",
3
+ "version": "2.42.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -8390,7 +8390,7 @@ HTML = r"""<!doctype html>
8390
8390
  <span class="meta">${formatDuration(card.duration_seconds)} · ${shots.length} shots</span>
8391
8391
  ${cls.primary_use ? `<span class="review-chip">${escapeHtml(cls.primary_use)}</span>` : ''}
8392
8392
  ${cls.select_potential ? `<span class="review-chip ${selectChipClass(cls.select_potential)}">${escapeHtml(cls.select_potential)}</span>` : ''}
8393
- <div class="actions"><button class="secondary" id="reviewClipTranscriptBtn">Transcript</button><button class="secondary" id="reviewClipOpenInResolveBtn">Open in Resolve</button></div>
8393
+ <div class="actions"><button class="secondary" id="reviewClipTranscriptBtn">Transcript</button><button class="secondary" id="reviewClipOpenInResolveBtn">Open in Resolve</button><button class="secondary" data-copy-chat-prompt="${escapeHtml(`Deepen the analysis of clip “${card.clip_name || state.review.currentClipId}”: call media_analysis(action="deepen", params={"clip_id": "${card.clip_id || state.review.currentClipId}"}), show me the cost estimate, and after I confirm, read the frames and commit the per-shot fields via commit_shot_vision.`)}">Deepen analysis</button></div>
8394
8394
  `;
8395
8395
  $('reviewClipSummary').textContent = data.clip_summary || data.clip_summary_oneliner || '';
8396
8396
  $('reviewClipTags').innerHTML = tags.map(t => `<span class="review-chip">${escapeHtml(t)}</span>`).join('');
@@ -9440,6 +9440,7 @@ HTML = r"""<!doctype html>
9440
9440
  <span class="meta">${escapeHtml(shot.description || '')}</span>
9441
9441
  <div class="actions">
9442
9442
  <button class="secondary" id="reviewShotOpenInResolveBtn">Open in Resolve</button>
9443
+ <button class="secondary" data-copy-chat-prompt="${escapeHtml(`Deepen shot ${shotIndex} of clip “${(state.review.currentClipData && state.review.currentClipData.card && state.review.currentClipData.card.clip_name) || clipId}”: call media_analysis(action="deepen", params={"clip_id": "${clipId}", "shot_index": ${shotIndex}}), show me the cost estimate, and after I confirm, read the frames and commit the per-shot fields via commit_shot_vision.`)}">Deepen this shot</button>
9443
9444
  <button id="reviewShotEditToggleBtn" ${editing ? '' : 'class="secondary"'}>${editToggleLabel}</button>
9444
9445
  </div>
9445
9446
  `;
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.41.0"
83
+ VERSION = "2.42.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.41.0"
14
+ VERSION = "2.42.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -15020,6 +15020,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15020
15020
  # C1 — DB-canonical analysis store.
15021
15021
  "db_status",
15022
15022
  "db_ingest",
15023
+ # Phase B — deep shot-level vision tier.
15024
+ "deepen",
15025
+ "commit_shot_vision",
15026
+ "vision_pending_sweep",
15023
15027
  }:
15024
15028
  root = resolve_media_analysis_output_root(
15025
15029
  project_name=project_name,
@@ -15079,6 +15083,42 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15079
15083
  if action == "db_ingest":
15080
15084
  from src.utils import analysis_store
15081
15085
  return analysis_store.ingest_project(project_root)
15086
+ # Phase B — deep shot-level vision tier.
15087
+ if action == "deepen":
15088
+ from src.utils import deep_vision
15089
+ clip_ref = p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir") or p.get("file_path") or p.get("filePath")
15090
+ if not clip_ref:
15091
+ return _err("deepen requires clip_id, clip_dir, or file_path")
15092
+ raw_indices = p.get("shot_indices") or p.get("shotIndices")
15093
+ if raw_indices is None and p.get("shot_index") is not None:
15094
+ raw_indices = [p.get("shot_index")]
15095
+ return deep_vision.deepen_clip(
15096
+ project_root,
15097
+ clip_ref=clip_ref,
15098
+ shot_indices=[int(i) for i in raw_indices] if raw_indices else None,
15099
+ confirm_token=p.get("confirm_token") or p.get("confirmToken"),
15100
+ job_id=p.get("job_id") or p.get("jobId"),
15101
+ )
15102
+ if action == "commit_shot_vision":
15103
+ from src.utils import deep_vision
15104
+ clip_ref = p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir") or p.get("file_path") or p.get("filePath")
15105
+ if not clip_ref:
15106
+ return _err("commit_shot_vision requires clip_id, clip_dir, or file_path")
15107
+ return deep_vision.commit_shot_vision(
15108
+ project_root,
15109
+ shots=p.get("shots"),
15110
+ vision_token=p.get("vision_token") or p.get("visionToken"),
15111
+ clip_ref=clip_ref,
15112
+ author=p.get("author") or "host_chat",
15113
+ )
15114
+ if action == "vision_pending_sweep":
15115
+ from src.utils import deep_vision
15116
+ return deep_vision.vision_pending_sweep(
15117
+ project_root,
15118
+ expire=_media_analysis_bool(p.get("expire"), False),
15119
+ max_age_days=p.get("max_age_days") or p.get("maxAgeDays"),
15120
+ reoffer=_media_analysis_bool(p.get("reoffer"), False),
15121
+ )
15082
15122
  if action in {"build_index", "rebuild_index"}:
15083
15123
  return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath"))
15084
15124
  if action == "index_status":
@@ -15490,6 +15530,9 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15490
15530
  "list_corrections",
15491
15531
  "db_status",
15492
15532
  "db_ingest",
15533
+ "deepen",
15534
+ "commit_shot_vision",
15535
+ "vision_pending_sweep",
15493
15536
  "get_caps",
15494
15537
  "set_caps_preset",
15495
15538
  "get_usage",
@@ -253,7 +253,10 @@ def _write_subjective(
253
253
  value_json = _dumps(value)
254
254
  existing = current.get(field_path)
255
255
  if existing is not None:
256
- if str(existing["value_json"]) == value_json and str(existing["source"]) == source:
256
+ # Identical value keep the existing row and its provenance. A
257
+ # different source re-deriving the same value must not re-attribute
258
+ # it (a deep pass would otherwise claim every unchanged field).
259
+ if str(existing["value_json"]) == value_json:
257
260
  continue
258
261
  if (
259
262
  str(existing["source"]) == HUMAN_SOURCE
@@ -0,0 +1,643 @@
1
+ """Deep shot-level vision tier (Phase B of the analysis program).
2
+
3
+ Fills the per-shot Visual / Content / Production / Editorial / Cuttability
4
+ field groups (v2 shot schema spec §3.2–3.8) via the same deferred-payload
5
+ host-vision pattern as standard analysis: the server prepares frames + a
6
+ schema, the host chat reads the frames and commits JSON back.
7
+
8
+ Two entry points share the schema:
9
+
10
+ - ``depth="deep"`` on analyze extends the single-pass payload (handled in
11
+ media_analysis.build_host_chat_paths_payload via deep_shot_schema()).
12
+ - ``deepen`` is the post-hoc per-clip/per-shot pass for already-analyzed
13
+ clips. It is estimate-first: the first call returns the frame/token cost
14
+ and a confirm_token; the second call (with the token) returns the deferred
15
+ payload. Both paths pass the caps pre-call refusal — confirmation never
16
+ bypasses budgets.
17
+
18
+ Deep results commit via ``commit_shot_vision``: subjective rows are written
19
+ with source ``vision_deep_v1`` (human rows still win), the canonical report
20
+ blob is updated, and analysis.json re-exports in lockstep.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import time
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ from src.utils import analysis_store, timeline_brain_db
31
+
32
+ DEEP_SHOT_SCHEMA_REFERENCE = "davinci_resolve_mcp.shot_deep_analysis.v1"
33
+ DEEP_SOURCE = "vision_deep_v1"
34
+
35
+ # Threshold above which a shot gets one extra extracted frame for the deep pass.
36
+ LONG_SHOT_SECONDS = 15.0
37
+
38
+ # Per-shot field groups (v2-shot-schema-spec §3.2–3.8). Values are the schema
39
+ # template shown to the host chat; enums are listed inline.
40
+ DEEP_SHOT_FIELD_GROUPS: Dict[str, Any] = {
41
+ "visual": {
42
+ "shot_size": "wide|medium_wide|medium|medium_close|close|extreme_close|insert|establishing|other",
43
+ "framing": "single|two_shot|group|crowd|empty|insert|establishing|abstract",
44
+ "camera_height": "eye_level|high_angle|low_angle|birds_eye|dutch|unknown",
45
+ "camera_motion": "locked|pan|tilt|dolly|handheld|crane|drone|zoom|composite|other",
46
+ "motion_direction": "left|right|up|down|in|out|clockwise|counter_clockwise|none",
47
+ "depth_of_field": "deep|shallow|rack_focus|unknown",
48
+ "lens_character": "wide|normal|tele|fisheye|unknown",
49
+ "lighting": "natural|high_key|low_key|practical|backlit|silhouette|mixed|unknown",
50
+ "color_mood": "warm|cool|neutral|desaturated|saturated|monochrome|unnatural|unknown",
51
+ "composition_notes": "<short free text>",
52
+ },
53
+ "content": {
54
+ "primary_subject": {
55
+ "type": "person|object|landscape|interior|vehicle|animal|text_graphic|abstract",
56
+ "description": "<one phrase>",
57
+ "performance": "null unless type==person: {eye_line: to_camera|off_left|off_right|down|up|closed|unknown, energy: low|medium|high, emotional_register: <short free text>}",
58
+ },
59
+ "secondary_subjects": ["<[{type, description}]>"],
60
+ "action": "<one sentence — what is happening>",
61
+ "location": "<one sentence — where this is>",
62
+ "visible_text": ["<readable on-screen text>"],
63
+ "objects_of_note": ["<notable props/elements>"],
64
+ "audio_character": "silence|sync_dialogue|vo_dialogue|music|ambient|sfx|mixed|unknown",
65
+ },
66
+ "production": {
67
+ "composite_shot": "<bool — split-screen / PiP / multi-angle composite>",
68
+ "composite_panels": "null unless composite_shot: [{region, primary_subject, action}]",
69
+ "vfx_present": "none|minor|major|unknown",
70
+ },
71
+ "editorial": {
72
+ "editorial_role": "establishing|coverage|reaction|insert|transition|b_roll|montage_element|titles_or_graphics|bumper|other",
73
+ "select_potential": "low|medium|high",
74
+ "best_moment": "null if the shot is a sustained flat beat, else {time_seconds, why}",
75
+ "best_moment_present": "<bool>",
76
+ "pacing": "still|moderate|kinetic|variable",
77
+ "stillness_type": "held_tension|quiet|contemplative|transitional|dead_air|unknown — null unless pacing is still/variable",
78
+ "pacing_note": "<free text or null — only when pacing is still/variable>",
79
+ },
80
+ "cuttability": {
81
+ "cut_in": {"quality": "poor|ok|clean", "notes": "<short>"},
82
+ "cut_out": {"quality": "poor|ok|clean", "notes": "<short>"},
83
+ "match_action_in": "<bool — could receive a match cut>",
84
+ "match_action_out": "<bool — could send a match cut>",
85
+ "cut_compatibility_hints": "<free text>",
86
+ },
87
+ "description": "<1-3 sentences, editorially useful, colleague-style note>",
88
+ "confidence": {
89
+ "visual": "low|medium|high",
90
+ "content": "low|medium|high",
91
+ "audio": "low|medium|high",
92
+ "editorial": "low|medium|high",
93
+ "cuttability": "low|medium|high",
94
+ },
95
+ }
96
+
97
+ DEEP_SHOT_PROMPT = (
98
+ "Deep per-shot editorial analysis. For EACH shot listed in shot_table, look "
99
+ "only at the frames whose indices appear in that shot's frame_indices and "
100
+ "fill every field group in deep_shot_schema (visual, content, production, "
101
+ "editorial, cuttability, description, confidence). Use the enum values "
102
+ "verbatim; use 'unknown' or null when the frames do not support a claim — "
103
+ "hedge identity/intent/value when frame evidence is thin. Return strict "
104
+ "JSON only: {\"shots\": [{\"shot_index\": <int>, ...field groups...}]}."
105
+ )
106
+
107
+
108
+ def _ma():
109
+ from src.utils import media_analysis
110
+
111
+ return media_analysis
112
+
113
+
114
+ def deep_shot_schema() -> Dict[str, Any]:
115
+ """The per-shot deep field groups (shared by deep-depth analyze + deepen)."""
116
+ return json.loads(json.dumps(DEEP_SHOT_FIELD_GROUPS))
117
+
118
+
119
+ def _now() -> str:
120
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
121
+
122
+
123
+ def _clip_context(project_root: str, clip_ref: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
124
+ """Resolve a clip to (context, error). Auto-ingests the JSON report when
125
+ the DB has no rows yet (pre-v9 analysis roots)."""
126
+ ma = _ma()
127
+ conn = timeline_brain_db.connect(project_root)
128
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
129
+ if not clip_uuid:
130
+ # Pre-v9 report? Find the analysis.json by walking clips/ and ingest it.
131
+ clips_root = os.path.join(project_root, "clips")
132
+ candidate = str(clip_ref or "")
133
+ if os.path.isdir(clips_root):
134
+ for entry in sorted(os.listdir(clips_root)):
135
+ report_path = os.path.join(clips_root, entry, "analysis.json")
136
+ if not os.path.isfile(report_path):
137
+ continue
138
+ try:
139
+ with open(report_path, "r", encoding="utf-8") as handle:
140
+ report = json.load(handle)
141
+ except (OSError, json.JSONDecodeError):
142
+ continue
143
+ clip_block = report.get("clip") or {}
144
+ if candidate not in (
145
+ entry,
146
+ str(clip_block.get("clip_id") or ""),
147
+ str(clip_block.get("media_id") or ""),
148
+ ma.normalize_path(clip_block.get("file_path") or ""),
149
+ ):
150
+ continue
151
+ ingest = analysis_store.ingest_report(
152
+ project_root, report, clip_dir=os.path.join(clips_root, entry)
153
+ )
154
+ if ingest.get("success"):
155
+ clip_uuid = ingest["clip_uuid"]
156
+ break
157
+ if not clip_uuid:
158
+ return None, f"No analyzed clip found for {clip_ref!r} (run db_ingest if this is an older analysis root)"
159
+ clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
160
+ shots = conn.execute(
161
+ "SELECT * FROM shots WHERE clip_uuid = ? ORDER BY shot_index", (clip_uuid,)
162
+ ).fetchall()
163
+ frames = conn.execute(
164
+ "SELECT * FROM frames WHERE clip_uuid = ? ORDER BY frame_index", (clip_uuid,)
165
+ ).fetchall()
166
+ if not shots:
167
+ return None, "Clip has no detected shots — deep shot analysis needs a standard analysis with cut detection first"
168
+ return {
169
+ "clip_uuid": clip_uuid,
170
+ "clip": dict(clip_row) if clip_row else {},
171
+ "shots": [dict(s) for s in shots],
172
+ "frames": [dict(f) for f in frames],
173
+ }, None
174
+
175
+
176
+ def _select_shots(shots: List[Dict[str, Any]], shot_indices: Optional[List[int]]) -> Tuple[List[Dict[str, Any]], List[int]]:
177
+ if not shot_indices:
178
+ return shots, []
179
+ wanted = {int(i) for i in shot_indices}
180
+ selected = [s for s in shots if int(s["shot_index"]) in wanted]
181
+ missing = sorted(wanted - {int(s["shot_index"]) for s in selected})
182
+ return selected, missing
183
+
184
+
185
+ def _frames_for_shot(shot: Dict[str, Any], frames: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
186
+ rows = [f for f in frames if f.get("shot_uuid") == shot["shot_uuid"]]
187
+ if rows:
188
+ return rows
189
+ start, end = shot.get("time_seconds_start"), shot.get("time_seconds_end")
190
+ if start is None or end is None:
191
+ return []
192
+ return [
193
+ f for f in frames
194
+ if f.get("time_seconds") is not None and start <= float(f["time_seconds"]) < end
195
+ ]
196
+
197
+
198
+ def _extract_frame(source: str, time_seconds: float, out_path: str) -> Optional[str]:
199
+ """Extract one frame with ffmpeg (read-only on source). None on failure."""
200
+ ma = _ma()
201
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
202
+ code, _out, _err = ma._run_command([
203
+ "ffmpeg", "-y", "-ss", f"{max(0.0, time_seconds):.3f}", "-i", source,
204
+ "-frames:v", "1", "-q:v", "3", out_path,
205
+ ])
206
+ if code != 0 or not os.path.isfile(out_path):
207
+ return None
208
+ try:
209
+ from src.utils import analysis_caps
210
+
211
+ caps = ma._resolve_active_caps()
212
+ analysis_caps.downscale_frame_if_needed(out_path, caps.max_frame_dim_pixels)
213
+ except Exception:
214
+ pass
215
+ return out_path
216
+
217
+
218
+ def _confirm_token_for(clip_uuid: str, shot_uuids: List[str]) -> str:
219
+ return _ma().short_hash(f"deepen:{clip_uuid}:{','.join(sorted(shot_uuids))}", 16)
220
+
221
+
222
+ def _vision_token_for(clip_uuid: str, shot_uuids: List[str]) -> str:
223
+ return _ma().short_hash(f"deep_vision:{clip_uuid}:{','.join(sorted(shot_uuids))}", 16)
224
+
225
+
226
+ def deepen_clip(
227
+ project_root: str,
228
+ *,
229
+ clip_ref: Any,
230
+ shot_indices: Optional[List[int]] = None,
231
+ confirm_token: Optional[str] = None,
232
+ job_id: Optional[str] = None,
233
+ ) -> Dict[str, Any]:
234
+ """Estimate-first deep pass over a clip (or selected shots).
235
+
236
+ First call (no confirm_token) returns the cost estimate + confirm_token.
237
+ Second call with the token returns the deferred host-vision payload.
238
+ """
239
+ ma = _ma()
240
+ context, err = _clip_context(project_root, clip_ref)
241
+ if err:
242
+ return {"success": False, "error": err}
243
+ clip_uuid = context["clip_uuid"]
244
+ clip = context["clip"]
245
+ shots, missing = _select_shots(context["shots"], shot_indices)
246
+ if missing:
247
+ return {"success": False, "error": f"shot_index not found: {missing}", "available": [s['shot_index'] for s in context['shots']]}
248
+ if not shots:
249
+ return {"success": False, "error": "No shots selected"}
250
+
251
+ shot_uuids = [s["shot_uuid"] for s in shots]
252
+ expected_confirm = _confirm_token_for(clip_uuid, shot_uuids)
253
+
254
+ # Frame plan: existing on-disk frames per shot; shots with none get
255
+ # extraction placeholders; long shots get one extra mid-late frame.
256
+ source = clip.get("file_path")
257
+ frame_plan: List[Dict[str, Any]] = []
258
+ total_frames = 0
259
+ for shot in shots:
260
+ rows = _frames_for_shot(shot, context["frames"])
261
+ on_disk = [r for r in rows if r.get("frame_path") and os.path.isfile(str(r["frame_path"]))]
262
+ start = float(shot.get("time_seconds_start") or 0.0)
263
+ end = float(shot.get("time_seconds_end") or start)
264
+ duration = max(0.0, end - start)
265
+ to_extract: List[float] = []
266
+ if not on_disk:
267
+ to_extract.append(start + duration / 2.0)
268
+ if duration > LONG_SHOT_SECONDS:
269
+ to_extract.append(start + duration * 2.0 / 3.0)
270
+ count = len(on_disk) + len(to_extract)
271
+ total_frames += count
272
+ frame_plan.append({
273
+ "shot_index": shot["shot_index"],
274
+ "shot_uuid": shot["shot_uuid"],
275
+ "existing_frames": len(on_disk),
276
+ "frames_to_extract": len(to_extract),
277
+ "extract_times": to_extract,
278
+ "rows": on_disk,
279
+ })
280
+
281
+ estimated_tokens = total_frames * ma.AVG_VISION_TOKENS_PER_FRAME
282
+ refusal = ma._check_caps_pre_call(
283
+ project_root=project_root,
284
+ estimated_vision_tokens=estimated_tokens,
285
+ clip_id=clip.get("resolve_clip_id") or clip_uuid,
286
+ job_id=job_id,
287
+ )
288
+ if refusal is not None:
289
+ return refusal
290
+
291
+ estimate = {
292
+ "clip_uuid": clip_uuid,
293
+ "clip_name": clip.get("clip_name"),
294
+ "shot_count": len(shots),
295
+ "frame_count": total_frames,
296
+ "estimated_vision_tokens": estimated_tokens,
297
+ "tokens_per_frame_assumption": ma.AVG_VISION_TOKENS_PER_FRAME,
298
+ }
299
+
300
+ if confirm_token != expected_confirm:
301
+ return {
302
+ "success": True,
303
+ "status": "confirmation_required",
304
+ "estimate": estimate,
305
+ "confirm_token": expected_confirm,
306
+ "note": (
307
+ "Deep shot analysis is opt-in and costs vision tokens. Re-call "
308
+ "media_analysis(action='deepen') with this confirm_token to "
309
+ "proceed. Confirmation does not bypass caps."
310
+ ),
311
+ }
312
+
313
+ # Confirmed: materialize missing frames, then build the deferred payload.
314
+ clip_dir_name = clip.get("clip_dir")
315
+ clip_dir = os.path.join(project_root, "clips", clip_dir_name) if clip_dir_name else None
316
+ shot_table: List[Dict[str, Any]] = []
317
+ frame_metadata: List[Dict[str, Any]] = []
318
+ frame_paths: List[str] = []
319
+ synthetic_index = 100000 # extraction-only frames get indices above sampled ones
320
+ for plan in frame_plan:
321
+ indices: List[int] = []
322
+ for row in plan["rows"]:
323
+ path = ma.normalize_path(str(row["frame_path"]))
324
+ if path not in frame_paths:
325
+ frame_paths.append(path)
326
+ frame_metadata.append({
327
+ "frame_index": int(row["frame_index"]),
328
+ "frame_path": path,
329
+ "time_seconds": row.get("time_seconds"),
330
+ "selection_reason": row.get("selection_reason"),
331
+ "shot_index": plan["shot_index"],
332
+ })
333
+ indices.append(int(row["frame_index"]))
334
+ for t in plan["extract_times"]:
335
+ if not source or not os.path.isfile(source):
336
+ continue
337
+ if not clip_dir:
338
+ continue
339
+ out_path = os.path.join(clip_dir, "frames", f"deep_shot{int(plan['shot_index']):03d}_{int(t * 1000):08d}.jpg")
340
+ extracted = out_path if os.path.isfile(out_path) else _extract_frame(source, t, out_path)
341
+ if not extracted:
342
+ continue
343
+ synthetic_index += 1
344
+ path = ma.normalize_path(extracted)
345
+ frame_paths.append(path)
346
+ frame_metadata.append({
347
+ "frame_index": synthetic_index,
348
+ "frame_path": path,
349
+ "time_seconds": t,
350
+ "selection_reason": "deep_pass_extraction",
351
+ "shot_index": plan["shot_index"],
352
+ })
353
+ indices.append(synthetic_index)
354
+ shot = next(s for s in shots if s["shot_index"] == plan["shot_index"])
355
+ shot_table.append({
356
+ "shot_index": int(shot["shot_index"]),
357
+ "shot_uuid": shot["shot_uuid"],
358
+ "time_seconds_start": shot.get("time_seconds_start"),
359
+ "time_seconds_end": shot.get("time_seconds_end"),
360
+ "current_description": shot.get("description"),
361
+ "frame_indices": indices,
362
+ })
363
+
364
+ vision_token = _vision_token_for(clip_uuid, shot_uuids)
365
+ commit_params: Dict[str, Any] = {
366
+ "vision_token": vision_token,
367
+ "shots": "<host chat: [{shot_index, ...deep_shot_schema groups...}]>",
368
+ "analysis_root": project_root,
369
+ }
370
+ if clip.get("resolve_clip_id"):
371
+ commit_params["clip_id"] = clip["resolve_clip_id"]
372
+ elif clip_dir_name:
373
+ commit_params["clip_dir"] = clip_dir_name
374
+
375
+ return {
376
+ "success": True,
377
+ "status": "pending_host_analysis",
378
+ "provider": "host_chat_paths",
379
+ "mode": "deep_shots",
380
+ "vision_token": vision_token,
381
+ "estimate": estimate,
382
+ "clip": {
383
+ "clip_uuid": clip_uuid,
384
+ "clip_id": clip.get("resolve_clip_id"),
385
+ "clip_name": clip.get("clip_name"),
386
+ "file_path": source,
387
+ },
388
+ "frame_count": len(frame_paths),
389
+ "frame_paths": frame_paths,
390
+ "frame_metadata": frame_metadata,
391
+ "shot_table": shot_table,
392
+ "deep_shot_schema": deep_shot_schema(),
393
+ "schema_reference": DEEP_SHOT_SCHEMA_REFERENCE,
394
+ "prompt": DEEP_SHOT_PROMPT,
395
+ "commit_action": {
396
+ "tool": "media_analysis",
397
+ "action": "commit_shot_vision",
398
+ "params": commit_params,
399
+ },
400
+ "instructions": (
401
+ "Read every file under frame_paths as a local image. For each entry in "
402
+ "shot_table, produce one object in `shots` with its shot_index and the "
403
+ "deep_shot_schema field groups, grounded ONLY in the frames listed in "
404
+ "that shot's frame_indices. Then call the tool in commit_action with "
405
+ "`shots` set to that array. Skipping the commit leaves the deep pass "
406
+ "incomplete — surface that rather than silently stopping."
407
+ ),
408
+ }
409
+
410
+
411
+ _DEEP_GROUP_KEYS = ("visual", "content", "production", "editorial", "cuttability")
412
+
413
+
414
+ def commit_shot_vision(
415
+ project_root: str,
416
+ *,
417
+ shots: Any,
418
+ vision_token: Optional[str] = None,
419
+ clip_ref: Any = None,
420
+ author: str = "host_chat",
421
+ ) -> Dict[str, Any]:
422
+ """Commit a deep per-shot payload: subjective rows (source vision_deep_v1),
423
+ canonical blob update, and lockstep analysis.json re-export."""
424
+ if isinstance(shots, str):
425
+ try:
426
+ shots = json.loads(shots)
427
+ except json.JSONDecodeError as exc:
428
+ return {"success": False, "error": f"shots was a string but not valid JSON: {exc}"}
429
+ if isinstance(shots, dict) and isinstance(shots.get("shots"), list):
430
+ shots = shots["shots"]
431
+ if not isinstance(shots, list) or not shots:
432
+ return {"success": False, "error": "commit_shot_vision requires `shots`: a non-empty array of per-shot objects"}
433
+
434
+ context, err = _clip_context(project_root, clip_ref)
435
+ if err:
436
+ return {"success": False, "error": err}
437
+ clip_uuid = context["clip_uuid"]
438
+ db_shots = {int(s["shot_index"]): s for s in context["shots"]}
439
+ by_uuid = {s["shot_uuid"]: s for s in context["shots"]}
440
+
441
+ # Token check: recompute over the shots being committed.
442
+ target_uuids = []
443
+ normalized_entries: List[Tuple[Dict[str, Any], Dict[str, Any]]] = [] # (db_shot, payload_entry)
444
+ for entry in shots:
445
+ if not isinstance(entry, dict):
446
+ continue
447
+ db_shot = None
448
+ if entry.get("shot_uuid") and entry["shot_uuid"] in by_uuid:
449
+ db_shot = by_uuid[entry["shot_uuid"]]
450
+ else:
451
+ try:
452
+ db_shot = db_shots.get(int(entry.get("shot_index")))
453
+ except (TypeError, ValueError):
454
+ db_shot = None
455
+ if db_shot is None:
456
+ return {"success": False, "error": f"shot not found for entry: {entry.get('shot_uuid') or entry.get('shot_index')!r}"}
457
+ target_uuids.append(db_shot["shot_uuid"])
458
+ normalized_entries.append((db_shot, entry))
459
+ if not normalized_entries:
460
+ return {"success": False, "error": "No valid shot entries in payload"}
461
+ expected = _vision_token_for(clip_uuid, target_uuids)
462
+ if vision_token and str(vision_token) != expected:
463
+ return {
464
+ "success": False,
465
+ "error": "vision_token mismatch; the clip was re-analyzed or the shot selection changed since the deepen payload was issued.",
466
+ "expected_vision_token": expected,
467
+ "received_vision_token": vision_token,
468
+ }
469
+
470
+ ma = _ma()
471
+ conn = timeline_brain_db.connect(project_root)
472
+ report_row = conn.execute(
473
+ "SELECT report_json FROM analysis_reports WHERE clip_uuid = ?", (clip_uuid,)
474
+ ).fetchone()
475
+ if not report_row:
476
+ return {"success": False, "error": "No canonical report for clip — run db_ingest first"}
477
+ report = json.loads(report_row["report_json"])
478
+ visual = report.get("visual") if isinstance(report.get("visual"), dict) else {}
479
+ shot_entries = visual.get("shot_descriptions") if isinstance(visual.get("shot_descriptions"), list) else []
480
+ entries_by_index: Dict[int, Dict[str, Any]] = {}
481
+ for se in shot_entries:
482
+ if isinstance(se, dict) and se.get("shot_index") is not None:
483
+ try:
484
+ entries_by_index[int(se["shot_index"])] = se
485
+ except (TypeError, ValueError):
486
+ continue
487
+
488
+ updated = 0
489
+ for db_shot, entry in normalized_entries:
490
+ target = entries_by_index.get(int(db_shot["shot_index"]))
491
+ if target is None:
492
+ target = {
493
+ "shot_index": int(db_shot["shot_index"]),
494
+ "time_seconds_start": db_shot.get("time_seconds_start"),
495
+ "time_seconds_end": db_shot.get("time_seconds_end"),
496
+ "description": db_shot.get("description") or "",
497
+ "qc_flags": [],
498
+ }
499
+ shot_entries.append(target)
500
+ entries_by_index[int(db_shot["shot_index"])] = target
501
+ for group in _DEEP_GROUP_KEYS:
502
+ if isinstance(entry.get(group), dict):
503
+ target[group] = entry[group]
504
+ if isinstance(entry.get("confidence"), dict):
505
+ target["confidence"] = entry["confidence"]
506
+ description = entry.get("description")
507
+ if isinstance(description, str) and description.strip():
508
+ target["description"] = description.strip()
509
+ updated += 1
510
+
511
+ visual["shot_descriptions"] = shot_entries
512
+ report["visual"] = visual
513
+ report["deep_shots_committed_at"] = _now()
514
+
515
+ # Rows first (canonical), deep source label; human rows preserved inside.
516
+ ingest = analysis_store.ingest_report(
517
+ project_root,
518
+ report,
519
+ clip_dir=os.path.join(project_root, "clips", context["clip"].get("clip_dir") or ""),
520
+ author=author,
521
+ source=DEEP_SOURCE,
522
+ )
523
+ if not ingest.get("success"):
524
+ return {"success": False, "error": f"DB ingest failed: {ingest.get('error')}"}
525
+
526
+ # Lockstep JSON export.
527
+ export_path = None
528
+ clip_dir_name = context["clip"].get("clip_dir")
529
+ if clip_dir_name:
530
+ candidate = os.path.join(project_root, "clips", clip_dir_name, "analysis.json")
531
+ if os.path.isfile(candidate):
532
+ export_path = analysis_store.export_report_file(project_root, clip_uuid, candidate)
533
+
534
+ # Caps usage: frames that fed this pass (frames table rows for the target
535
+ # shots), falling back to one frame per shot when none are recorded.
536
+ placeholders = ",".join("?" for _ in target_uuids)
537
+ frames_seen = int(conn.execute(
538
+ f"SELECT COUNT(*) FROM frames WHERE clip_uuid = ? AND shot_uuid IN ({placeholders})",
539
+ (clip_uuid, *target_uuids),
540
+ ).fetchone()[0]) or len(normalized_entries)
541
+ ma._record_caps_usage(
542
+ project_root=project_root,
543
+ clip_id=context["clip"].get("resolve_clip_id") or clip_uuid,
544
+ vision_tokens=frames_seen * ma.AVG_VISION_TOKENS_PER_FRAME,
545
+ frames_uploaded=frames_seen,
546
+ )
547
+
548
+ return {
549
+ "success": True,
550
+ "clip_uuid": clip_uuid,
551
+ "shots_updated": updated,
552
+ "subjective_fields_written": ingest.get("subjective_fields_written"),
553
+ "subjective_fields_preserved_human": ingest.get("subjective_fields_preserved_human"),
554
+ "source": DEEP_SOURCE,
555
+ "analysis_json": export_path,
556
+ }
557
+
558
+
559
+ def vision_pending_sweep(
560
+ project_root: str,
561
+ *,
562
+ expire: bool = False,
563
+ max_age_days: Optional[float] = None,
564
+ reoffer: bool = False,
565
+ ) -> Dict[str, Any]:
566
+ """List clips stuck in pending_host_analysis; optionally expire or re-offer.
567
+
568
+ Re-offer returns each clip's stored deferred payload (report["visual"])
569
+ after verifying its frame files still exist. Expire stamps the report
570
+ ``expired_host_analysis`` so pendings never linger silently.
571
+ """
572
+ ma = _ma()
573
+ root = ma.normalize_path(project_root)
574
+ clips_root = os.path.join(root, "clips")
575
+ now = time.time()
576
+ pending: List[Dict[str, Any]] = []
577
+ expired: List[str] = []
578
+ reoffers: List[Dict[str, Any]] = []
579
+ if os.path.isdir(clips_root):
580
+ for entry in sorted(os.listdir(clips_root)):
581
+ clip_dir = os.path.join(clips_root, entry)
582
+ report_path = os.path.join(clip_dir, "analysis.json")
583
+ if not os.path.isfile(report_path):
584
+ continue
585
+ report = analysis_store.load_db_report(root, clip_dir=entry)
586
+ if report is None:
587
+ try:
588
+ with open(report_path, "r", encoding="utf-8") as handle:
589
+ report = json.load(handle)
590
+ except (OSError, json.JSONDecodeError):
591
+ continue
592
+ if report.get("vision_status") != "pending_host_analysis":
593
+ continue
594
+ analyzed_at = report.get("analyzed_at")
595
+ age_days = None
596
+ ts = ma._timestamp_from_analyzed_at(analyzed_at)
597
+ if ts:
598
+ age_days = round((now - ts) / 86400.0, 2)
599
+ visual = report.get("visual") if isinstance(report.get("visual"), dict) else {}
600
+ frame_paths = visual.get("frame_paths") if isinstance(visual.get("frame_paths"), list) else []
601
+ frames_present = sum(1 for p in frame_paths if p and os.path.isfile(str(p)))
602
+ row = {
603
+ "clip_dir": entry,
604
+ "clip_name": (report.get("clip") or {}).get("clip_name"),
605
+ "clip_id": (report.get("clip") or {}).get("clip_id"),
606
+ "analyzed_at": analyzed_at,
607
+ "age_days": age_days,
608
+ "vision_token": report.get("vision_token") or visual.get("vision_token"),
609
+ "frame_count": len(frame_paths),
610
+ "frames_still_on_disk": frames_present,
611
+ "reofferable": frames_present > 0,
612
+ }
613
+ pending.append(row)
614
+ over_age = max_age_days is not None and (age_days or 0) >= float(max_age_days)
615
+ if expire and (max_age_days is None or over_age):
616
+ report["vision_status"] = "expired_host_analysis"
617
+ report["vision_expired_at"] = _now()
618
+ ingest = analysis_store.ingest_report(root, report, clip_dir=clip_dir)
619
+ if ingest.get("success"):
620
+ analysis_store.export_report_file(root, ingest["clip_uuid"], report_path)
621
+ else:
622
+ ma._write_json(report_path, report)
623
+ expired.append(entry)
624
+ row["expired"] = True
625
+ elif reoffer and frames_present:
626
+ reoffers.append({
627
+ "clip_dir": entry,
628
+ "payload": visual,
629
+ })
630
+ result: Dict[str, Any] = {
631
+ "success": True,
632
+ "project_root": root,
633
+ "pending_count": len(pending),
634
+ "pending": pending,
635
+ "expired": expired,
636
+ }
637
+ if reoffer:
638
+ result["reoffers"] = reoffers
639
+ result["note"] = (
640
+ "Each reoffer.payload is the original deferred host-vision payload; "
641
+ "read its frame_paths and call its commit_action to finish the run."
642
+ )
643
+ return result
@@ -4265,7 +4265,7 @@ def build_host_chat_paths_payload(
4265
4265
  if refusal is not None:
4266
4266
  return refusal
4267
4267
 
4268
- return {
4268
+ payload: Dict[str, Any] = {
4269
4269
  "success": True,
4270
4270
  "status": "pending_host_analysis",
4271
4271
  "provider": HOST_CHAT_PATHS_PROVIDER,
@@ -4327,6 +4327,21 @@ def build_host_chat_paths_payload(
4327
4327
  "the clip's analysis directory; commit_vision finishes the run."
4328
4328
  ),
4329
4329
  }
4330
+ # Phase B — deep depth: each shot_descriptions entry must additionally
4331
+ # carry the per-shot field groups. The extra keys flow through
4332
+ # commit_vision → canonical blob → subjective_fields rows unchanged.
4333
+ if str(options.get("depth") or "").lower() == "deep":
4334
+ from src.utils import deep_vision as _deep_vision
4335
+
4336
+ payload["deep_shot_schema"] = _deep_vision.deep_shot_schema()
4337
+ payload["deep_schema_reference"] = _deep_vision.DEEP_SHOT_SCHEMA_REFERENCE
4338
+ payload["instructions"] += (
4339
+ " DEEP PASS: in addition to `description`, every shot_descriptions "
4340
+ "entry MUST include the field groups in `deep_shot_schema` (visual, "
4341
+ "content, production, editorial, cuttability, confidence), using the "
4342
+ "enum values verbatim and 'unknown'/null when frame evidence is thin."
4343
+ )
4344
+ return payload
4330
4345
 
4331
4346
 
4332
4347
  def _vision_analysis(record: Dict[str, Any], motion: Dict[str, Any], options: Dict[str, Any], artifacts: Dict[str, Any], capabilities: Dict[str, Any]) -> Dict[str, Any]:
@@ -5096,6 +5111,9 @@ async def execute_plan_async(
5096
5111
  # Same for project_root — caps recording needs it to address the per-
5097
5112
  # project usage DB; falling back to the plan's output_root is fine.
5098
5113
  "project_root": params.get("project_root") or output_root,
5114
+ # Phase B — depth threads into the vision payload builder so deep runs
5115
+ # carry the per-shot field-group schema.
5116
+ "depth": plan.get("depth", DEFAULT_DEPTH),
5099
5117
  }
5100
5118
  keep_frame_artifacts_for_vision = vision_uses_chat_context(options, caps)
5101
5119
  depth = plan.get("depth", DEFAULT_DEPTH)
@@ -5114,6 +5132,37 @@ async def execute_plan_async(
5114
5132
  }
5115
5133
  _write_json(os.path.join(output_root, "capabilities.json"), caps)
5116
5134
 
5135
+ # Phase B — deep depth is opt-in with an explicit cost estimate first.
5136
+ # The per-shot field-group pass multiplies vision spend, so the first call
5137
+ # returns the estimate; re-call with confirm_deep=true to run. Caps still
5138
+ # apply downstream — confirmation does not bypass budgets.
5139
+ if (
5140
+ depth == "deep"
5141
+ and vision_uses_chat_context(options, caps)
5142
+ and not _coerce_bool(params.get("confirm_deep") or params.get("confirmDeep"), default=False)
5143
+ ):
5144
+ executing = [
5145
+ clip for clip in plan.get("clips", [])
5146
+ if not (clip.get("skip_execution") and (clip.get("existing_report") or {}).get("path"))
5147
+ ]
5148
+ estimated_frames = sum(int(c.get("analysis_keyframe_budget") or 0) for c in executing)
5149
+ return {
5150
+ "success": True,
5151
+ "status": "confirmation_required",
5152
+ "reason": "deep_depth_cost_estimate",
5153
+ "estimate": {
5154
+ "clip_count": len(executing),
5155
+ "estimated_frames": estimated_frames,
5156
+ "estimated_vision_tokens": estimated_frames * AVG_VISION_TOKENS_PER_FRAME,
5157
+ "tokens_per_frame_assumption": AVG_VISION_TOKENS_PER_FRAME,
5158
+ },
5159
+ "note": (
5160
+ "Deep analysis fills per-shot Visual/Content/Editorial field groups "
5161
+ "and costs vision tokens accordingly. Re-call the same analyze action "
5162
+ "with confirm_deep=true to proceed, or drop depth to 'standard'."
5163
+ ),
5164
+ }
5165
+
5117
5166
  for clip_plan in plan.get("clips", []):
5118
5167
  record = clip_plan["record"]
5119
5168
  artifacts = clip_plan["artifacts"]