davinci-resolve-mcp 2.68.2 → 2.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.68.2"
14
+ VERSION = "2.69.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -124,6 +124,10 @@ from src.utils.fusion_group_settings import (
124
124
  from src.utils import analysis_runs as _analysis_runs
125
125
  from src.utils import brain_edits as _brain_edits
126
126
  from src.utils import edit_engine as _edit_engine_mod
127
+ from src.utils import edit_report as _edit_report_mod
128
+ from src.utils import conform_lint as _conform_lint_mod
129
+ from src.utils import first_impression as _first_impression_mod
130
+ from src.utils import project_journal as _journal_mod
127
131
  from src.utils import transcript_edit as _transcript_edit_defaults
128
132
  from src.utils import media_pool_changes as _media_pool_changes
129
133
  from src.utils import timeline_versioning as _timeline_versioning
@@ -163,8 +167,13 @@ mcp = FastMCP(
163
167
  "DaVinciResolveMCP",
164
168
  instructions=(
165
169
  "DaVinci Resolve MCP Server — controls Resolve via its Scripting API. "
166
- "Tools automatically launch Resolve if it is not running (may take up to 60s on first call). "
167
- "If a tool returns a connection error, Resolve Studio may not be installed or external scripting is disabled."
170
+ "If no Resolve is running, tools launch one (up to 60s on the first call); "
171
+ "if one is already running they never launch a second. "
172
+ "On a connection error, read the error's own remediation field — it names the "
173
+ "fix that applies. External scripting is Studio-only, but the free edition is "
174
+ "reachable via the in-app bridge (Workspace > Scripts > resolve_bridge, with "
175
+ "DAVINCI_RESOLVE_BRIDGE=1), so a connection error does NOT mean the free "
176
+ "edition is unsupported."
168
177
  ),
169
178
  )
170
179
 
@@ -18828,10 +18837,137 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
18828
18837
  row["audio_track_indices"] = audio_indices
18829
18838
  except Exception:
18830
18839
  row["audio_track_indices"] = []
18840
+ _edit_engine_add_conform_fields(item, row)
18831
18841
  rows.append(row)
18832
18842
  return rows
18833
18843
 
18834
18844
 
