davinci-resolve-mcp 2.43.0 → 2.45.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/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.43.0"
14
+ VERSION = "2.45.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -112,6 +112,7 @@ from src.utils.fusion_group_settings import (
112
112
  )
113
113
  from src.utils import analysis_runs as _analysis_runs
114
114
  from src.utils import brain_edits as _brain_edits
115
+ from src.utils import edit_engine as _edit_engine_mod
115
116
  from src.utils import media_pool_changes as _media_pool_changes
116
117
  from src.utils import timeline_versioning as _timeline_versioning
117
118
  from src.utils import project_spec as _project_spec
@@ -782,6 +783,10 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
782
783
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
783
784
  ("timeline", "delete_track"),
784
785
  ("timeline", "apply_cuts"),
786
+ # Phase E edit-engine loops: plan → confirm → execute.
787
+ ("edit_engine", "execute_selects"),
788
+ ("edit_engine", "execute_tighten"),
789
+ ("edit_engine", "execute_swap"),
785
790
  ("graph", "apply_grade_from_drx"),
786
791
  ("graph", "reset_all_grades"),
787
792
  # 21.0 AI ops that render/generate NEW media files (additive, but expensive
@@ -15027,6 +15032,12 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15027
15032
  # Phase C — embeddings + similarity.
15028
15033
  "build_embeddings",
15029
15034
  "find_similar",
15035
+ # Phase D — cross-clip entities + bin briefing v2.
15036
+ "detect_entities",
15037
+ "commit_entities",
15038
+ "list_entities",
15039
+ "prepare_bin_briefing",
15040
+ "commit_bin_summary",
15030
15041
  }:
15031
15042
  root = resolve_media_analysis_output_root(
15032
15043
  project_name=project_name,
@@ -15150,6 +15161,38 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15150
15161
  entity_types=entity_types,
15151
15162
  limit=int(p.get("limit") or 10),
15152
15163
  )
15164
+ # Phase D — cross-clip entities + bin briefing v2.
15165
+ if action == "detect_entities":
15166
+ from src.utils import entities
15167
+ return entities.detect_entities(
15168
+ project_root,
15169
+ threshold=float(p.get("threshold") or entities.DEFAULT_CLUSTER_THRESHOLD),
15170
+ min_cluster_size=int(p.get("min_cluster_size") or p.get("minClusterSize") or entities.DEFAULT_MIN_CLUSTER_SIZE),
15171
+ max_clusters=int(p.get("max_clusters") or p.get("maxClusters") or 24),
15172
+ job_id=p.get("job_id") or p.get("jobId"),
15173
+ )
15174
+ if action == "commit_entities":
15175
+ from src.utils import entities
15176
+ return entities.commit_entities(
15177
+ project_root,
15178
+ entities_payload=p.get("entities"),
15179
+ vision_token=p.get("vision_token") or p.get("visionToken"),
15180
+ author=p.get("author") or "host_chat",
15181
+ )
15182
+ if action == "list_entities":
15183
+ from src.utils import entities
15184
+ return entities.list_entities(project_root, kind=p.get("kind"))
15185
+ if action == "prepare_bin_briefing":
15186
+ from src.utils import entities
15187
+ return entities.prepare_bin_briefing(project_root)
15188
+ if action == "commit_bin_summary":
15189
+ from src.utils import entities
15190
+ return entities.commit_bin_summary(
15191
+ project_root,
15192
+ briefing=p.get("briefing") or p.get("summary"),
15193
+ briefing_token=p.get("briefing_token") or p.get("briefingToken"),
15194
+ author=p.get("author") or "host_chat",
15195
+ )
15153
15196
  if action in {"build_index", "rebuild_index"}:
15154
15197
  return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath"))
15155
15198
  if action == "index_status":
@@ -15566,6 +15609,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15566
15609
  "vision_pending_sweep",
15567
15610
  "build_embeddings",
15568
15611
  "find_similar",
15612
+ "detect_entities",
15613
+ "commit_entities",
15614
+ "list_entities",
15615
+ "prepare_bin_briefing",
15616
+ "commit_bin_summary",
15569
15617
  "get_caps",
15570
15618
  "set_caps_preset",
15571
15619
  "get_usage",
