davinci-resolve-mcp 2.62.3 → 2.63.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/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
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.1
6
+
7
+ Documentation-only. Records the `Graph.SetLUT` master-LUT-dir behavior (from the
8
+ v2.62.3 fix) in the canonical reference surfaces. No code or tool-surface change.
9
+
10
+ ### Documentation
11
+
12
+ - **`api_truth` + api-limitations report** — added a `Graph.SetLUT` entry
13
+ (`submit: bug`, #90): `SetLUT` resolves LUT paths only against the master
14
+ (system) LUT dir and configured custom paths, never the per-user dir the
15
+ `dctl` tool installs to; an absolute user-dir path fails too, and
16
+ `RefreshLUTList()` does not help, while a master-subfolder path resolves.
17
+ Verified live on Studio 19.1.3.7 (same behavior reported on 21.0.2 in #90).
18
+ Regenerated `docs/reference/api-limitations.md`.
19
+ - **`docs/notes/lut-notes.md`** — corrected a misleading claim that any valid
20
+ absolute path resolves (an absolute path into the user LUT dir does not),
21
+ added a "master LUT dir only" section, and documented the per-user install
22
+ location.
23
+ - **`docs/notes/dctl-notes.md`** — noted the same caveat on the `set_lut`
24
+ action, including that `set_lut` now auto-relocates into the master dir.
25
+
26
+ ## What's New in v2.63.0
27
+
28
+ Waveform silence ripple for the edit engine (PR #91, by @EliteSystemsAI),
29
+ adopted with follow-up hardening and live-validated in Resolve Studio.
30
+
31
+ ### Added
32
+
33
+ - **`plan_silence_ripple` / `execute_silence_ripple`** — waveform-driven dead-air
34
+ removal that mirrors Resolve's *Ripple Delete Silence* dialog (ffmpeg
35
+ `silencedetect` → keep-range variant assembly). Tunable `threshold_db`,
36
+ `min_strip_frames`, `pre_head_frames`, and `post_tail_frames`. Original
37
+ timeline is never mutated; execution is confirm-gated like `execute_tighten`.
38
+ New module: `src/utils/silence_ripple.py`; tests in
39
+ `tests/test_silence_ripple.py` and `tests/test_edit_engine.py`.
40
+
41
+ ### Fixed (hardening on the PR before release)
42
+
43
+ - **Skipped items ride along whole** — timeline items without a readable media
44
+ file path now emit a full-range keep (video + mirrored audio) so the
45
+ assembled variant never silently loses content; only items with no media
46
+ reference at all are omitted, and the `skipped` reason says so explicitly.
47
+ - **All audio streams are merged before detection** — production MXF often
48
+ carries one mono PCM stream per channel, and ffmpeg's default stream
49
+ selection could land on a dead scratch channel, reading entire takes as
50
+ silence. Detection now merges every audio stream (silence only when ALL
51
+ channels are silent — Resolve's dialog semantics), with a single-stream
52
+ fallback if `amerge` refuses the graph. Found by live validation on 5-stream
53
+ interview masters.
54
+ - **No pointless video decode** — silence detection runs with `-vn`, so 4K
55
+ ProRes sources no longer decode picture just to scan audio.
56
+ - **Clear failure modes** — a missing ffmpeg binary now reports itself instead
57
+ of "no silence regions found", and a plan whose keep ranges are empty
58
+ (source quieter than `threshold_db` throughout) carries an explicit warning
59
+ and is refused at execution.
60
+
5
61
  ## What's New in v2.62.3
6
62
 
7
63
  A single grading fix (PR #90, by @Mldphotohraphie), extended and hardened. No
@@ -14,9 +70,10 @@ new tool surface.
14
70
  `Graph.SetLUT()` resolves LUT paths (relative names *and* absolute paths)
15
71
  only against the master (system) LUT directory. A freshly installed LUT could
16
72
  therefore never be applied, and `set_lut` always returned
17
- `{"success": false}`. Verified live on Resolve Studio 21: `SetLUT` fails for a
18
- user-dir LUT even after `RefreshLUTList()` and even via an absolute user-dir
19
- path, so relocation into the master dir is genuinely required. On a `SetLUT`
73
+ `{"success": false}`. Verified live on Resolve Studio 19.1.3.7: `SetLUT` fails
74
+ for a user-dir LUT even after `RefreshLUTList()` and even via an absolute
75
+ user-dir path, so relocation into the master dir is genuinely required. (The
76
+ originating report, PR #90, observed the same on Studio 21.0.2.) On a `SetLUT`
20
77
  failure the server now locates the LUT, stages it under a namespaced
21
78
  `MCP/` subfolder of the master LUT dir (avoiding basename collisions with
22
79
  stock/vendor LUTs), calls `RefreshLUTList()`, and retries. No behavior change
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.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.63.1-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.
@@ -19,7 +19,12 @@ Relevant existing actions:
19
19
  Resolve's LUT folders.
20
20
  - `graph(action="set_lut", params={"node_index": 1, "lut_path": "..."})`
21
21
  wraps `Graph.SetLUT(nodeIndex, lutPath)`. DCTLs that Resolve exposes through
22
- LUT selection follow the same discovery constraints as LUTs.
22
+ LUT selection follow the same discovery constraints as LUTs. **Caveat:**
23
+ `install` writes to the per-user LUT dir, but `SetLUT` resolves only against
24
+ the *master* LUT dir (not even an absolute user-dir path works, and
25
+ `refresh_luts` does not help). `set_lut` now auto-relocates the file into an
26
+ `MCP/` subfolder of the master dir and retries — see `docs/notes/lut-notes.md`
27
+ ("master LUT dir only") and the `Graph.SetLUT` entry in `api_truth.py`.
23
28
  - `graph(action="get_lut", params={"node_index": 1})` wraps `Graph.GetLUT()`.
24
29
  - `graph(action="get_tools_in_node", ...)` may report ResolveFX tools present
25
30
  in a Color page node, but the scripting API does not expose a general
@@ -21,8 +21,32 @@ The scripting API already exposes the important LUT operations:
21
21
  can include LUTs in a `.drp` export.
22
22
 
23
23
  `SetLUT()` uses 1-based node indexes and succeeds only for LUT paths Resolve has
24
- already discovered. The `lut_path` may be absolute or relative to Resolve's
25
- master/custom LUT paths.
24
+ already discovered.
25
+
26
+ ### `SetLUT` resolves against the master LUT dir only
27
+
28
+ Resolve resolves the `lut_path` given to `SetLUT()` **only against the master
29
+ (system) LUT directory and its configured custom LUT paths — not the per-user
30
+ LUT directory** that the `dctl` tool and Project-level LUT installs write to.
31
+ This trips up the obvious workflow (install a LUT/DCTL, then apply it):
32
+
33
+ - A bare basename that lives only in the user LUT dir returns `False`.
34
+ - An **absolute path into the user LUT dir also returns `False`** — absolute
35
+ paths are not a workaround.
36
+ - `project_settings(action="refresh_luts")` (`RefreshLUTList()`) does **not**
37
+ change this; the file is discovered but still not resolvable by `SetLUT`.
38
+ - A **subfolder-relative** path under the master root (e.g. `MCP/Foo.cube`)
39
+ *does* resolve.
40
+
41
+ Verified live on Studio 19.1.3.7; the same behavior was reported on 21.0.2
42
+ (PR #90), so it is not version-specific. See the `Graph.SetLUT` entry in
43
+ `src/utils/api_truth.py`.
44
+
45
+ `graph.set_lut` (and the granular `graph_set_lut`) handle this automatically:
46
+ on a `False` return they stage the LUT into an `MCP/` subfolder of the master
47
+ LUT dir, refresh, and retry, returning `resolved_lut` on success. If you call
48
+ `Graph.SetLUT` directly, relocate the file into the master dir yourself first
49
+ (`src.utils.lut_paths.ensure_lut_in_master`).
26
50
 
27
51
  ## Cube LUT Format
28
52
 
@@ -100,16 +124,25 @@ Blackmagic's examples live in the installed LUT folder:
100
124
  Example built-in LUT categories include `VFX IO`, `Blackmagic Design`, and
101
125
  `ACES`.
102
126
 
127
+ The `dctl` tool and Project-level LUT installs instead write to the **per-user**
128
+ LUT dir (macOS `~/Library/Application Support/Blackmagic Design/DaVinci Resolve/LUT`,
129
+ Windows `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\LUT`). Files there
130
+ are discovered by the LUT browser but are **not** resolvable by `SetLUT` until
131
+ relocated into the master dir above — see "master LUT dir only".
132
+
103
133
  ## Practical Failure Checks
104
134
 
105
135
  If `graph.set_lut` fails or `graph.get_lut` returns empty:
106
136
 
107
137
  - Confirm the current page/context has a valid Color graph and the node index is
108
138
  1-based.
109
- - Confirm the LUT file is in a Resolve LUT folder or is referenced by a valid
110
- absolute path.
139
+ - Confirm the LUT is in the **master** LUT folder (or a configured custom LUT
140
+ path) — not just the per-user LUT dir. A file only in the user dir fails even
141
+ by absolute path (see "master LUT dir only" above). `graph.set_lut` relocates
142
+ it for you; a direct `Graph.SetLUT` call does not.
111
143
  - Call `project_settings(action="refresh_luts")` after adding or changing LUT
112
- files.
144
+ files (note: refresh alone does not make a user-dir LUT resolvable by
145
+ `SetLUT`).
113
146
  - Check the `.cube` header and row count. 3D LUTs must contain exactly
114
147
  `N * N * N` rows.
115
148
  - Confirm the file extension is `.cube` and the content is plain text.
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.0
14
14
 
15
- **Totals:** 19 missing capabilities, 11 bugs / unreliable behaviors.
15
+ **Totals:** 19 missing capabilities, 12 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -265,3 +265,12 @@ values, or automation-hostile modal prompts.
265
265
  - **Workaround / current handling:** After writing 'Reel Name', read it back with GetClipProperty('Reel Name') and refuse to report success on mismatch; surface the project-setting gate to the caller (server._verify_clip_property_writeback).
266
266
  - **Reference:** [issue #77](https://github.com/samuelgursky/davinci-resolve-mcp/issues/77)
267
267
  - **Tags:** unreliable-return, silent-failure, metadata, reel-name
268
+
269
+ ### Graph.SetLUT (master-LUT-dir-only resolution)
270
+
271
+ - **Object:** `Graph`
272
+ - **Signature:** `(nodeIndex, lutPath) -> bool`
273
+ - **Behavior:** SetLUT resolves lutPath ONLY against the master (system) LUT directory and its configured custom LUT paths -- NOT the per-user LUT dir that the dctl tool / Project LUT install writes to. A bare basename in the user dir returns False, and so does an ABSOLUTE path pointing into the user dir; RefreshLUTList() does not change this. A subfolder-relative path under the master root (e.g. 'MCP/Foo.cube') DOES resolve. Net effect: a LUT/DCTL the dctl tool just installed can never be applied by SetLUT as-is, so set_lut used to always return {success: false}. Verified live on Studio 19.1.3.7 (basename and absolute user-dir path both False, before and after RefreshLUTList; master-dir and master-subfolder paths True); the originating report (PR #90) observed the same on 21.0.2, so it is not version-specific.
274
+ - **Workaround / current handling:** On a False return, locate the LUT, copy it into a namespaced subfolder of the master LUT dir (MCP/, so it does not clobber stock LUTs by basename), call RefreshLUTList(), and retry with the master-relative path. graph.set_lut and the granular graph_set_lut now do this automatically via src.utils.lut_paths.ensure_lut_in_master.
275
+ - **Reference:** [issue #90](https://github.com/samuelgursky/davinci-resolve-mcp/issues/90)
276
+ - **Tags:** color, lut, path-resolution, silent-failure
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.3"
38
+ VERSION = "2.63.1"
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.3",
3
+ "version": "2.63.1",
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.3"
83
+ VERSION = "2.63.1"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.62.3"
14
+ VERSION = "2.63.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1045,6 +1045,7 @@ _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
1045
1045
  # Phase E edit-engine loops: plan → confirm → execute.
1046
1046
  ("edit_engine", "execute_selects"),
1047
1047
  ("edit_engine", "execute_tighten"),
1048
+ ("edit_engine", "execute_silence_ripple"),
1048
1049
  ("edit_engine", "execute_swap"),
1049
1050
  ("graph", "apply_grade_from_drx"),
1050
1051
  ("graph", "reset_all_grades"),
@@ -18420,6 +18421,13 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18420
18421
  block and a compact structural_diff (counts + a small sample); the full
18421
18422
  per-item diff is persisted in the plan record (get_plan → execution_summary)
18422
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.
18423
18431
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
18424
18432
  — alternates for one timeline item via the similarity index.
18425
18433
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -18494,6 +18502,26 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18494
18502
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
18495
18503
  )
18496
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
+
18497
18525
  if action == "plan_swap":
18498
18526
  _r, proj, project_root, err = _project_context(need_resolve=True)
18499
18527
  if err:
@@ -18769,6 +18797,141 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18769
18797
  "plan_id": plan.get("plan_id"),
18770
18798
  }
18771
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
+
18772
18935
  if action == "execute_swap":
18773
18936
  _r, proj, project_root, err = _project_context(need_resolve=True)
18774
18937
  if err:
@@ -18930,6 +19093,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18930
19093
  "execute_selects",
18931
19094
  "plan_tighten",
18932
19095
  "execute_tighten",
19096
+ "plan_silence_ripple",
19097
+ "execute_silence_ripple",
18933
19098
  "plan_swap",
18934
19099
  "execute_swap",
18935
19100
  "list_plans",
@@ -564,6 +564,35 @@ API_TRUTH: List[Dict[str, Any]] = [
564
564
  "when mirroring keep-ranges into clipInfos.",
565
565
  "tags": ["timeline", "edit", "off-by-one", "readback"],
566
566
  },
567
+ {
568
+ "symbol": "Graph.SetLUT (master-LUT-dir-only resolution)",
569
+ "object": "Graph",
570
+ "signature": "(nodeIndex, lutPath) -> bool",
571
+ "reality": "SetLUT resolves lutPath ONLY against the master (system) LUT "
572
+ "directory and its configured custom LUT paths -- NOT the "
573
+ "per-user LUT dir that the dctl tool / Project LUT install "
574
+ "writes to. A bare basename in the user dir returns False, and "
575
+ "so does an ABSOLUTE path pointing into the user dir; "
576
+ "RefreshLUTList() does not change this. A subfolder-relative "
577
+ "path under the master root (e.g. 'MCP/Foo.cube') DOES resolve. "
578
+ "Net effect: a LUT/DCTL the dctl tool just installed can never "
579
+ "be applied by SetLUT as-is, so set_lut used to always return "
580
+ "{success: false}. Verified live on Studio 19.1.3.7 (basename "
581
+ "and absolute user-dir path both False, before and after "
582
+ "RefreshLUTList; master-dir and master-subfolder paths True); "
583
+ "the originating report (PR #90) observed the same on 21.0.2, "
584
+ "so it is not version-specific.",
585
+ "recommended": "On a False return, locate the LUT, copy it into a "
586
+ "namespaced subfolder of the master LUT dir (MCP/, so it "
587
+ "does not clobber stock LUTs by basename), call "
588
+ "RefreshLUTList(), and retry with the master-relative path. "
589
+ "graph.set_lut and the granular graph_set_lut now do this "
590
+ "automatically via src.utils.lut_paths.ensure_lut_in_master.",
591
+ "tags": ["color", "lut", "path-resolution", "silent-failure"],
592
+ "submit": "bug",
593
+ "issue": 90,
594
+ "mitigation": ["_ensure_lut_in_master"],
595
+ },
567
596
  ]
568
597
 
569
598
 
@@ -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
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  Resolve's Graph.SetLUT() resolves relative LUT names -- and even absolute
4
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 21: SetLUT
6
- succeeds for a LUT in the master dir (by relative or subfolder path) and
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
7
  returns False for the same file in the user dir, even after RefreshLUTList and
8
- even via an absolute user-dir path. These helpers relocate a user-dir LUT into
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
9
10
  a namespaced subfolder of the master dir so SetLUT can resolve it.
10
11
  """
11
12
 
@@ -19,7 +20,7 @@ from src.utils.platform import get_resolve_plugin_paths
19
20
  # Subfolder under the master LUT root where relocated LUTs are staged. SetLUT
20
21
  # resolves relative paths against the master root, so a subfolder path like
21
22
  # "MCP/Foo.cube" works AND avoids clobbering stock/vendor LUTs that share a
22
- # basename (e.g. InstantC.cube). Verified live on Resolve Studio 21.
23
+ # basename (e.g. InstantC.cube). Verified live on Resolve Studio 19.1.3.7.
23
24
  MASTER_LUT_RELOCATE_SUBDIR = "MCP"
24
25
 
25
26
 
@@ -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