18845
+ def _edit_engine_add_conform_fields(item, row: Dict[str, Any]) -> None:
18846
+ """Extra per-item metadata the conform lint needs.
18847
+
18848
+ Best-effort and individually guarded: a field this API does not expose on a
18849
+ given build must leave the rest of the row intact. Absent fields are
18850
+ reported by the lint as *not checked* rather than silently passing, so a
18851
+ missing value degrades coverage honestly instead of manufacturing a clean
18852
+ bill of health.
18853
+ """
18854
+ mpi = None
18855
+ try:
18856
+ mpi = item.GetMediaPoolItem()
18857
+ except Exception:
18858
+ mpi = None
18859
+ if mpi is not None:
18860
+ for key, prop in (
18861
+ ("source_start_timecode", "Start TC"),
18862
+ ("reel_name", "Reel Name"),
18863
+ ("clip_fps", "FPS"),
18864
+ ):
18865
+ try:
18866
+ value = mpi.GetClipProperty(prop)
18867
+ except Exception:
18868
+ continue
18869
+ if value in (None, ""):
18870
+ continue
18871
+ if key == "clip_fps":
18872
+ try:
18873
+ row[key] = float(value)
18874
+ except (TypeError, ValueError):
18875
+ continue
18876
+ else:
18877
+ row[key] = value
18878
+ try:
18879
+ fusion_names = [c.GetName() for c in (item.GetFusionCompNameList() or [])] \
18880
+ if _has_method(item, "GetFusionCompNameList") else []
18881
+ except Exception:
18882
+ fusion_names = []
18883
+ effects: List[str] = [n for n in fusion_names if n]
18884
+ for getter in ("GetVersionNameList",):
18885
+ if not _has_method(item, getter):
18886
+ continue
18887
+ try:
18888
+ extra = getattr(item, getter)(0) or []
18889
+ effects.extend(str(e) for e in extra if e)
18890
+ except Exception:
18891
+ pass
18892
+ if effects:
18893
+ row["effects"] = effects
18894
+
18895
+
18896
+ def _attach_audit_report(result: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]:
18897
+ """Give every audit a human-readable form on request (invariant I5).
18898
+
18899
+ Off by default because a rendered report costs tokens on every call and the
18900
+ structured payload is what an agent acts on. But it must always be one
18901
+ parameter away: an audit a human cannot read is an audit they will re-do by
18902
+ hand, which is the cost the tool exists to remove.
18903
+ """
18904
+ if not isinstance(result, dict) or not result.get("success"):
18905
+ return result
18906
+ result["report_available"] = "pass include_report=true for a Markdown report"
18907
+ flag = params.get("include_report", params.get("includeReport", False))
18908
+ if str(flag).strip().lower() in {"true", "1", "yes", "on"}:
18909
+ try:
18910
+ result["report_markdown"] = _edit_report_mod.render_any(result)
18911
+ except Exception as exc: # a renderer must never break the audit
18912
+ result["report_error"] = f"report rendering failed: {exc}"
18913
+ return result
18914
+
18915
+
18916
+ def _edit_engine_collect_audio_items(tl) -> List[Dict[str, Any]]:
18917
+ """Audio-track item boundaries, for split-edit analysis.
18918
+
18919
+ Only the boundaries are needed — a split edit is defined by where the audio
18920
+ cut sits relative to the picture cut, not by what is in the clip.
18921
+ """
18922
+ rows: List[Dict[str, Any]] = []
18923
+ try:
18924
+ track_count = int(tl.GetTrackCount("audio") or 0)
18925
+ except Exception:
18926
+ return rows
18927
+ for index in range(1, track_count + 1):
18928
+ try:
18929
+ items = tl.GetItemListInTrack("audio", index) or []
18930
+ except Exception:
18931
+ continue
18932
+ for item in items:
18933
+ try:
18934
+ rows.append({
18935
+ "track_index": index,
18936
+ "item_name": item.GetName(),
18937
+ "timeline_start_frame": _frame_int(item.GetStart()),
18938
+ "timeline_end_frame": _frame_int(item.GetEnd()),
18939
+ })
18940
+ except Exception:
18941
+ continue
18942
+ return rows
18943
+
18944
+
18945
+ def _edit_engine_marker_beats(tl, fps: float) -> List[float]:
18946
+ """Timeline markers as story beats, in seconds.
18947
+
18948
+ Markers are where an editor has already said "something happens here", which
18949
+ makes them the best available proxy for a story beat. Absent markers are
18950
+ reported by the rhythm criterion as beats-not-supplied rather than as an
18951
+ absence of beats.
18952
+ """
18953
+ beats: List[float] = []
18954
+ try:
18955
+ markers = tl.GetMarkers() or {}
18956
+ except Exception:
18957
+ return beats
18958
+ start = 0
18959
+ try:
18960
+ start = int(tl.GetStartFrame() or 0)
18961
+ except Exception:
18962
+ pass
18963
+ for frame in markers:
18964
+ try:
18965
+ beats.append((float(frame) + start) / (fps or 24.0))
18966
+ except (TypeError, ValueError):
18967
+ continue
18968
+ return sorted(beats)
18969
+
18970
+
18835
18971
  def _edit_engine_timeline_fps(tl) -> float:
18836
18972
  try:
18837
18973
  fps = float(tl.GetSetting("timelineFrameRate") or 0)
@@ -19069,6 +19205,30 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19069
19205
  return _err("plan file failed its fingerprint check — re-plan")
19070
19206
  return {"success": True, "plan": plan}
19071
19207
 
