davinci-resolve-mcp 2.62.2 → 2.63.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,65 @@
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.63.0
6
+
7
+ Waveform silence ripple for the edit engine (PR #91, by @EliteSystemsAI),
8
+ adopted with follow-up hardening and live-validated in Resolve Studio.
9
+
10
+ ### Added
11
+
12
+ - **`plan_silence_ripple` / `execute_silence_ripple`** — waveform-driven dead-air
13
+ removal that mirrors Resolve's *Ripple Delete Silence* dialog (ffmpeg
14
+ `silencedetect` → keep-range variant assembly). Tunable `threshold_db`,
15
+ `min_strip_frames`, `pre_head_frames`, and `post_tail_frames`. Original
16
+ timeline is never mutated; execution is confirm-gated like `execute_tighten`.
17
+ New module: `src/utils/silence_ripple.py`; tests in
18
+ `tests/test_silence_ripple.py` and `tests/test_edit_engine.py`.
19
+
20
+ ### Fixed (hardening on the PR before release)
21
+
22
+ - **Skipped items ride along whole** — timeline items without a readable media
23
+ file path now emit a full-range keep (video + mirrored audio) so the
24
+ assembled variant never silently loses content; only items with no media
25
+ reference at all are omitted, and the `skipped` reason says so explicitly.
26
+ - **All audio streams are merged before detection** — production MXF often
27
+ carries one mono PCM stream per channel, and ffmpeg's default stream
28
+ selection could land on a dead scratch channel, reading entire takes as
29
+ silence. Detection now merges every audio stream (silence only when ALL
30
+ channels are silent — Resolve's dialog semantics), with a single-stream
31
+ fallback if `amerge` refuses the graph. Found by live validation on 5-stream
32
+ interview masters.
33
+ - **No pointless video decode** — silence detection runs with `-vn`, so 4K
34
+ ProRes sources no longer decode picture just to scan audio.
35
+ - **Clear failure modes** — a missing ffmpeg binary now reports itself instead
36
+ of "no silence regions found", and a plan whose keep ranges are empty
37
+ (source quieter than `threshold_db` throughout) carries an explicit warning
38
+ and is refused at execution.
39
+
40
+ ## What's New in v2.62.3
41
+
42
+ A single grading fix (PR #90, by @Mldphotohraphie), extended and hardened. No
43
+ new tool surface.
44
+
45
+ ### Fixed
46
+
47
+ - **`graph set_lut` now applies LUTs/DCTLs installed by the `dctl` tool** —
48
+ the `dctl` tool installs into Resolve's per-user LUT directory, but
49
+ `Graph.SetLUT()` resolves LUT paths (relative names *and* absolute paths)
50
+ only against the master (system) LUT directory. A freshly installed LUT could
51
+ therefore never be applied, and `set_lut` always returned
52
+ `{"success": false}`. Verified live on Resolve Studio 19.1.3.7: `SetLUT` fails
53
+ for a user-dir LUT even after `RefreshLUTList()` and even via an absolute
54
+ user-dir path, so relocation into the master dir is genuinely required. (The
55
+ originating report, PR #90, observed the same on Studio 21.0.2.) On a `SetLUT`
56
+ failure the server now locates the LUT, stages it under a namespaced
57
+ `MCP/` subfolder of the master LUT dir (avoiding basename collisions with
58
+ stock/vendor LUTs), calls `RefreshLUTList()`, and retries. No behavior change
59
+ when `SetLUT` already succeeds. Applied to both `graph set_lut` (`src/server.py`)
60
+ and the granular `graph_set_lut` (`src/granular/graph.py`) via a shared
61
+ `src/utils/lut_paths.py` helper, with offline coverage in
62
+ `tests/test_lut_paths.py`.
63
+
5
64
  ## What's New in v2.62.2
6
65
 
7
66
  A single cross-platform launcher fix. No new tool surface; default behavior is
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.62.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.63.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -820,6 +820,15 @@ clip-count readback plus `brain_edits` rationale rows.
820
820
  speech-driven cut was previously silent — #67); pass
821
821
  `include_audio=false` for a video-only assembly, and the
822
822
  `execute_tighten` readback carries an `audio_accounting` block.
823
+ - `plan_silence_ripple(timeline_name?, track_index?, threshold_db?,
824
+ min_strip_frames?, pre_head_frames?, post_tail_frames?, include_audio?)` —
825
+ waveform silence strips via ffmpeg `silencedetect`, mirroring Resolve's
826
+ *Clip → Audio Operations → Ripple Delete Silence* (defaults: −30 dB,
827
+ 10-frame minimum strip, 0 pre-head, 1 post-tail frame). Items without
828
+ readable file paths ride along whole (reported in `skipped`), so the
829
+ variant never silently loses content. `execute_silence_ripple(plan_id)`
830
+ assembles a tightened VARIANT timeline from keep ranges — same safety model
831
+ as `execute_tighten` (original untouched, confirm token, audio mirroring).
823
832
  - `plan_swap(timeline_start_frame | item_name, kind="visual"|"text",
824
833
  limit?)` — alternates for one timeline item via the similarity index,
825
834
  filtered to shots long enough to fill the slot exactly.
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.62.2"
38
+ VERSION = "2.63.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.62.2",
3
+ "version": "2.63.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.62.2"
83
+ VERSION = "2.63.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()}")
@@ -1,6 +1,7 @@
1
1
  """Color page graph, LUT, and color-group tools."""
2
2
 
3
3
  from src.granular.common import * # noqa: F401,F403
4
+ from src.utils.lut_paths import ensure_lut_in_master
4
5
 
5
6
  resolve = ResolveProxy()
6
7
 
@@ -39,8 +40,25 @@ def graph_set_lut(node_index: int, lut_path: str, item_index: int = 0, track_typ
39
40
  graph = item.GetNodeGraph()
40
41
  if not graph:
41
42
  return {"error": "No node graph available"}
42
- result = graph.SetLUT(node_index, lut_path)
43
- return {"success": bool(result)}
43
+ ok = bool(graph.SetLUT(node_index, lut_path))
44
+ if not ok:
45
+ # SetLUT resolves LUTs only against the master LUT dir, not the per-user
46
+ # dir dctl installs to. Relocate into the master dir and retry.
47
+ relocated = ensure_lut_in_master(lut_path)
48
+ if relocated:
49
+ try:
50
+ project = resolve.GetProjectManager().GetCurrentProject()
51
+ if project:
52
+ project.RefreshLUTList()
53
+ except Exception:
54
+ pass
55
+ ok = bool(graph.SetLUT(node_index, relocated))
56
+ if ok:
57
+ return {"success": True, "resolved_lut": relocated,
58
+ "note": "LUT staged under the master LUT dir "
59
+ "(MCP/ subfolder) and applied; SetLUT does "
60
+ "not resolve the user LUT dir."}
61
+ return {"success": ok}
44
62
 
45
63
 
46
64
  @mcp.tool()
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.62.2"
14
+ VERSION = "2.63.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -97,6 +97,7 @@ from src.utils.media_analysis_jobs import (
97
97
  run_batch_job_slice as run_media_analysis_batch_job_slice,
98
98
  )
99
99
  from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths
100
+ from src.utils.lut_paths import master_lut_dir, ensure_lut_in_master
100
101
  from src.utils import fuse_templates, dctl_templates, script_templates
101
102
  from src.utils.timeline_title_text import (
102
103
  candidate_title_property_keys as _candidate_title_property_keys,
@@ -1044,6 +1045,7 @@ _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
1044
1045
  # Phase E edit-engine loops: plan → confirm → execute.
1045
1046
  ("edit_engine", "execute_selects"),
1046
1047
  ("edit_engine", "execute_tighten"),
1048
+ ("edit_engine", "execute_silence_ripple"),
1047
1049
  ("edit_engine", "execute_swap"),
1048
1050
  ("graph", "apply_grade_from_drx"),
1049
1051
  ("graph", "reset_all_grades"),
@@ -18419,6 +18421,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18419
18421
  block and a compact structural_diff (counts + a small sample); the full
18420
18422
  per-item diff is persisted in the plan record (get_plan → execution_summary)
18421
18423
  and returned inline only when include_details=true.
18424
+ - plan_silence_ripple(timeline_name?, track_index?, threshold_db?,
18425
+ min_strip_frames?, pre_head_frames?, post_tail_frames?, include_audio?)
18426
+ — waveform silence strips via ffmpeg silencedetect (Resolve's Ripple Delete
18427
+ Silence parity). Items without readable file paths ride along whole
18428
+ (reported in skipped).
18429
+ - execute_silence_ripple(plan_id, confirm_token?, include_details?) —
18430
+ same variant assembly as execute_tighten; original timeline untouched.
18422
18431
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
18423
18432
  — alternates for one timeline item via the similarity index.
18424
18433
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -18493,6 +18502,26 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18493
18502
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
18494
18503
  )
18495
18504
 
18505
+ if action == "plan_silence_ripple":
18506
+ _r, proj, project_root, err = _project_context(need_resolve=True)
18507
+ if err:
18508
+ return err
18509
+ 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())
18510
+ if not tl:
18511
+ return _err("Timeline not found")
18512
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
18513
+ return _edit_engine_mod.plan_silence_ripple(
18514
+ project_root,
18515
+ items=items,
18516
+ timeline_name=tl.GetName(),
18517
+ timeline_fps=_edit_engine_timeline_fps(tl),
18518
+ threshold_db=float(p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb") if p.get("thresholdDb") is not None else _edit_engine_mod.DEFAULT_SILENCE_THRESHOLD_DB),
18519
+ 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),
18520
+ 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),
18521
+ 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),
18522
+ include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
18523
+ )
18524
+
18496
18525
  if action == "plan_swap":