@@ -15715,6 +15763,506 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
15715
15763
  return _err(f"Unknown action: {action}")
15716
15764
 
15717
15765
 
15766
+ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List[Dict[str, Any]]:
15767
+ """Serialize video timeline items with the source mapping the planner needs."""
15768
+ rows: List[Dict[str, Any]] = []
15769
+ track_count = int(tl.GetTrackCount("video") or 0)
15770
+ indices = [int(track_index)] if track_index else range(1, track_count + 1)
15771
+ for index in indices:
15772
+ try:
15773
+ items = tl.GetItemListInTrack("video", index) or []
15774
+ except Exception:
15775
+ continue
15776
+ for item in items:
15777
+ try:
15778
+ row: Dict[str, Any] = {
15779
+ "track_index": index,
15780
+ "item_name": item.GetName(),
15781
+ "timeline_start_frame": _frame_int(item.GetStart()),
15782
+ "timeline_end_frame": _frame_int(item.GetEnd()),
15783
+ }
15784
+ except Exception:
15785
+ continue
15786
+ source_start = None
15787
+ if _has_method(item, "GetSourceStartFrame"):
15788
+ try:
15789
+ source_start = _frame_int(item.GetSourceStartFrame())
15790
+ except Exception:
15791
+ source_start = None
15792
+ if source_start is None and _has_method(item, "GetLeftOffset"):
15793
+ try:
15794
+ source_start = _frame_int(item.GetLeftOffset())
15795
+ except Exception:
15796
+ source_start = None
15797
+ row["source_start_frame"] = source_start or 0
15798
+ try:
15799
+ mpi = item.GetMediaPoolItem()
15800
+ if mpi is not None:
15801
+ row["media_ref"] = mpi.GetUniqueId()
15802
+ try:
15803
+ row["media_path"] = mpi.GetClipProperty("File Path")
15804
+ except Exception:
15805
+ pass
15806
+ except Exception:
15807
+ pass
15808
+ rows.append(row)
15809
+ return rows
15810
+
15811
+
15812
+ def _edit_engine_timeline_fps(tl) -> float:
15813
+ try:
15814
+ fps = float(tl.GetSetting("timelineFrameRate") or 0)
15815
+ return fps if fps > 0 else 24.0
15816
+ except Exception:
15817
+ return 24.0
15818
+
15819
+
15820
+ def _edit_engine_capture(tl) -> Dict[str, Any]:
15821
+ """Duration/clip-count readback for execute results."""
15822
+ out: Dict[str, Any] = {}
15823
+ try:
15824
+ out["duration_seconds"] = _brain_edits.capture_metric(_brain_edits.METRIC_DURATION_SECONDS, tl)
15825
+ out["clip_count"] = _brain_edits.capture_metric(_brain_edits.METRIC_CLIP_COUNT, tl)
15826
+ except Exception:
15827
+ pass
15828
+ return out
15829
+
15830
+
15831
+ @mcp.tool()
15832
+ @_destructive_op("edit_engine")
15833
+ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
15834
+ """Evidence-driven edit loops (Phase E): selects assembly, tighten, swap.
15835
+
15836
+ Every loop is plan → confirm → execute. plan_* actions are dry-run by
15837
+ construction: they query the DB-canonical analysis store and return a
15838
+ per-decision rationale plus a stored plan_id. execute_* actions require a
15839
+ confirm_token, run under the version-on-mutate hook (the timeline is
15840
+ archived first), and return before/after metrics as readback.
15841
+
15842
+ Actions:
15843
+ - plan_selects(min_select_potential?, max_duration_seconds?, timeline_name?,
15844
+ max_shots?, handle_seconds?, analysis_root?) — rank shots by deep-tier
15845
+ select potential (clip-level fallback), story-spine order. Additive.
15846
+ - execute_selects(plan_id, confirm_token?) — creates a NEW selects timeline
15847
+ from the plan's clip in/out ranges. Nothing existing is touched.
15848
+ - plan_tighten(timeline_name?, target_ratio?, min_pause_seconds?,
15849
+ handle_seconds?) — dead-air lifts from transcript-gap evidence for the
15850
+ current (or named) timeline.
15851
+ - execute_tighten(plan_id, confirm_token?) — duplicates the timeline and
15852
+ applies the lifts (ripple) to the DUPLICATE, never the original.
15853
+ - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
15854
+ — alternates for one timeline item via the similarity index.
15855
+ - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
15856
+ item in place (lift + positioned append, same slot) on the current
15857
+ timeline (version-archived first).
15858
+ - list_plans(limit?) / get_plan(plan_id)
15859
+ """
15860
+ p = params or {}
15861
+
15862
+ def _project_context(*, need_resolve: bool) -> Tuple[Optional[Any], Optional[Any], Optional[str], Optional[Dict[str, Any]]]:
15863
+ # An explicit analysis_root always wins — the evidence DB the caller
15864
+ # analyzed into may differ from the provider's derived root (e.g. a
15865
+ # headless analysis keyed by project name only).
15866
+ analysis_root = p.get("analysis_root") or p.get("analysisRoot")
15867
+ explicit_root = str(analysis_root) if analysis_root and os.path.isdir(str(analysis_root)) else None
15868
+ vctx = _destructive_versioning_provider()
15869
+ if vctx is not None:
15870
+ r, proj, project_root, _name = vctx
15871
+ return r, proj, explicit_root or project_root, None
15872
+ if not need_resolve and explicit_root:
15873
+ return None, None, explicit_root, None
15874
+ return None, None, None, _err(
15875
+ "No project context — open a Resolve project (or pass analysis_root for plan_selects)."
15876
+ )
15877
+
15878
+ if action == "list_plans":
15879
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
15880
+ if err:
15881
+ return err
15882
+ return _edit_engine_mod.list_plans(project_root, limit=int(p.get("limit") or 20))
15883
+
15884
+ if action == "get_plan":
15885
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
15886
+ if err:
15887
+ return err
15888
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
15889
+ if not plan:
15890
+ return _err("plan not found")
15891
+ if plan.get("_corrupt"):
15892
+ return _err("plan file failed its fingerprint check — re-plan")
15893
+ return {"success": True, "plan": plan}
15894
+
15895
+ if action == "plan_selects":
15896
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
15897
+ if err:
15898
+ return err
15899
+ return _edit_engine_mod.plan_selects(
15900
+ project_root,
15901
+ timeline_name=p.get("timeline_name") or p.get("timelineName"),
15902
+ max_duration_seconds=p.get("max_duration_seconds") or p.get("maxDurationSeconds"),
15903
+ min_select_potential=str(p.get("min_select_potential") or p.get("minSelectPotential") or "medium"),
15904
+ handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
15905
+ max_shots=int(p.get("max_shots") or p.get("maxShots") or 60),
15906
+ )
15907
+
15908
+ if action == "plan_tighten":
15909
+ _r, proj, project_root, err = _project_context(need_resolve=True)
15910
+ if err:
15911
+ return err
15912
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
15913
+ if not tl:
15914
+ return _err("Timeline not found")
15915
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
15916
+ return _edit_engine_mod.plan_tighten(
15917
+ project_root,
15918
+ items=items,
15919
+ timeline_name=tl.GetName(),
15920
+ timeline_fps=_edit_engine_timeline_fps(tl),
15921
+ target_ratio=p.get("target_ratio") or p.get("targetRatio"),
15922
+ min_pause_seconds=float(p.get("min_pause_seconds") or p.get("minPauseSeconds") or _edit_engine_mod.DEFAULT_MIN_PAUSE_SECONDS),
15923
+ handle_seconds=float(p.get("handle_seconds") or p.get("handleSeconds") or _edit_engine_mod.DEFAULT_HANDLE_SECONDS),
15924
+ )
15925
+
15926
+ if action == "plan_swap":
15927
+ _r, proj, project_root, err = _project_context(need_resolve=True)
15928
+ if err:
15929
+ return err
15930
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
15931
+ if not tl:
15932
+ return _err("Timeline not found")
15933
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
15934
+ target = None
15935
+ wanted_start = p.get("timeline_start_frame") if p.get("timeline_start_frame") is not None else p.get("timelineStartFrame")
15936
+ wanted_name = p.get("item_name") or p.get("itemName")
15937
+ for row in items:
15938
+ if wanted_start is not None and int(row["timeline_start_frame"]) == int(wanted_start):
15939
+ target = row
15940
+ break
15941
+ if wanted_name and row.get("item_name") == wanted_name:
15942
+ target = row
15943
+ break
15944
+ if target is None:
15945
+ return _err(
15946
+ "Target item not found — pass timeline_start_frame or item_name "
15947
+ f"(items: {[{'name': r.get('item_name'), 'start': r['timeline_start_frame']} for r in items[:20]]})"
15948
+ )
15949
+ return _edit_engine_mod.plan_swap(
15950
+ project_root,
15951
+ item=target,
15952
+ timeline_name=tl.GetName(),
15953
+ timeline_fps=_edit_engine_timeline_fps(tl),
15954
+ kind=str(p.get("kind") or "visual"),
15955
+ limit=int(p.get("limit") or 5),
15956
+ )
15957
+
15958
+ if action == "execute_selects":
15959
+ _r, proj, project_root, err = _project_context(need_resolve=True)
15960
+ if err:
15961
+ return err
15962
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
15963
+ if not plan or plan.get("_corrupt"):
15964
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
15965
+ if plan.get("kind") != "selects":
15966
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
15967
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15968
+ return _issue_confirm_token(
15969
+ action="edit_engine.execute_selects", params=p,
15970
+ preview={
15971
+ "operation": "edit_engine.execute_selects",
15972
+ "warning": "Creates a NEW selects timeline; no existing timeline is modified.",
15973
+ "timeline_name": plan.get("timeline_name"),
15974
+ "decision_count": len(plan.get("decisions") or []),
15975
+ "estimated_duration_seconds": plan.get("estimated_duration_seconds"),
15976
+ },
15977
+ )
15978
+ blocked = _consume_confirm_token(action="edit_engine.execute_selects", params=p)
15979
+ if blocked:
15980
+ return blocked
15981
+ mp = proj.GetMediaPool()
15982
+ if not mp:
15983
+ return _err("No media pool")
15984
+ root_folder = mp.GetRootFolder()
15985
+ name = str(plan.get("timeline_name") or "Selects")
15986
+ if _find_timeline_by_name(proj, name)[0]:
15987
+ name = f"{name} {time.strftime('%H%M%S')}"
15988
+ tl = mp.CreateEmptyTimeline(name)
15989
+ if not tl:
15990
+ return _err("Failed to create selects timeline")
15991
+ try:
15992
+ proj.SetCurrentTimeline(tl)
15993
+ except Exception:
15994
+ pass
15995
+ timeline_start = _timeline_start_frame(tl)
15996
+ built = []
15997
+ build_errors = []
15998
+ record_cursor = 0
15999
+ for i, ci in enumerate(plan.get("clip_infos") or []):
16000
+ append_ci = dict(ci)
16001
+ # Sequential assembly: each select lands after the previous one.
16002
+ append_ci.setdefault("record_frame", record_cursor)
16003
+ append_ci.setdefault("track_index", 1)
16004
+ row, row_err = _build_append_clip_info_dict(root_folder, append_ci, i, timeline_start)
16005
+ if row_err:
16006
+ build_errors.append({"index": i, "error": row_err.get("error")})
16007
+ continue
16008
+ built.append(row)
16009
+ record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
16010
+ appended = mp.AppendToTimeline(built) if built else None
16011
+ readback = _edit_engine_capture(tl)
16012
+ run_id = _analysis_runs.current_run_id()
16013
+ try:
16014
+ _brain_edits.log_brain_edit(
16015
+ project_root=project_root,
16016
+ analysis_run_id=run_id or "edit-engine",
16017
+ edit_type="edit_engine.selects_result",
16018
+ tool_name="edit_engine",
16019
+ action_name="execute_selects",
16020
+ timeline_after=tl.GetName(),
16021
+ target_metric=_brain_edits.METRIC_CLIP_COUNT,
16022
+ metric_direction="target_value",
16023
+ after_value=readback.get("clip_count"),
16024
+ rationale=plan.get("summary"),
16025
+ params={"plan_id": plan.get("plan_id")},
16026
+ result_summary={"appended": len(built), "build_errors": build_errors},
16027
+ )
16028
+ except Exception:
16029
+ pass
16030
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16031
+ "timeline_name": tl.GetName(),
16032
+ "appended": len(built),
16033
+ "build_errors": build_errors,
16034
+ **readback,
16035
+ })
16036
+ return {
16037
+ "success": bool(appended),
16038
+ "timeline_name": tl.GetName(),
16039
+ "appended": len(built),
16040
+ "build_errors": build_errors,
16041
+ "readback": readback,
16042
+ "plan_id": plan.get("plan_id"),
16043
+ }
16044
+
16045
+ if action == "execute_tighten":
16046
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16047
+ if err:
16048
+ return err
16049
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16050
+ if not plan or plan.get("_corrupt"):
16051
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
16052
+ if plan.get("kind") != "tighten":
16053
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
16054
+ lifts = plan.get("lifts") or []
16055
+ keep_ranges = plan.get("keep_ranges") or []
16056
+ if not keep_ranges:
16057
+ return _err("plan has no keep_ranges — re-plan with this version")
16058
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16059
+ return _issue_confirm_token(
16060
+ action="edit_engine.execute_tighten", params=p,
16061
+ preview={
16062
+ "operation": "edit_engine.execute_tighten",
16063
+ "warning": (
16064
+ "Assembles a tightened VARIANT timeline from the plan's keep "
16065
+ "ranges; the original timeline is not modified."
16066
+ ),
16067
+ "timeline_name": plan.get("timeline_name"),
16068
+ "lift_count": len(lifts),
16069
+ "keep_range_count": len(keep_ranges),
16070
+ "estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
16071
+ },
16072
+ )
16073
+ blocked = _consume_confirm_token(action="edit_engine.execute_tighten", params=p)
16074
+ if blocked:
16075
+ return blocked
16076
+ source_tl, _src_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
16077
+ if not source_tl:
16078
+ return _err(f"Timeline '{plan.get('timeline_name')}' not found")
16079
+ before = _edit_engine_capture(source_tl)
16080
+ variant_name = f"{plan.get('timeline_name')} — tightened {time.strftime('%H%M%S')}"
16081
+ variant = _timeline_create_variant_from_ranges(proj, source_tl, {
16082
+ "ranges": keep_ranges,
16083
+ "name": variant_name,
16084
+ })
16085
+ if not variant.get("success"):
16086
+ return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
16087
+ new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
16088
+ after = _edit_engine_capture(new_tl) if new_tl else {}
16089
+ run_id = _analysis_runs.current_run_id()
16090
+ try:
16091
+ _brain_edits.log_brain_edit(
16092
+ project_root=project_root,
16093
+ analysis_run_id=run_id or "edit-engine",
16094
+ edit_type="edit_engine.tighten_result",
16095
+ tool_name="edit_engine",
16096
+ action_name="execute_tighten",
16097
+ timeline_before=plan.get("timeline_name"),
16098
+ timeline_after=variant.get("name") or variant_name,
16099
+ target_metric=_brain_edits.METRIC_DURATION_SECONDS,
16100
+ metric_direction="decrease",
16101
+ before_value=before.get("duration_seconds"),
16102
+ after_value=after.get("duration_seconds"),
16103
+ rationale=plan.get("summary"),
16104
+ params={"plan_id": plan.get("plan_id")},
16105
+ result_summary={"keep_ranges": len(keep_ranges), "lifts": len(lifts)},
16106
+ )
16107
+ except Exception:
16108
+ pass
16109
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16110
+ "variant_timeline": variant.get("name") or variant_name,
16111
+ "keep_ranges": len(keep_ranges),
16112
+ "lifts": len(lifts),
16113
+ "before": before,
16114
+ "after": after,
16115
+ })
16116
+ return {
16117
+ "success": True,
16118
+ "original_timeline": plan.get("timeline_name"),
16119
+ "variant_timeline": variant.get("name") or variant_name,
16120
+ "lifts_applied": len(lifts),
16121
+ "keep_ranges": len(keep_ranges),
16122
+ "lift_rationales": [
16123
+ {"lift": [l["timeline_start_frame"], l["timeline_end_frame"]], "rationale": l.get("rationale")}
16124
+ for l in lifts
16125
+ ],
16126
+ "readback": {
16127
+ "before": before,
16128
+ "after": after,
16129
+ "removed_seconds": (
16130
+ round(before["duration_seconds"] - after["duration_seconds"], 2)
16131
+ if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
16132
+ else None
16133
+ ),
16134
+ },
16135
+ "plan_id": plan.get("plan_id"),
16136
+ }
16137
+
16138
+ if action == "execute_swap":
16139
+ _r, proj, project_root, err = _project_context(need_resolve=True)
16140
+ if err:
16141
+ return err
16142
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
16143
+ if not plan or plan.get("_corrupt"):
16144
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
16145
+ if plan.get("kind") != "swap":
16146
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
16147
+ alternates = plan.get("alternates") or []
16148
+ try:
16149
+ alt_index = int(p.get("alternate_index") if p.get("alternate_index") is not None else p.get("alternateIndex"))
16150
+ except (TypeError, ValueError):
16151
+ return _err(f"execute_swap requires alternate_index (0-{len(alternates) - 1})")
16152
+ if not (0 <= alt_index < len(alternates)):
16153
+ return _err(f"alternate_index out of range (0-{len(alternates) - 1})")
16154
+ alternate = alternates[alt_index]
16155
+ item_block = plan.get("item") or {}
16156
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
16157
+ return _issue_confirm_token(
16158
+ action="edit_engine.execute_swap", params=p,
16159
+ preview={
16160
+ "operation": "edit_engine.execute_swap",
16161
+ "warning": (
16162
+ "Replaces one timeline item in place (lift + positioned append). "
16163
+ "A timeline version is archived first."
16164
+ ),
16165
+ "timeline_name": plan.get("timeline_name"),
16166
+ "slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
16167
+ "replacement": {
16168
+ "clip_name": alternate.get("clip_name"),
16169
+ "shot_index": alternate.get("shot_index"),
16170
+ "score": alternate.get("score"),
16171
+ },
16172
+ },
16173
+ )
16174
+ blocked = _consume_confirm_token(action="edit_engine.execute_swap", params=p)
16175
+ if blocked:
16176
+ return blocked
16177
+ tl, _tl_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
16178
+ if not tl:
16179
+ return _err(f"Timeline '{plan.get('timeline_name')}' not found")
16180
+ try:
16181
+ proj.SetCurrentTimeline(tl)
16182
+ except Exception:
16183
+ pass
16184
+ before = _edit_engine_capture(tl)
16185
+ lift = _timeline_lift_range_impl(tl, {
16186
+ "start_frame": item_block.get("timeline_start_frame"),
16187
+ "end_frame": item_block.get("timeline_end_frame"),
16188
+ "allow_partial_item_delete": True,
16189
+ "ripple": False,
16190
+ })
16191
+ if not lift.get("success"):
16192
+ return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
16193
+ mp = proj.GetMediaPool()
16194
+ root_folder = mp.GetRootFolder()
16195
+ timeline_start = _timeline_start_frame(tl)
16196
+ frame_range = alternate.get("source_frame_range") or [0, 0]
16197
+ ci = {
16198
+ "clip_id": alternate.get("resolve_clip_id"),
16199
+ "start_frame": int(frame_range[0]),
16200
+ "end_frame": int(frame_range[1]),
16201
+ # Item starts come from item.GetStart() — absolute timeline frames.
16202
+ "record_frame": int(item_block.get("timeline_start_frame")),
16203
+ "record_frame_mode": "absolute",
16204
+ "track_index": int(item_block.get("track_index") or 1),
16205
+ }
16206
+ row, row_err = _build_append_clip_info_dict(root_folder, ci, 0, timeline_start)
16207
+ if row_err:
16208
+ return {"success": False, "error": f"swap append failed: {row_err.get('error')}", "lifted": lift}
16209
+ appended = mp.AppendToTimeline([row])
16210
+ after = _edit_engine_capture(tl)
16211
+ run_id = _analysis_runs.current_run_id()
16212
+ try:
16213
+ _brain_edits.log_brain_edit(
16214
+ project_root=project_root,
16215
+ analysis_run_id=run_id or "edit-engine",
16216
+ edit_type="edit_engine.swap_result",
16217
+ tool_name="edit_engine",
16218
+ action_name="execute_swap",
16219
+ timeline_after=tl.GetName(),
16220
+ target_metric=_brain_edits.METRIC_CLIP_COUNT,
16221
+ metric_direction="target_value",
16222
+ before_value=before.get("clip_count"),
16223
+ after_value=after.get("clip_count"),
16224
+ rationale=(
16225
+ f"Swapped slot {item_block.get('timeline_start_frame')}-"
16226
+ f"{item_block.get('timeline_end_frame')} with "
16227
+ f"{alternate.get('clip_name')} shot {alternate.get('shot_index')}: "
16228
+ f"{alternate.get('rationale')}"
16229
+ ),
16230
+ params={"plan_id": plan.get("plan_id"), "alternate_index": alt_index},
16231
+ result_summary={"appended": bool(appended)},
16232
+ )
16233
+ except Exception:
16234
+ pass
16235
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
16236
+ "alternate_index": alt_index,
16237
+ "replacement": alternate.get("clip_name"),
16238
+ "before": before,
16239
+ "after": after,
16240
+ })
16241
+ return {
16242
+ "success": bool(appended),
16243
+ "timeline_name": tl.GetName(),
16244
+ "replaced_slot": [item_block.get("timeline_start_frame"), item_block.get("timeline_end_frame")],
16245
+ "replacement": {
16246
+ "clip_name": alternate.get("clip_name"),
16247
+ "shot_index": alternate.get("shot_index"),
16248
+ "score": alternate.get("score"),
16249
+ },
16250
+ "readback": {"before": before, "after": after},
16251
+ "plan_id": plan.get("plan_id"),
16252
+ }
16253
+
16254
+ return _unknown(action, [
16255
+ "plan_selects",
16256
+ "execute_selects",
16257
+ "plan_tighten",
16258
+ "execute_tighten",
16259
+ "plan_swap",
16260
+ "execute_swap",
16261
+ "list_plans",
16262
+ "get_plan",
16263
+ ])
16264
+
16265
+
15718
16266
  @mcp.tool()