19208
+ if action == "plan_report":
19209
+ # A 340-entry keep_ranges array is not reviewable, and the rational
19210
+ # response to a machine you cannot audit is to re-check it by hand —
19211
+ # which is the cost the tool was supposed to remove. This renders any
19212
+ # plan as Markdown: what changes, why, what was deliberately left alone,
19213
+ # what could NOT be checked, and what needs a human.
19214
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
19215
+ if err:
19216
+ return err
19217
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
19218
+ if not plan:
19219
+ return _err("plan not found")
19220
+ if plan.get("_corrupt"):
19221
+ return _err("plan file failed its fingerprint check — re-plan")
19222
+ _rows = p.get("max_detail_rows") if p.get("max_detail_rows") is not None else p.get("maxDetailRows")
19223
+ return {
19224
+ "success": True,
19225
+ "plan_id": plan.get("plan_id"),
19226
+ "summary": _edit_report_mod.summarize_for_chat(plan),
19227
+ "report_markdown": _edit_report_mod.render_plan_report(
19228
+ plan, max_detail_rows=int(_rows) if _rows is not None else 25
19229
+ ),
19230
+ }
19231
+
19072
19232
  if action == "plan_selects":
19073
19233
  _r, _proj, project_root, err = _project_context(need_resolve=False)
19074
19234
  if err:
@@ -19150,6 +19310,22 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19150
19310
  min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
19151
19311
  )
19152
19312
 
19313
+ if action == "rank_takes":
19314
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19315
+ if err:
19316
+ return err
19317
+ refs = p.get("clip_refs") or p.get("clipRefs") or p.get("clip_ids") or []
19318
+ if isinstance(refs, str):
19319
+ refs = [refs]
19320
+ if not isinstance(refs, (list, tuple)) or not refs:
19321
+ return _err("clip_refs is required (a list of clips to compare)",
19322
+ code="MISSING_CLIP_REFS", category="invalid_input")
19323
+ return _edit_engine_mod.rank_takes(
19324
+ project_root,
19325
+ clip_refs=list(refs),
19326
+ script=p.get("script") if isinstance(p.get("script"), str) else None,
19327
+ )
19328
+
19153
19329
  if action == "search_spoken_content":
19154
19330
  _r, proj, project_root, err = _project_context(need_resolve=False)
19155
19331
  if err:
@@ -19193,6 +19369,332 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19193
19369
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
19194
19370
  )
19195
19371
 