18497
18526
  _r, proj, project_root, err = _project_context(need_resolve=True)
18498
18527
  if err:
@@ -18768,6 +18797,141 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18768
18797
  "plan_id": plan.get("plan_id"),
18769
18798
  }
18770
18799
 
18800
+ if action == "execute_silence_ripple":
18801
+ _r, proj, project_root, err = _project_context(need_resolve=True)
18802
+ if err:
18803
+ return err
18804
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
18805
+ if not plan or plan.get("_corrupt"):
18806
+ return _err("plan not found (or failed its fingerprint check) — re-plan")
18807
+ if plan.get("kind") != "silence_ripple":
18808
+ return _err(f"plan {plan.get('plan_id')} is a {plan.get('kind')} plan")
18809
+ lifts = plan.get("lifts") or []
18810
+ keep_ranges = plan.get("keep_ranges") or []
18811
+ if not keep_ranges:
18812
+ return _err("plan has no keep_ranges — re-plan with this version")
18813
+ audio_keep_ranges = sum(1 for r in keep_ranges if str(r.get("track_type", "video")).lower() == "audio")
18814
+ video_keep_ranges = len(keep_ranges) - audio_keep_ranges
18815
+ settings = plan.get("settings") or {}
18816
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
18817
+ return _issue_confirm_token(
18818
+ action="edit_engine.execute_silence_ripple", params=p,
18819
+ preview={
18820
+ "operation": "edit_engine.execute_silence_ripple",
18821
+ "warning": (
18822
+ "Assembles a silence-ripple VARIANT timeline from waveform "
18823
+ "keep ranges; the original timeline is not modified."
18824
+ ),
18825
+ "timeline_name": plan.get("timeline_name"),
18826
+ "lift_count": len(lifts),
18827
+ "keep_range_count": len(keep_ranges),
18828
+ "video_keep_range_count": video_keep_ranges,
18829
+ "audio_keep_range_count": audio_keep_ranges,
18830
+ "settings": settings,
18831
+ "estimated_removed_seconds": sum(l.get("duration_seconds") or 0 for l in lifts),
18832
+ },
18833
+ )
18834
+ blocked = _consume_confirm_token(action="edit_engine.execute_silence_ripple", params=p)
18835
+ if blocked:
18836
+ return blocked
18837
+ source_tl, _src_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
18838
+ if not source_tl:
18839
+ return _err(f"Timeline '{plan.get('timeline_name')}' not found")
18840
+ before = _edit_engine_capture(source_tl)
18841
+ variant_name = f"{plan.get('timeline_name')} — silence ripple {time.strftime('%H%M%S')}"
18842
+ variant = _timeline_create_variant_from_ranges(proj, source_tl, {
18843
+ "ranges": keep_ranges,
18844
+ "name": variant_name,
18845
+ })
18846
+ if not variant.get("success"):
18847
+ return {"success": False, "error": f"variant assembly failed: {variant.get('error')}", "variant": variant}
18848
+ new_tl, _new_index = _find_timeline_by_name(proj, variant.get("name") or variant_name)
18849
+ after = _edit_engine_capture(new_tl) if new_tl else {}
18850
+ structural_diff = None
18851
+ if new_tl is not None:
18852
+ try:
18853
+ structural_diff = _timeline_versioning.compare_usage_snapshots(
18854
+ _timeline_versioning.capture_timeline_clip_usage(source_tl),
18855
+ _timeline_versioning.capture_timeline_clip_usage(new_tl),
18856
+ )
18857
+ except Exception as diff_exc:
18858
+ structural_diff = {"error": f"{type(diff_exc).__name__}: {diff_exc}"}
18859
+ run_id = _analysis_runs.current_run_id()
18860
+ try:
18861
+ _brain_edits.log_brain_edit(
18862
+ project_root=project_root,
18863
+ analysis_run_id=run_id or "edit-engine",
18864
+ edit_type="edit_engine.silence_ripple_result",
18865
+ tool_name="edit_engine",
18866
+ action_name="execute_silence_ripple",
18867
+ timeline_before=plan.get("timeline_name"),
18868
+ timeline_after=variant.get("name") or variant_name,
18869
+ target_metric=_brain_edits.METRIC_DURATION_SECONDS,
18870
+ metric_direction="decrease",
18871
+ before_value=before.get("duration_seconds"),
18872
+ after_value=after.get("duration_seconds"),
18873
+ rationale=plan.get("summary"),
18874
+ params={"plan_id": plan.get("plan_id"), "settings": settings},
18875
+ result_summary={"keep_ranges": len(keep_ranges), "lifts": len(lifts)},
18876
+ )
18877
+ except Exception:
18878
+ pass
18879
+ include_details = _media_analysis_bool(
18880
+ p.get("include_details", p.get("includeDetails")), False
18881
+ )
18882
+ _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
18883
+ "variant_timeline": variant.get("name") or variant_name,
18884
+ "keep_ranges": len(keep_ranges),
18885
+ "lifts": len(lifts),
18886
+ "before": before,
18887
+ "after": after,
18888
+ "structural_diff": structural_diff,
18889
+ "settings": settings,
18890
+ })
18891
+ return {
18892
+ "success": True,
18893
+ "original_timeline": plan.get("timeline_name"),
18894
+ "variant_timeline": variant.get("name") or variant_name,
18895
+ "lifts_applied": len(lifts),
18896
+ "keep_ranges": len(keep_ranges),
18897
+ "settings": settings,
18898
+ "lift_rationales": [
18899
+ {"lift": [l["timeline_start_frame"], l["timeline_end_frame"]], "rationale": l.get("rationale")}
18900
+ for l in lifts
18901
+ ],
18902
+ "readback": {
18903
+ "before": before,
18904
+ "after": after,
18905
+ "removed_seconds": (
18906
+ round(before["duration_seconds"] - after["duration_seconds"], 2)
18907
+ if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
18908
+ else None
18909
+ ),
18910
+ "structural_diff": (
18911
+ structural_diff if include_details
18912
+ else _compact_structural_diff(structural_diff)
18913
+ ),
18914
+ "audio_accounting": {
18915
+ "planned_audio_ranges": audio_keep_ranges,
18916
+ "planned_video_ranges": video_keep_ranges,
18917
+ "variant_audio_items": sum(
18918
+ 1 for it in (variant.get("items") or [])
18919
+ if (it.get("range") or {}).get("media_type") == 2
18920
+ ),
18921
+ "variant_video_items": sum(
18922
+ 1 for it in (variant.get("items") or [])
18923
+ if (it.get("range") or {}).get("media_type") == 1
18924
+ ),
18925
+ "note": (
18926
+ "Variant carries audio mirrored from the video cuts."
18927
+ if audio_keep_ranges
18928
+ else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
18929
+ ),
18930
+ },
18931
+ },
18932
+ "plan_id": plan.get("plan_id"),
18933
+ }
18934
+
18771
18935
  if action == "execute_swap":
