davinci-resolve-mcp 2.62.3 → 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 +39 -3
- package/README.md +1 -1
- package/docs/SKILL.md +9 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +166 -1
- package/src/utils/destructive_hook.py +1 -0
- package/src/utils/edit_engine.py +254 -0
- package/src/utils/lut_paths.py +5 -4
- package/src/utils/silence_ripple.py +202 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
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
|
+
|
|
5
40
|
## What's New in v2.62.3
|
|
6
41
|
|
|
7
42
|
A single grading fix (PR #90, by @Mldphotohraphie), extended and hardened. No
|
|
@@ -14,9 +49,10 @@ new tool surface.
|
|
|
14
49
|
`Graph.SetLUT()` resolves LUT paths (relative names *and* absolute paths)
|
|
15
50
|
only against the master (system) LUT directory. A freshly installed LUT could
|
|
16
51
|
therefore never be applied, and `set_lut` always returned
|
|
17
|
-
`{"success": false}`. Verified live on Resolve Studio
|
|
18
|
-
user-dir LUT even after `RefreshLUTList()` and even via an absolute
|
|
19
|
-
path, so relocation into the master dir is genuinely required.
|
|
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`
|
|
20
56
|
failure the server now locates the LUT, stages it under a namespaced
|
|
21
57
|
`MCP/` subfolder of the master LUT dir (avoiding basename collisions with
|
|
22
58
|
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "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()}")
|
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.
|
|
14
|
+
VERSION = "2.63.0"
|
|
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",
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -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
|
|
package/src/utils/lut_paths.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|