19372
+ if action == "rule_of_six_audit":
19373
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19374
+ if err:
19375
+ return err
19376
+ 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())
19377
+ if not tl:
19378
+ return _err("Timeline not found")
19379
+ _fps = _edit_engine_timeline_fps(tl)
19380
+ return _attach_audit_report(_edit_engine_mod.rule_of_six_audit(
19381
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19382
+ timeline_name=tl.GetName(),
19383
+ timeline_fps=_fps,
19384
+ story_beats=_edit_engine_marker_beats(tl, _fps),
19385
+ ), p)
19386
+
19387
+ if action == "split_edit_audit":
19388
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19389
+ if err:
19390
+ return err
19391
+ 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())
19392
+ if not tl:
19393
+ return _err("Timeline not found")
19394
+ return _edit_engine_mod.split_edit_audit(
19395
+ video_items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19396
+ audio_items=_edit_engine_collect_audio_items(tl),
19397
+ timeline_fps=_edit_engine_timeline_fps(tl),
19398
+ )
19399
+
19400
+ if action == "sound_density_audit":
19401
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
19402
+ if err:
19403
+ return err
19404
+ media = p.get("track_media") or p.get("trackMedia") or {}
19405
+ if not isinstance(media, dict):
19406
+ return _err("track_media must be a mapping of {name: path}",
19407
+ code="INVALID_TRACK_MEDIA", category="invalid_input")
19408
+ _limit = p.get("stream_limit") if p.get("stream_limit") is not None else p.get("streamLimit")
19409
+ _dur = p.get("duration_seconds") if p.get("duration_seconds") is not None else p.get("durationSeconds")
19410
+ return _attach_audit_report(_edit_engine_mod.sound_density_audit(
19411
+ track_media={str(k): str(v) for k, v in media.items()},
19412
+ stream_limit=float(_limit) if _limit is not None else None,
19413
+ duration_seconds=float(_dur) if _dur is not None else None,
19414
+ ), p)
19415
+
19416
+ if action == "setup_sheet":
19417
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19418
+ if err:
19419
+ return err
19420
+ 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())
19421
+ if not tl:
19422
+ return _err("Timeline not found")
19423
+ return _attach_audit_report(_edit_engine_mod.setup_sheet(
19424
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19425
+ timeline_name=tl.GetName(),
19426
+ timeline_fps=_edit_engine_timeline_fps(tl),
19427
+ ), p)
19428
+
19429
+ if action == "first_impression":
19430
+ # Measures nothing. Captures the one perception that cannot be recovered
19431
+ # once the editor has seen the film too many times to feel it fresh.
19432
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19433
+ if err:
19434
+ return err
19435
+ sub = str(p.get("op") or p.get("sub_action") or "").strip().lower()
19436
+ log_id = p.get("log_id") or p.get("logId")
19437
+ if sub == "list":
19438
+ return {"success": True, "logs": _first_impression_mod.list_logs(project_root)}
19439
+ if sub in ("start", "record", "lock", "get") and not log_id:
19440
+ return _err("log_id is required", code="MISSING_LOG_ID", category="invalid_input")
19441
+ if sub == "start":
19442
+ return _first_impression_mod.start_pass(
19443
+ project_root,
19444
+ timeline_name=str(p.get("timeline_name") or p.get("timelineName") or ""),
19445
+ log_id=str(log_id),
19446
+ viewer=p.get("viewer"),
19447
+ )
19448
+ if sub == "record":
19449
+ _t = p.get("time_seconds") if p.get("time_seconds") is not None else p.get("timeSeconds")
19450
+ try:
19451
+ return _first_impression_mod.record(
19452
+ project_root, log_id=str(log_id),
19453
+ time_seconds=float(_t or 0.0), text=str(p.get("text") or ""),
19454
+ )
19455
+ except _first_impression_mod.LogLocked as exc:
19456
+ return _err(str(exc), code="LOG_LOCKED", category="invalid_input", retryable=False)
19457
+ if sub == "lock":
19458
+ return _first_impression_mod.lock(project_root, log_id=str(log_id))
19459
+ if sub == "get":
19460
+ log = _first_impression_mod.load(project_root, log_id=str(log_id))
19461
+ return {"success": bool(log), "log": log} if log else _err("log not found")
19462
+ if sub == "diff":
19463
+ first = _first_impression_mod.load(project_root, log_id=str(p.get("first_log_id") or p.get("firstLogId") or ""))
19464
+ later = _first_impression_mod.load(project_root, log_id=str(p.get("later_log_id") or p.get("laterLogId") or ""))
19465
+ if not first or not later:
19466
+ return _err("both first_log_id and later_log_id must name existing logs")
19467
+ return {"success": True, **_first_impression_mod.diff(first, later)}
19468
+ return _err("op must be one of: start, record, lock, get, list, diff",
19469
+ code="UNKNOWN_OP", category="invalid_input")
19470
+
19471
+ if action == "journal":
19472
+ # The paperwork every craft role keeps and every tool skips: ingest log,
19473
+ # accumulating known issues, session prep, handoff, status.
19474
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19475
+ if err:
19476
+ return err
19477
+ op = str(p.get("op") or p.get("sub_action") or "").strip().lower()
19478
+ if op == "append":
19479
+ return _journal_mod.append(
19480
+ project_root,
19481
+ kind=str(p.get("kind") or "note"),
19482
+ summary=str(p.get("summary") or ""),
19483
+ detail=p.get("detail"),
19484
+ ref=p.get("ref"),
19485
+ data=p.get("data") if isinstance(p.get("data"), dict) else None,
19486
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
19487
+ )
19488
+ if op == "read":
19489
+ return {"success": True,
19490
+ "records": _journal_mod.read(project_root, kind=p.get("kind"))}
19491
+ if op == "known_issues":
19492
+ state = _journal_mod.open_issues(project_root)
19493
+ return {"success": True, **state,
19494
+ "report_markdown": _journal_mod.render_known_issues(project_root)}
19495
+ if op == "ingest_log":
19496
+ return {"success": True,
19497
+ "report_markdown": _journal_mod.render_ingest_log(project_root)}
19498
+ if op == "session_prep":
19499
+ _f = lambda k: (float(p[k]) if p.get(k) is not None else None)
19500
+ return {"success": True, "report_markdown": _journal_mod.render_session_prep(
19501
+ project_root,
19502
+ session_kind=str(p.get("session_kind") or "colour"),
19503
+ prep_hours=_f("prep_hours"),
19504
+ estimated_hours_saved=_f("estimated_hours_saved"),
19505
+ hourly_rate=_f("hourly_rate"),
19506
+ ready=p.get("ready") if isinstance(p.get("ready"), list) else None,
19507
+ outstanding=p.get("outstanding") if isinstance(p.get("outstanding"), list) else None,
19508
+ )}
19509
+ if op == "handoff":
19510
+ return {"success": True, "report_markdown": _journal_mod.render_handoff(
19511
+ project=str(p.get("project") or (proj.GetName() if proj else "project")),
19512
+ to=str(p.get("to") or "receiving facility"),
19513
+ timeline_name=p.get("timeline_name") or p.get("timelineName"),
19514
+ technical=p.get("technical") if isinstance(p.get("technical"), dict) else None,
19515
+ included=p.get("included") if isinstance(p.get("included"), list) else None,
19516
+ known_issues=[i.get("summary") for i in _journal_mod.open_issues(project_root)["open"]],
19517
+ notes=p.get("notes"),
19518
+ )}
19519
+ if op == "picture_lock":
19520
+ _r2, proj2, _pr, err2 = _project_context(need_resolve=True)
19521
+ if err2:
19522
+ return err2
19523
+ tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
19524
+ if not tl:
19525
+ return _err("Timeline not found")
19526
+ return _journal_mod.set_picture_lock(
19527
+ project_root, timeline_name=tl.GetName(),
19528
+ items=_edit_engine_collect_items(tl),
19529
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
19530
+ )
19531
+ if op == "check_lock":
19532
+ _r2, proj2, _pr, err2 = _project_context(need_resolve=True)
19533
+ if err2:
19534
+ return err2
19535
+ tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
19536
+ if not tl:
19537
+ return _err("Timeline not found")
19538
+ return {"success": True, **_journal_mod.check_picture_lock(
19539
+ project_root, timeline_name=tl.GetName(),
19540
+ items=_edit_engine_collect_items(tl),
19541
+ )}
19542
+ if op == "status":
19543
+ _pc = p.get("percent_complete") if p.get("percent_complete") is not None else p.get("percentComplete")
19544
+ return {"success": True, "report_markdown": _journal_mod.render_status(
19545
+ project=str(p.get("project") or (proj.GetName() if proj else "project")),
19546
+ phase=str(p.get("phase") or "unspecified"),
19547
+ percent_complete=float(_pc) if _pc is not None else None,
19548
+ blockers=p.get("blockers") if isinstance(p.get("blockers"), list) else None,
19549
+ next_milestone=p.get("next_milestone") or p.get("nextMilestone"),
19550
+ open_issue_count=len(_journal_mod.open_issues(project_root)["open"]),
19551
+ )}
19552
+ return _err("op must be one of: append, read, known_issues, ingest_log, "
19553
+ "session_prep, handoff, status, picture_lock, check_lock",
19554
+ code="UNKNOWN_OP", category="invalid_input")
19555
+
19556
+ if action == "plan_reference_match":
19557
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19558
+ if err:
19559
+ return err
19560
+ ref = p.get("reference_media") or p.get("referenceMedia")
19561
+ if not ref:
19562
+ return _err("reference_media is required (path to the graded reference still or clip)",
19563
+ code="MISSING_REFERENCE", category="invalid_input")
19564
+ 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())
19565
+ if not tl:
19566
+ return _err("Timeline not found")
19567
+ _at = p.get("reference_at_seconds") if p.get("reference_at_seconds") is not None else p.get("referenceAtSeconds")
19568
+ _max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
19569
+ return _edit_engine_mod.plan_reference_match(
19570
+ project_root,
19571
+ reference_media=str(ref),
19572
+ reference_at_seconds=float(_at) if _at is not None else 0.0,
19573
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19574
+ timeline_fps=_edit_engine_timeline_fps(tl),
19575
+ max_items=int(_max) if _max is not None else 200,
19576
+ )
19577
+
19578
+ if action == "plan_string_out":
19579
+ shots = p.get("shots")
19580
+ if not isinstance(shots, list) or not shots:
19581
+ return _err("shots is required: [{name, start_seconds, end_seconds, motion_energy?}]",
19582
+ code="MISSING_SHOTS", category="invalid_input")
19583
+ return _edit_engine_mod.plan_string_out(
19584
+ shots=shots, order=str(p.get("order") or "chronological"))
19585
+
19586
+ if action == "propose_structure":
19587
+ topics = p.get("topics")
19588
+ if not isinstance(topics, list) or not topics:
19589
+ return _err("topics is required: [{label, total_seconds, clip_count}]",
19590
+ code="MISSING_TOPICS", category="invalid_input")
19591
+ return _edit_engine_mod.propose_structure(topics=topics)
19592
+
19593
+ if action == "plan_broll":
19594
+ beats = p.get("beats")
19595
+ candidates = p.get("candidates")
19596
+ if not isinstance(beats, list) or not beats:
19597
+ return _err("beats is required: [{start_seconds, end_seconds, text?, protected?}]",
19598
+ code="MISSING_BEATS", category="invalid_input")
19599
+ if not isinstance(candidates, list) or not candidates:
19600
+ return _err("candidates is required: [{name, duration_seconds, relevance?, beat_index?}]",
19601
+ code="MISSING_CANDIDATES", category="invalid_input")
19602
+ return _edit_engine_mod.plan_broll(
19603
+ beats=beats, candidates=candidates,
19604
+ allow_reuse=str(p.get("allow_reuse", p.get("allowReuse", False))).strip().lower() in {"true", "1", "yes", "on"},
19605
+ )
19606
+
19607
+ if action == "plan_turnover":
19608
+ dests = p.get("destinations") or p.get("destination")
19609
+ if isinstance(dests, str):
19610
+ dests = [dests]
19611
+ if not isinstance(dests, list) or not dests:
19612
+ return _err("destinations is required: any of sound, vfx, color",
19613
+ code="MISSING_DESTINATIONS", category="invalid_input")
19614
+ _hf = p.get("handle_frames") if p.get("handle_frames") is not None else p.get("handleFrames")
19615
+ return _edit_engine_mod.plan_turnover(
19616
+ destinations=dests,
19617
+ contents=p.get("contents") if isinstance(p.get("contents"), dict) else {},
19618
+ version=str(p.get("version") or "v01"),
19619
+ handle_frames=int(_hf) if _hf is not None else None,
19620
+ )
19621
+
19622
+ if action == "conform_lint":
19623
+ # The online editor's checklist, run before turnover instead of
19624
+ # discovered after picture lock in someone else's suite.
19625
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19626
+ if err:
19627
+ return err
19628
+ 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())
19629
+ if not tl:
19630
+ return _err("Timeline not found")
19631
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19632
+ return _attach_audit_report(_conform_lint_mod.lint_timeline({
19633
+ "timeline_name": tl.GetName(),
19634
+ "timeline_fps": _edit_engine_timeline_fps(tl),
19635
+ "items": items,
19636
+ }), p)
19637
+
19638
+ if action == "plan_beat_cuts":
19639
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19640
+ if err:
19641
+ return err
19642
+ _fps = p.get("timeline_fps") if p.get("timeline_fps") is not None else p.get("timelineFps")
19643
+ return _edit_engine_mod.plan_beat_cuts(
19644
+ project_root,
19645
+ clip_ref=p.get("clip_ref") or p.get("clipRef"),
19646
+ media_path=p.get("media_path") or p.get("mediaPath"),
19647
+ timeline_fps=float(_fps) if _fps is not None else 24.0,
19648
+ mode=str(p.get("mode") or "phrase"),
19649
+ beats_per_bar=int(p.get("beats_per_bar") or p.get("beatsPerBar") or 4),
19650
+ bars_per_phrase=int(p.get("bars_per_phrase") or p.get("barsPerPhrase") or 8),
19651
+ beat_offset=int(p.get("beat_offset") or p.get("beatOffset") or 0),
19652
+ min_shot_seconds=float(p.get("min_shot_seconds") or p.get("minShotSeconds") or 0.0),
19653
+ )
19654
+
19655
+ if action == "plan_prebalance":
19656
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19657
+ if err:
19658
+ return err
19659
+ 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())
19660
+ if not tl:
19661
+ return _err("Timeline not found")
19662
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19663
+ _max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
19664
+ return _edit_engine_mod.plan_prebalance(
19665
+ project_root,
19666
+ items=items,
19667
+ timeline_name=tl.GetName(),
19668
+ timeline_fps=_edit_engine_timeline_fps(tl),
19669
+ max_items=int(_max) if _max is not None else 200,
19670
+ )
19671
+
19672
+ if action == "plan_dead_space_markers":
19673
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19674
+ if err:
19675
+ return err
19676
+ 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())
19677
+ if not tl:
19678
+ return _err("Timeline not found")
19679
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19680
+ return _edit_engine_mod.plan_dead_space_markers(
19681
+ project_root,
19682
+ items=items,
19683
+ timeline_name=tl.GetName(),
19684
+ timeline_fps=_edit_engine_timeline_fps(tl),
19685
+ # Same calibrate-by-default contract as plan_silence_ripple: what you
19686
+ # review here must be what that would remove.
19687
+ threshold_db=(
19688
+ float(_th)
19689
+ if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
19690
+ else None
19691
+ ),
19692
+ tightness=p.get("tightness"),
19693
+ min_strip_frames=float(p.get("min_strip_frames") if p.get("min_strip_frames") is not None else p.get("minStripFrames") if p.get("minStripFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_MIN_STRIP_FRAMES),
19694
+ pre_head_frames=float(p.get("pre_head_frames") if p.get("pre_head_frames") is not None else p.get("preHeadFrames") if p.get("preHeadFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_PRE_HEAD_FRAMES),
19695
+ post_tail_frames=float(p.get("post_tail_frames") if p.get("post_tail_frames") is not None else p.get("postTailFrames") if p.get("postTailFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_POST_TAIL_FRAMES),
19696
+ )
19697
+
19196
19698
  if action == "plan_swap":
19197
19699
  _r, proj, project_root, err = _project_context(need_resolve=True)
19198
19700
  if err:
@@ -19765,7 +20267,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19765
20267
  "plan_tighten",
19766
20268
  "execute_tighten",
19767
20269
  "plan_silence_ripple",
20270
+ "plan_dead_space_markers",
20271
+ "conform_lint",
20272
+ "rule_of_six_audit",
20273
+ "split_edit_audit",
20274
+ "sound_density_audit",
20275
+ "setup_sheet",
20276
+ "first_impression",
20277
+ "journal",
20278
+ "plan_prebalance",
20279
+ "plan_reference_match",
20280
+ "plan_beat_cuts",
20281
+ "plan_string_out",
20282
+ "propose_structure",
20283
+ "plan_broll",
20284
+ "plan_turnover",
19768
20285
  "plan_transcript_tighten",
20286
+ "rank_takes",
19769
20287
  "generate_captions",
19770
20288
  "search_spoken_content",
19771
20289
  "execute_silence_ripple",
@@ -19773,6 +20291,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19773
20291
  "execute_swap",
19774
20292
  "list_plans",
19775
20293
  "get_plan",
20294
+ "plan_report",
19776
20295
  ])
19777
20296
 
19778
20297