18772
18936
  _r, proj, project_root, err = _project_context(need_resolve=True)
18773
18937
  if err:
@@ -18929,6 +19093,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18929
19093
  "execute_selects",
18930
19094
  "plan_tighten",
18931
19095
  "execute_tighten",
19096
+ "plan_silence_ripple",
19097
+ "execute_silence_ripple",
18932
19098
  "plan_swap",
18933
19099
  "execute_swap",
18934
19100
  "list_plans",
@@ -22070,7 +22236,27 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
22070
22236
  elif action == "get_lut":
22071
22237
  return {"lut": g.GetLUT(p["node_index"])}
22072
22238
  elif action == "set_lut":
22073
- return {"success": bool(g.SetLUT(p["node_index"], p["lut_path"]))}
22239
+ node_index = p["node_index"]
22240
+ lut_path = p["lut_path"]
22241
+ ok = bool(g.SetLUT(node_index, lut_path))
22242
+ if not ok:
22243
+ # Resolve resolves LUTs for SetLUT only against the master LUT dir,
22244
+ # not the per-user dir that dctl install writes to. Relocate and retry.
22245
+ relocated = _ensure_lut_in_master(lut_path)
22246
+ if relocated:
22247
+ try:
22248
+ _, proj, _lut_err = _check()
22249
+ if proj:
22250
+ proj.RefreshLUTList()
22251
+ except Exception:
22252
+ pass
22253
+ ok = bool(g.SetLUT(node_index, relocated))
22254
+ if ok:
22255
+ return {"success": True, "resolved_lut": relocated,
22256
+ "note": "LUT staged under the master LUT dir "
22257
+ "(MCP/ subfolder) and applied; SetLUT does "
22258
+ "not resolve the user LUT dir."}
22259
+ return {"success": ok}
22074
22260
  elif action == "get_node_cache":