15719
16267
  @_destructive_op("timeline")
15720
16268
  def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -473,7 +473,25 @@ def ingest_report(
473
473
  ),
474
474
  )
475
475
 
476
- # Sampled frames.
476
+ # Sampled frames. Shot mapping uses frame_indices_used when present,
477
+ # with a time-containment fallback (some commit paths don't record
478
+ # which frame indices fed which shot).
479
+ shot_intervals: List[Tuple[float, float, str]] = []
480
+ for entry in shot_entries:
481
+ if not isinstance(entry, dict):
482
+ continue
483
+ s, e = entry.get("time_seconds_start"), entry.get("time_seconds_end")
484
+ if isinstance(s, (int, float)) and isinstance(e, (int, float)):
485
+ shot_intervals.append((float(s), float(e), shot_uuid_for(clip_uuid, s, e)))
486
+
487
+ def _shot_for_time(t: Any) -> Optional[str]:
488
+ if not isinstance(t, (int, float)):
489
+ return None
490
+ for s, e, uuid_ in shot_intervals:
491
+ if s <= float(t) < e:
492
+ return uuid_
493
+ return None
494
+
477
495
  conn.execute("DELETE FROM frames WHERE clip_uuid = ?", (clip_uuid,))
478
496
  keyframes = motion.get("analysis_keyframes") if isinstance(motion.get("analysis_keyframes"), list) else []
479
497
  for kf in keyframes:
@@ -492,7 +510,7 @@ def ingest_report(
492
510
  """,
493
511
  (
494
512
  clip_uuid,
495
- frame_to_shot.get(frame_index),
513
+ frame_to_shot.get(frame_index) or _shot_for_time(kf.get("time_seconds")),
496
514
  frame_index,
497
515
  kf.get("time_seconds") if isinstance(kf.get("time_seconds"), (int, float)) else None,
498
516
  kf.get("frame_path") or kf.get("path"),
@@ -56,6 +56,11 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
56
56
  "delete_clip_mattes",
57
57
  "delete_timelines",
58
58
  }),
59
+ "edit_engine": frozenset({
60
+ "execute_selects",
61
+ "execute_tighten",
62
+ "execute_swap",
63
+ }),
59
64
  "timeline": frozenset({
60
65
  "create_timeline",
61
66
  "create_timeline_from_clips",