22075
22261
  return {"cache": g.GetNodeCacheMode(p["node_index"])}
22076
22262
  elif action == "set_node_cache":
@@ -23771,6 +23957,13 @@ def _dctl_dir(category: str = "lut") -> str:
23771
23957
  "Valid: lut, aces_idt, aces_odt")
23772
23958
 
23773
23959
 
23960
+ # LUT relocation for Graph.SetLUT lives in src.utils.lut_paths so server.py and
23961
+ # src/granular/graph.py share one implementation (see the module docstring for
23962
+ # the live-verified behavior). Thin aliases keep the existing call sites stable.
23963
+ _master_lut_dir = master_lut_dir
23964
+ _ensure_lut_in_master = ensure_lut_in_master
23965
+
23966
+
23774
23967
  def _validate_dctl_name(name: str) -> Optional[Dict[str, Any]]:
23775
23968
  if not name or not _DCTL_NAME_RE.match(name):
23776
23969
  return _err(f"Invalid DCTL name '{name}'. "
@@ -70,6 +70,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
70
70
  "edit_engine": frozenset({
71
71
  "execute_selects",
72
72
  "execute_tighten",
73
+ "execute_silence_ripple",
73
74
  "execute_swap",
74
75
  }),
75
76
  "timeline": frozenset({
@@ -31,10 +31,15 @@ import uuid
31
31
  from typing import Any, Dict, List, Optional, Sequence, Tuple
32
32
 
33
33
  from src.utils import analysis_memory, analysis_store, timeline_brain_db
34
+ from src.utils import silence_ripple as _silence_ripple_mod
34
35
 
35
36
  PLAN_DIR_NAME = "edit_plans"
36
37
  DEFAULT_HANDLE_SECONDS = 0.25
37
38
  DEFAULT_MIN_PAUSE_SECONDS = 1.5
39
+ DEFAULT_SILENCE_THRESHOLD_DB = _silence_ripple_mod.DEFAULT_THRESHOLD_DB
40
+ DEFAULT_SILENCE_MIN_STRIP_FRAMES = _silence_ripple_mod.DEFAULT_MIN_STRIP_FRAMES
41
+ DEFAULT_SILENCE_PRE_HEAD_FRAMES = _silence_ripple_mod.DEFAULT_PRE_HEAD_FRAMES
42
+ DEFAULT_SILENCE_POST_TAIL_FRAMES = _silence_ripple_mod.DEFAULT_POST_TAIL_FRAMES
38
43
 
39
44
  _SELECT_RANK = {"high": 3, "medium": 2, "low": 1}
40
45
 
@@ -593,6 +598,255 @@ def plan_tighten(
593
598
  }
594
599
 
595
600
 
601
+ # ── E2b: waveform silence ripple (Resolve UI parity) ─────────────────────────
602
+
603
+
604
+ def _resolve_media_path(conn, item: Dict[str, Any]) -> Optional[str]:
605
+ path = item.get("media_path") or item.get("file_path")
606
+ if path and os.path.isfile(str(path)):
607
+ return str(path)
608
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_ref"))
609
+ if not clip_uuid:
610
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_path"))
611
+ if not clip_uuid:
612
+ return None
613
+ row = conn.execute("SELECT file_path FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
614
+ if not row or not row["file_path"]:
615
+ return None
616
+ fp = str(row["file_path"])
617
+ return fp if os.path.isfile(fp) else None
618
+
619
+
620
+ def plan_silence_ripple(
621
+ project_root: str,
622
+ *,
623
+ items: Sequence[Dict[str, Any]],
624
+ timeline_name: str,
625
+ timeline_fps: float,
626
+ threshold_db: float = DEFAULT_SILENCE_THRESHOLD_DB,
627
+ min_strip_frames: float = DEFAULT_SILENCE_MIN_STRIP_FRAMES,
628
+ pre_head_frames: float = DEFAULT_SILENCE_PRE_HEAD_FRAMES,
629
+ post_tail_frames: float = DEFAULT_SILENCE_POST_TAIL_FRAMES,
630
+ include_audio: bool = True,
631
+ ) -> Dict[str, Any]:
632
+ """Propose silence strips from waveform detection (ffmpeg silencedetect).
633
+
634
+ Mirrors Resolve's *Clip → Audio Operations → Ripple Delete Silence*.
635
+ Each timeline item is analyzed over its source trim; detected silences
636
+ become lifts assembled into a tightened VARIANT via keep_ranges.
637
+ """
638
+ if not items:
639
+ return {"success": False, "error": "No timeline items supplied"}
640
+ fps = float(timeline_fps) if timeline_fps and float(timeline_fps) > 0 else 24.0
641
+ min_strip_sec = _silence_ripple_mod.frames_to_seconds(min_strip_frames, fps)
642
+ pre_head_sec = _silence_ripple_mod.frames_to_seconds(pre_head_frames, fps)
643
+ post_tail_sec = _silence_ripple_mod.frames_to_seconds(post_tail_frames, fps)
644
+ conn = timeline_brain_db.connect(project_root)
645
+
646
+ lifts: List[Dict[str, Any]] = []
647
+ skipped: List[Dict[str, Any]] = []
648
+ item_specs: List[Dict[str, Any]] = []
649
+ timeline_total_frames = 0
650
+
651
+ for item_index, item in enumerate(items):
652
+ try:
653
+ tl_start = int(item["timeline_start_frame"])
654
+ tl_end = int(item["timeline_end_frame"])
655
+ src_start_frame = int(item.get("source_start_frame") or 0)
656
+ except (KeyError, TypeError, ValueError):
657
+ skipped.append({"item": item.get("item_name"), "reason": "missing frame fields"})
658
+ continue
659
+ timeline_total_frames += max(0, tl_end - tl_start)
660
+
661
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_ref"))
662
+ if not clip_uuid:
663
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, item.get("media_path"))
664
+ clip_row = None
665
+ clip_fps = fps
666
+ resolve_clip_id = None
667
+ if clip_uuid:
668
+ clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
669
+ if clip_row:
670
+ clip_fps = _clip_fps(dict(clip_row))
671
+ resolve_clip_id = dict(clip_row).get("resolve_clip_id")
672
+ resolve_clip_id = resolve_clip_id or item.get("media_ref")
673
+ if not resolve_clip_id:
674
+ skipped.append({
675
+ "item": item.get("item_name"),
676
+ "reason": "no media reference — OMITTED from the assembled variant",
677
+ })
678
+ continue
679
+
680
+ src_start_sec = src_start_frame / clip_fps
681
+ src_end_sec = src_start_sec + (tl_end - tl_start) / fps
682
+ spec = {
683
+ "item_index": item_index,
684
+ "item": item,
685
+ "clip_uuid": clip_uuid,
686
+ "clip_fps": clip_fps,
687
+ "resolve_clip_id": resolve_clip_id,
688
+ "src_start_sec": src_start_sec,
689
+ "src_end_sec": src_end_sec,
690
+ # Default: ride along whole. Overwritten below when waveform
691
+ # evidence is available for this item.
692
+ "keep_segments": [(src_start_sec, src_end_sec)],
693
+ "strip_regions": [],
694
+ }
695
+
696
+ media_path = _resolve_media_path(conn, item)
697
+ if not media_path:
698
+ skipped.append({
699
+ "item": item.get("item_name"),
700
+ "reason": "no readable media file path — kept whole in variant",
701
+ })
702
+ item_specs.append(spec)
703
+ continue
704
+ if not _silence_ripple_mod.ffmpeg_available():
705
+ return {
706
+ "success": False,
707
+ "error": (
708
+ "ffmpeg not found on PATH — waveform silence detection "
709
+ "requires ffmpeg (see media_analysis capabilities)"
710
+ ),
711
+ }
712
+
713
+ strip_regions, keep_segments = _silence_ripple_mod.plan_item_silence_strips(
714
+ media_path,
715
+ src_start_sec,
716
+ src_end_sec,
717
+ threshold_db=float(threshold_db),
718
+ min_strip_sec=min_strip_sec,
719
+ pre_head_sec=pre_head_sec,
720
+ post_tail_sec=post_tail_sec,
721
+ )
722
+ spec["keep_segments"] = keep_segments
723
+ spec["strip_regions"] = strip_regions
724
+ item_specs.append(spec)
725
+
726
+ for strip_start, strip_end in strip_regions:
727
+ lift_start = tl_start + int(round((strip_start - src_start_sec) * fps))
728
+ lift_end = tl_start + int(round((strip_end - src_start_sec) * fps))
729
+ if lift_end <= lift_start:
730
+ continue
731
+ lifts.append({
732
+ "kind": "silence",
733
+ "action": "ripple_delete",
734
+ "timeline_start_frame": lift_start,
735
+ "timeline_end_frame": lift_end,
736
+ "duration_seconds": round((lift_end - lift_start) / fps, 3),
737
+ "item_name": item.get("item_name"),
738
+ "item_index": item_index,
739
+ "source_lift_seconds": [round(strip_start, 3), round(strip_end, 3)],
740
+ "rationale": (
741
+ f"Waveform silence {round(strip_start, 2)}s–{round(strip_end, 2)}s "
742
+ f"(threshold {threshold_db} dB, min {min_strip_frames} frames)."
743
+ ),
744
+ "evidence": {
745
+ "basis": "ffmpeg_silencedetect",
746
+ "threshold_db": threshold_db,
747
+ "source_gap_seconds": [round(strip_start, 3), round(strip_end, 3)],
748
+ },
749
+ })
750
+
751
+ skipped = _dedupe_skipped(skipped)
752
+ if not lifts:
753
+ return {
754
+ "success": False,
755
+ "error": "No silence regions found above threshold",
756
+ "skipped": skipped,
757
+ "note": (
758
+ f"threshold_db={threshold_db}, min_strip_frames={min_strip_frames}; "
759
+ "items without readable file paths carry no waveform evidence "
760
+ "(kept whole — see skipped)."
761
+ ),
762
+ }
763
+
764
+ lifts.sort(key=lambda l: -l["timeline_start_frame"])
765
+
766
+ keep_ranges: List[Dict[str, Any]] = []
767
+ for spec in item_specs:
768
+ item = spec["item"]
769
+ clip_fps = spec["clip_fps"]
770
+ audio_indices: List[int] = []
771
+ if include_audio:
772
+ audio_indices = [int(i) for i in (item.get("audio_track_indices") or []) if int(i) > 0]
773
+ if not audio_indices:
774
+ audio_indices = [1]
775
+ for seg_start, seg_end in spec.get("keep_segments") or []:
776
+ start_frame = int(round(seg_start * clip_fps))
777
+ end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)))
778
+ keep_ranges.append({
779
+ "clip_id": spec["resolve_clip_id"],
780
+ "start_frame": start_frame,
781
+ "end_frame": end_frame,
782
+ "track_type": "video",
783
+ "track_index": int(item.get("track_index") or 1),
784
+ })
785
+ for audio_index in audio_indices:
786
+ keep_ranges.append({
787
+ "clip_id": spec["resolve_clip_id"],
788
+ "start_frame": start_frame,
789
+ "end_frame": end_frame,
790
+ "track_type": "audio",
791
+ "media_type": 2,
792
+ "track_index": audio_index,
793
+ })
794
+
795
+ removed_frames = sum(l["timeline_end_frame"] - l["timeline_start_frame"] for l in lifts)
796
+ audio_keep_range_count = sum(1 for r in keep_ranges if r.get("track_type") == "audio")
797
+ video_keep_range_count = len(keep_ranges) - audio_keep_range_count
798
+ plan = save_plan(project_root, {
799
+ "kind": "silence_ripple",
800
+ "timeline_name": timeline_name,
801
+ "timeline_fps": fps,
802
+ "lifts": lifts,
803
+ "keep_ranges": keep_ranges,
804
+ "include_audio": bool(include_audio),
805
+ "skipped": skipped,
806
+ "summary": (
807
+ f"{len(lifts)} silence strips, ~{round(removed_frames / fps, 1)}s removed "
808
+ f"from '{timeline_name}' (waveform ripple-delete variant"
809
+ f"{', video + audio' if include_audio else ', video only'})"
810
+ ),
811
+ "settings": {
812
+ "threshold_db": threshold_db,
813
+ "min_strip_frames": min_strip_frames,
814
+ "pre_head_frames": pre_head_frames,
815
+ "post_tail_frames": post_tail_frames,
816
+ "include_audio": bool(include_audio),
817
+ },
818
+ })
819
+ result = {
820
+ "success": True,
821
+ "status": "plan_ready",
822
+ "plan_id": plan["plan_id"],
823
+ "kind": "silence_ripple",
824
+ "timeline_name": timeline_name,
825
+ "lift_count": len(lifts),
826
+ "estimated_removed_seconds": round(removed_frames / fps, 2),
827
+ "lifts": lifts,
828
+ "keep_range_count": len(keep_ranges),
829
+ "video_keep_range_count": video_keep_range_count,
830
+ "audio_keep_range_count": audio_keep_range_count,
831
+ "include_audio": bool(include_audio),
832
+ "skipped": skipped,
833
+ "note": (
834
+ "Dry-run plan. Execute with edit_engine(action='execute_silence_ripple', "
835
+ "params={plan_id}) — a tightened VARIANT timeline is assembled from "
836
+ "waveform silence detection; the original timeline is never mutated. "
837
+ "Tune threshold_db / min_strip_frames to match Resolve's "
838
+ "Ripple Delete Silence dialog."
839
+ ),
840
+ }
841
+ if video_keep_range_count == 0:
842
+ result["warning"] = (
843
+ "Plan removes ALL planned content (no keep ranges survived) — "
844
+ "the source audio may be quieter than threshold_db throughout. "
845
+ "Check the threshold before executing; execution will refuse this plan."
846
+ )
847
+ return result
848
+
849
+
596
850
  # ── E3: swap alternates ──────────────────────────────────────────────────────
597
851
 
598
852
 
@@ -0,0 +1,81 @@
1
+ """Master LUT directory resolution for Graph.SetLUT().
2
+
3
+ Resolve's Graph.SetLUT() resolves relative LUT names -- and even absolute
4
+ paths -- ONLY against the master (system) LUT root, NOT the per-user LUT dir
5
+ that the dctl tool installs into. Verified live on Resolve Studio 19.1.3.7:
6
+ SetLUT succeeds for a LUT in the master dir (by relative or subfolder path) and
7
+ returns False for the same file in the user dir, even after RefreshLUTList and
8
+ even via an absolute user-dir path. (The originating report, PR #90, observed
9
+ the same behavior on Studio 21.0.2.) These helpers relocate a user-dir LUT into
10
+ a namespaced subfolder of the master dir so SetLUT can resolve it.
11
+ """
12
+
13
+ import os
14
+ import platform
15
+ import shutil
16
+ from typing import List, Optional
17
+
18
+ from src.utils.platform import get_resolve_plugin_paths
19
+
20
+ # Subfolder under the master LUT root where relocated LUTs are staged. SetLUT
21
+ # resolves relative paths against the master root, so a subfolder path like
22
+ # "MCP/Foo.cube" works AND avoids clobbering stock/vendor LUTs that share a
23
+ # basename (e.g. InstantC.cube). Verified live on Resolve Studio 19.1.3.7.
24
+ MASTER_LUT_RELOCATE_SUBDIR = "MCP"
25
+
26
+
27
+ def master_lut_dir() -> str:
28
+ """Return Resolve's master (system) LUT directory for this platform."""
29
+ plat = platform.system().lower()
30
+ if plat == "windows":
31
+ programdata = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
32
+ return os.path.join(programdata, "Blackmagic Design",
33
+ "DaVinci Resolve", "Support", "LUT")
34
+ if plat == "linux":
35
+ for cand in ("/opt/resolve/LUT", "/home/resolve/LUT"):
36
+ if os.path.isdir(cand):
37
+ return cand
38
+ return "/opt/resolve/LUT"
39
+ # darwin / default
40
+ return "/Library/Application Support/Blackmagic Design/DaVinci Resolve/LUT"
41
+
42
+
43
+ def _user_lut_dir() -> Optional[str]:
44
+ """Return the per-user LUT dir (where dctl install writes), or None."""
45
+ try:
46
+ return get_resolve_plugin_paths()["dctl_dir"]
47
+ except Exception:
48
+ return None
49
+
50
+
51
+ def ensure_lut_in_master(lut_path: str) -> Optional[str]:
52
+ """Make a LUT resolvable by Graph.SetLUT().
53
+
54
+ Locates the file named by lut_path (absolute, user LUT dir, or already in
55
+ the master dir), copies it into a namespaced subfolder of the master LUT
56
+ dir when needed, and returns the master-relative path (forward slashes, as
57
+ GetLUT reports) to hand back to SetLUT. Returns None if the source cannot
58
+ be found or the master dir is not writable.
59
+ """
60
+ master = master_lut_dir()
61
+ base = os.path.basename(lut_path)
62
+ candidates: List[str] = []
63
+ if os.path.isabs(lut_path):
64
+ candidates.append(lut_path)
65
+ else:
66
+ user_dir = _user_lut_dir()
67
+ if user_dir:
68
+ candidates.append(os.path.join(user_dir, lut_path))
69
+ candidates.append(os.path.join(master, lut_path))
70
+ src = next((c for c in candidates if os.path.isfile(c)), None)
71
+ if not src:
72
+ return None
73
+ dst_dir = os.path.join(master, MASTER_LUT_RELOCATE_SUBDIR)
74
+ dst = os.path.join(dst_dir, base)
75
+ if os.path.abspath(src) != os.path.abspath(dst):
76
+ try:
77
+ os.makedirs(dst_dir, exist_ok=True)
78
+ shutil.copy2(src, dst)
79
+ except Exception:
80
+ return None
81
+ return f"{MASTER_LUT_RELOCATE_SUBDIR}/{base}"
@@ -0,0 +1,202 @@
1
+ """Waveform silence detection for ripple-delete planning.
2
+
3
+ Resolve exposes **Clip → Audio Operations → Ripple Delete Silence** in the UI
4
+ but not via scripting. This module approximates that workflow using ffmpeg
5
+ ``silencedetect`` plus the edit-engine keep-range assembler (variant timeline).
6
+
7
+ Settings map to the Resolve dialog:
8
+ - threshold_db → Threshold (dB)
9
+ - min_strip_frames → Minimum strip length (frames)
10
+ - pre_head_frames → Pre-head (frames kept before silence)
11
+ - post_tail_frames → Post-tail (frames kept after silence ends)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ from typing import List, Sequence, Tuple
19
+
20
+ from src.utils.media_analysis import _parse_silencedetect
21
+
22
+ DEFAULT_THRESHOLD_DB = -30.0
23
+ DEFAULT_MIN_STRIP_FRAMES = 10
24
+ DEFAULT_PRE_HEAD_FRAMES = 0
25
+ DEFAULT_POST_TAIL_FRAMES = 1
26
+
27
+
28
+ def ffmpeg_available() -> bool:
29
+ return shutil.which("ffmpeg") is not None
30
+
31
+
32
+ def frames_to_seconds(frames: float, fps: float) -> float:
33
+ if fps <= 0:
34
+ fps = 24.0
35
+ return float(frames) / fps
36
+
37
+
38
+ def audio_stream_count(media_path: str) -> int:
39
+ """Number of audio streams in the file (0 when ffprobe fails)."""
40
+ from src.utils.media_analysis import _ffprobe
41
+
42
+ probe = _ffprobe(media_path)
43
+ if not probe.get("success"):
44
+ return 0
45
+ streams = (probe.get("raw") or {}).get("streams") or []
46
+ return sum(1 for s in streams if s.get("codec_type") == "audio")
47
+
48
+
49
+ def build_silencedetect_args(
50
+ media_path: str,
51
+ start_sec: float,
52
+ duration: float,
53
+ *,
54
+ threshold_db: float,
55
+ min_duration_sec: float,
56
+ audio_streams: int,
57
+ ) -> List[str]:
58
+ """ffmpeg argv for silence detection over a source slice.
59
+
60
+ Multi-stream sources (e.g. production MXF with one mono stream per
61
+ channel) are merged first: silencedetect's default joint mode then only
62
+ triggers when EVERY channel is silent — matching Resolve's dialog, and
63
+ immune to a dead scratch channel reading as all-silence. -vn skips the
64
+ (potentially 4K) video decode entirely.
65
+ """
66
+ detect = f"silencedetect=noise={threshold_db}dB:d={min_duration_sec}"
67
+ args = [
68
+ "ffmpeg", "-hide_banner", "-nostats",
69
+ "-ss", str(start_sec),
70
+ "-i", media_path,
71
+ "-t", str(duration),
72
+ "-vn",
73
+ ]
74
+ if audio_streams > 1:
75
+ inputs = "".join(f"[0:a:{i}]" for i in range(audio_streams))
76
+ args += ["-filter_complex", f"{inputs}amerge=inputs={audio_streams},{detect}"]
77
+ else:
78
+ args += ["-af", detect]
79
+ args += ["-f", "null", "-"]
80
+ return args
81
+
82
+
83
+ def detect_silence_in_range(
84
+ media_path: str,
85
+ start_sec: float,
86
+ end_sec: float,
87
+ *,
88
+ threshold_db: float = DEFAULT_THRESHOLD_DB,
89
+ min_duration_sec: float,
90
+ ) -> List[Tuple[float, float]]:
91
+ """Return absolute-file silence intervals within [start_sec, end_sec).
92
+
93
+ Uses ffmpeg silencedetect on the trimmed slice (all audio streams merged).
94
+ Timestamps are converted back to absolute source-file seconds.
95
+ """
96
+ if not media_path or not os.path.isfile(media_path):
97
+ return []
98
+ if end_sec <= start_sec:
99
+ return []
100
+ duration = end_sec - start_sec
101
+ from src.utils.media_analysis import _run_command
102
+
103
+ streams = audio_stream_count(media_path)
104
+ if streams == 0:
105
+ return []
106
+ args = build_silencedetect_args(
107
+ media_path, start_sec, duration,
108
+ threshold_db=threshold_db, min_duration_sec=min_duration_sec,
109
+ audio_streams=streams,
110
+ )
111
+ code, _, stderr = _run_command(args)
112
+ if code != 0 and streams > 1:
113
+ # amerge can refuse mismatched sample rates — fall back to the
114
+ # default single-stream selection rather than dropping evidence.
115
+ args = build_silencedetect_args(
116
+ media_path, start_sec, duration,
117
+ threshold_db=threshold_db, min_duration_sec=min_duration_sec,
118
+ audio_streams=1,
119
+ )
120
+ code, _, stderr = _run_command(args)
121
+ if code != 0:
122
+ return []
123
+ parsed = _parse_silencedetect(stderr)
124
+ out: List[Tuple[float, float]] = []
125
+ for row in parsed:
126
+ rel_start = row.get("start")
127
+ rel_end = row.get("end")
128
+ if rel_start is None:
129
+ continue
130
+ abs_start = start_sec + float(rel_start)
131
+ abs_end = start_sec + float(rel_end) if rel_end is not None else end_sec
132
+ abs_end = min(abs_end, end_sec)
133
+ if abs_end - abs_start >= min_duration_sec * 0.9:
134
+ out.append((abs_start, abs_end))
135
+ return out
136
+
137
+
138
+ def apply_silence_handles(
139
+ silences: Sequence[Tuple[float, float]],
140
+ *,
141
+ pre_head_sec: float,
142
+ post_tail_sec: float,
143
+ range_start: float,
144
+ range_end: float,
145
+ ) -> List[Tuple[float, float]]:
146
+ """Expand/shrink silence regions per Resolve pre-head / post-tail handles."""
147
+ strip: List[Tuple[float, float]] = []
148
+ for s, e in silences:
149
+ s = max(range_start, s + pre_head_sec)
150
+ e = min(range_end, e - post_tail_sec)
151
+ if e > s + 0.001:
152
+ strip.append((s, e))
153
+ return strip
154
+
155
+
156
+ def silence_to_keep_segments(
157
+ range_start: float,
158
+ range_end: float,
159
+ strip_regions: Sequence[Tuple[float, float]],
160
+ *,
161
+ min_keep_sec: float = 0.05,
162
+ ) -> List[Tuple[float, float]]:
163
+ """Complement of strip regions → speech/keep segments in source time."""
164
+ segments: List[Tuple[float, float]] = []
165
+ cursor = range_start
166
+ for s, e in sorted(strip_regions):
167
+ s, e = max(s, range_start), min(e, range_end)
168
+ if s - cursor >= min_keep_sec:
169
+ segments.append((cursor, s))
170
+ cursor = max(cursor, e)
171
+ if range_end - cursor >= min_keep_sec:
172
+ segments.append((cursor, range_end))
173
+ return segments
174
+
175
+
176
+ def plan_item_silence_strips(
177
+ media_path: str,
178
+ src_start_sec: float,
179
+ src_end_sec: float,
180
+ *,
181
+ threshold_db: float,
182
+ min_strip_sec: float,
183
+ pre_head_sec: float,
184
+ post_tail_sec: float,
185
+ ) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]:
186
+ """Detect silences and return (strip_regions, keep_segments) in source seconds."""
187
+ raw = detect_silence_in_range(
188
+ media_path,
189
+ src_start_sec,
190
+ src_end_sec,
191
+ threshold_db=threshold_db,
192
+ min_duration_sec=min_strip_sec,
193
+ )
194
+ strip = apply_silence_handles(
195
+ raw,
196
+ pre_head_sec=pre_head_sec,
197
+ post_tail_sec=post_tail_sec,
198
+ range_start=src_start_sec,
199
+ range_end=src_end_sec,
200
+ )
201
+ keep = silence_to_keep_segments(src_start_sec, src_end_sec, strip)
202
+ return strip, keep