davinci-resolve-mcp 2.33.7 → 2.34.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 +28 -0
- package/README.md +1 -1
- package/docs/SKILL.md +2 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/measure_bridge_cost.py +65 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +13 -2
- package/src/utils/bridge_metrics.py +76 -0
- package/src/utils/cut_ir.py +115 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
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.34.0
|
|
6
|
+
|
|
7
|
+
First phase of transcript-driven editing: a Cut intermediate representation and a
|
|
8
|
+
mechanical cut proposer.
|
|
9
|
+
|
|
10
|
+
- **Added** `src/utils/cut_ir.py` — the Cut-IR: a typed representation of an
|
|
11
|
+
editorial cut ({kind, span, action, confidence, rationale, evidence}) plus a
|
|
12
|
+
deterministic Pass-1 detector that flags filler words, long pauses, and
|
|
13
|
+
repeated lines from a timestamped transcript. No LLM.
|
|
14
|
+
- **Added** `timeline(action="propose_cuts", cues?, long_pause_frames?)` — a
|
|
15
|
+
DRY-RUN that runs Pass-1 over the timeline's subtitle transcript (or provided
|
|
16
|
+
cues) and returns a CutList. It proposes only; it applies nothing. The semantic
|
|
17
|
+
Pass-2 and the governed timeline executor are subsequent phases.
|
|
18
|
+
|
|
19
|
+
## What's New in v2.33.8
|
|
20
|
+
|
|
21
|
+
Bridge-call performance instrumentation.
|
|
22
|
+
|
|
23
|
+
- **Added** `src/utils/bridge_metrics.py` — a counting proxy that wraps a Resolve
|
|
24
|
+
handle and tallies attribute accesses and method calls (each a COM/socket
|
|
25
|
+
round-trip), so the real bridge cost of an operation is measured rather than
|
|
26
|
+
guessed.
|
|
27
|
+
- **Added** `scripts/measure_bridge_cost.py` — runs a representative media-pool
|
|
28
|
+
traversal through the proxy and reports round-trips per clip. A minimal
|
|
29
|
+
name+type walk measured ~6.7 round-trips per clip, confirming round-trips scale
|
|
30
|
+
linearly with traversal size. A property cache remains gated on profiling
|
|
31
|
+
*repeated*-read patterns in real workflows (don't cache blind).
|
|
32
|
+
|
|
5
33
|
## What's New in v2.33.7
|
|
6
34
|
|
|
7
35
|
Read/write symmetry audit and a gap it surfaced.
|
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
|
@@ -729,6 +729,8 @@ Key actions:
|
|
|
729
729
|
- `get_track_count(track_type)` — track_type: `"video"`, `"audio"`, `"subtitle"`
|
|
730
730
|
- `get_transcript(with_timecodes?)` — read the subtitle track(s) as transcript
|
|
731
731
|
text `{text, cue_count, has_subtitles, cues}`
|
|
732
|
+
- `propose_cuts(cues?, long_pause_frames?)` — DRY-RUN: mechanically detect
|
|
733
|
+
candidate cuts (fillers, long pauses, repeats) from the transcript; proposes only
|
|
732
734
|
- `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
|
|
733
735
|
- `get_items(track_type, index)` — items on a track
|
|
734
736
|
- `clip_where(track_type?, track_index?, name_contains?, duration_lt?, duration_gt?)` —
|
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.34.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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure DaVinci Resolve bridge round-trips for representative operations.
|
|
3
|
+
|
|
4
|
+
Wraps the live project in a counting proxy and runs a few common traversals,
|
|
5
|
+
reporting how many attribute accesses + method calls (each a bridge round-trip)
|
|
6
|
+
they cost. This is the measurement that gates whether a property cache is worth
|
|
7
|
+
building. Run against an OPEN project; it only reads.
|
|
8
|
+
|
|
9
|
+
PYTHONPATH=. venv/bin/python scripts/measure_bridge_cost.py
|
|
10
|
+
"""
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import src.server as s
|
|
14
|
+
from src.utils.bridge_metrics import measure
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _walk_media_pool(project_proxy):
|
|
18
|
+
"""A typical media-pool traversal: every folder, every clip, name + a property."""
|
|
19
|
+
mp = project_proxy.GetMediaPool()
|
|
20
|
+
root = mp.GetRootFolder()
|
|
21
|
+
clips_seen = 0
|
|
22
|
+
|
|
23
|
+
def walk(folder):
|
|
24
|
+
nonlocal clips_seen
|
|
25
|
+
for clip in (folder.GetClipList() or []):
|
|
26
|
+
clip.GetName()
|
|
27
|
+
clip.GetClipProperty("Type")
|
|
28
|
+
clips_seen += 1
|
|
29
|
+
for sub in (folder.GetSubFolderList() or []):
|
|
30
|
+
walk(sub)
|
|
31
|
+
|
|
32
|
+
walk(root)
|
|
33
|
+
return clips_seen
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
r = s.get_resolve()
|
|
38
|
+
if not r:
|
|
39
|
+
print("Not connected to Resolve.")
|
|
40
|
+
return 1
|
|
41
|
+
proj = r.GetProjectManager().GetCurrentProject()
|
|
42
|
+
if not proj:
|
|
43
|
+
print("No project open.")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
clips_holder = {}
|
|
47
|
+
|
|
48
|
+
def op(proj_proxy):
|
|
49
|
+
clips_holder["n"] = _walk_media_pool(proj_proxy)
|
|
50
|
+
|
|
51
|
+
counts = measure(op, proj)
|
|
52
|
+
n = clips_holder.get("n", 0)
|
|
53
|
+
total = counts["attr_access"] + counts["calls"]
|
|
54
|
+
print(f"project: {proj.GetName()!r}")
|
|
55
|
+
print(f"clips walked: {n}")
|
|
56
|
+
print(f"bridge attr-accesses: {counts['attr_access']}")
|
|
57
|
+
print(f"bridge method-calls: {counts['calls']}")
|
|
58
|
+
print(f"total round-trips: {total}")
|
|
59
|
+
if n:
|
|
60
|
+
print(f"round-trips per clip: {total / n:.1f}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
sys.exit(main())
|
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.34.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.34.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -45,6 +45,7 @@ from src.utils.cdl import normalize_cdl_payload
|
|
|
45
45
|
from src.utils.mcp_stdio import run_fastmcp_stdio
|
|
46
46
|
from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
|
|
47
47
|
from src.utils.contracts import validate as _validate_params
|
|
48
|
+
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
48
49
|
from src.utils.proc import safe_run
|
|
49
50
|
from src.utils.readback import verify_by_readback
|
|
50
51
|
from src.utils.update_check import (
|
|
@@ -15202,6 +15203,9 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15202
15203
|
get_media_pool_item() -> {name, id}
|
|
15203
15204
|
get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues}
|
|
15204
15205
|
Read the timeline's subtitle track(s) as transcript text.
|
|
15206
|
+
propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note}
|
|
15207
|
+
DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses,
|
|
15208
|
+
repeated lines) from the subtitle transcript. Proposes only; applies nothing.
|
|
15205
15209
|
get_mark_in_out() -> {mark}
|
|
15206
15210
|
set_mark_in_out(mark_in, mark_out, type?) -> {success}
|
|
15207
15211
|
clear_mark_in_out(type?) -> {success}
|
|
@@ -15489,6 +15493,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15489
15493
|
return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None}
|
|
15490
15494
|
elif action == "get_transcript":
|
|
15491
15495
|
return _timeline_transcript(tl, with_timecodes=bool(p.get("with_timecodes")))
|
|
15496
|
+
elif action == "propose_cuts":
|
|
15497
|
+
# Dry-run only: detect mechanical cuts (fillers, long pauses, repeats)
|
|
15498
|
+
# from the timeline's subtitle transcript. Proposes; never edits.
|
|
15499
|
+
cues = p.get("cues")
|
|
15500
|
+
if cues is None:
|
|
15501
|
+
cues = _timeline_transcript(tl, with_timecodes=True)["cues"]
|
|
15502
|
+
return _build_cut_list(cues, long_pause_frames=int(p.get("long_pause_frames", 48)))
|
|
15492
15503
|
elif action == "get_mark_in_out":
|
|
15493
15504
|
return _ser(tl.GetMarkInOut())
|
|
15494
15505
|
elif action == "set_mark_in_out":
|
|
@@ -15698,7 +15709,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15698
15709
|
if mp_err:
|
|
15699
15710
|
return mp_err
|
|
15700
15711
|
return _fairlight_boundary_report(proj, mp, tl, p)
|
|
15701
|
-
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
15712
|
+
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
15702
15713
|
|
|
15703
15714
|
|
|
15704
15715
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Instrumentation for counting DaVinci Resolve bridge round-trips.
|
|
2
|
+
|
|
3
|
+
Every attribute access and method call on a Resolve object is a COM/socket
|
|
4
|
+
round-trip — the dominant cost of most operations. Before optimizing (or adding
|
|
5
|
+
a cache), you have to *measure* where the round-trips concentrate. This module
|
|
6
|
+
provides an opt-in counting proxy that wraps a Resolve handle and tallies
|
|
7
|
+
attribute accesses and method calls, so a hot path's real bridge cost is
|
|
8
|
+
visible instead of guessed.
|
|
9
|
+
|
|
10
|
+
This is the measurement half of the bridge-perf work. A property cache is only
|
|
11
|
+
worth building once measurement shows a clear, repeated-read hot spot — don't
|
|
12
|
+
cache blind.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
|
|
16
|
+
counts = {}
|
|
17
|
+
rp = CountingProxy(resolve, counts)
|
|
18
|
+
rp.GetProjectManager().GetCurrentProject().GetName()
|
|
19
|
+
print(counts) # {'attr_access': N, 'calls': M}
|
|
20
|
+
"""
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CountingProxy:
|
|
25
|
+
"""Wrap a Resolve object, counting attribute accesses and method calls.
|
|
26
|
+
|
|
27
|
+
Method return values that are themselves objects are wrapped too, so a whole
|
|
28
|
+
call chain through the bridge is counted. Primitives are returned as-is.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_target", "_counter")
|
|
32
|
+
|
|
33
|
+
def __init__(self, target: Any, counter: Dict[str, int]):
|
|
34
|
+
object.__setattr__(self, "_target", target)
|
|
35
|
+
object.__setattr__(self, "_counter", counter)
|
|
36
|
+
counter.setdefault("attr_access", 0)
|
|
37
|
+
counter.setdefault("calls", 0)
|
|
38
|
+
|
|
39
|
+
def __getattr__(self, name: str) -> Any:
|
|
40
|
+
counter = object.__getattribute__(self, "_counter")
|
|
41
|
+
target = object.__getattribute__(self, "_target")
|
|
42
|
+
counter["attr_access"] += 1
|
|
43
|
+
attr = getattr(target, name)
|
|
44
|
+
if callable(attr):
|
|
45
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
46
|
+
counter["calls"] += 1
|
|
47
|
+
result = attr(*args, **kwargs)
|
|
48
|
+
return _maybe_wrap(result, counter)
|
|
49
|
+
return wrapped
|
|
50
|
+
return _maybe_wrap(attr, counter)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_PRIMITIVE = (str, int, float, bool, bytes, type(None))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _maybe_wrap(value: Any, counter: Dict[str, int]) -> Any:
|
|
57
|
+
if isinstance(value, _PRIMITIVE):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, (list, tuple)):
|
|
60
|
+
# Wrap object elements so traversals (e.g. clip lists) are counted.
|
|
61
|
+
return type(value)(_maybe_wrap(v, counter) for v in value)
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
return {k: _maybe_wrap(v, counter) for k, v in value.items()}
|
|
64
|
+
# Likely a Resolve API object — wrap so its further use is counted.
|
|
65
|
+
return CountingProxy(value, counter)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def measure(fn, target: Any) -> Dict[str, int]:
|
|
69
|
+
"""Run ``fn(proxy)`` against a counting proxy of ``target``; return the counts.
|
|
70
|
+
|
|
71
|
+
``fn`` receives the proxy and should exercise the operation under study.
|
|
72
|
+
"""
|
|
73
|
+
counter: Dict[str, int] = {}
|
|
74
|
+
proxy = CountingProxy(target, counter)
|
|
75
|
+
fn(proxy)
|
|
76
|
+
return counter
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Cut intermediate representation (Cut-IR) and the mechanical (Pass-1) detector.
|
|
2
|
+
|
|
3
|
+
The Cut-IR is the typed contract between a timestamped transcript and concrete,
|
|
4
|
+
governed timeline operations. Producers emit Cuts; an executor (a later phase)
|
|
5
|
+
consumes them as governed, versioned edits; a review UI shows them. This module
|
|
6
|
+
provides the schema plus the deterministic Pass-1 detector — filler words, long
|
|
7
|
+
pauses, and repeated lines — with no LLM. The semantic Pass-2 and the timeline
|
|
8
|
+
executor are separate phases (see local/design/research/r1-cut-ir.md).
|
|
9
|
+
|
|
10
|
+
A Cut:
|
|
11
|
+
{
|
|
12
|
+
"kind": "filler" | "long_pause" | "stammer" | "false_start" | "semantic",
|
|
13
|
+
"span": {"start": <frame>, "end": <frame>},
|
|
14
|
+
"action": "lift" | "ripple_delete" | "keep" | "reorder" | "swap",
|
|
15
|
+
"confidence": 0.0..1.0,
|
|
16
|
+
"rationale": str,
|
|
17
|
+
"evidence": {...},
|
|
18
|
+
}
|
|
19
|
+
"""
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
# Common English fillers (single tokens and short phrases).
|
|
23
|
+
FILLER_WORDS = {
|
|
24
|
+
"um", "uh", "er", "ah", "eh", "hmm", "mm", "uhh", "umm",
|
|
25
|
+
"like", "so", "well", "right", "okay", "ok",
|
|
26
|
+
}
|
|
27
|
+
FILLER_PHRASES = {"you know", "i mean", "sort of", "kind of", "you see"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_cut(
|
|
31
|
+
kind: str,
|
|
32
|
+
start: Optional[int],
|
|
33
|
+
end: Optional[int],
|
|
34
|
+
action: str,
|
|
35
|
+
confidence: float,
|
|
36
|
+
rationale: str,
|
|
37
|
+
evidence: Optional[Dict[str, Any]] = None,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"kind": kind,
|
|
41
|
+
"span": {"start": start, "end": end},
|
|
42
|
+
"action": action,
|
|
43
|
+
"confidence": round(float(confidence), 2),
|
|
44
|
+
"rationale": rationale,
|
|
45
|
+
"evidence": evidence or {},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _norm(text: str) -> str:
|
|
50
|
+
return (text or "").strip().lower().strip(".,!?;:")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_filler_only(text: str) -> bool:
|
|
54
|
+
low = _norm(text)
|
|
55
|
+
if not low:
|
|
56
|
+
return False
|
|
57
|
+
if low in FILLER_PHRASES:
|
|
58
|
+
return True
|
|
59
|
+
tokens = [t.strip(".,!?;:") for t in low.split()]
|
|
60
|
+
tokens = [t for t in tokens if t]
|
|
61
|
+
return bool(tokens) and all(t in FILLER_WORDS for t in tokens)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def detect_cuts_pass1(
|
|
65
|
+
cues: List[Dict[str, Any]],
|
|
66
|
+
*,
|
|
67
|
+
long_pause_frames: int = 48,
|
|
68
|
+
) -> List[Dict[str, Any]]:
|
|
69
|
+
"""Mechanical Pass-1 detection over timestamped cues.
|
|
70
|
+
|
|
71
|
+
cues: list of {"text", "start", "end"} in frames. Returns a list of Cuts.
|
|
72
|
+
"""
|
|
73
|
+
cuts: List[Dict[str, Any]] = []
|
|
74
|
+
for i, cue in enumerate(cues):
|
|
75
|
+
text = (cue.get("text") or "").strip()
|
|
76
|
+
start, end = cue.get("start"), cue.get("end")
|
|
77
|
+
|
|
78
|
+
if _is_filler_only(text):
|
|
79
|
+
cuts.append(make_cut(
|
|
80
|
+
"filler", start, end, "lift", 0.8,
|
|
81
|
+
f"Filler-only cue: {text!r}", {"text": text},
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
if i > 0:
|
|
85
|
+
prev_text = (cues[i - 1].get("text") or "").strip()
|
|
86
|
+
if _norm(prev_text) and _norm(text) == _norm(prev_text):
|
|
87
|
+
cuts.append(make_cut(
|
|
88
|
+
"stammer", start, end, "lift", 0.6,
|
|
89
|
+
f"Repeated line: {text!r}", {"text": text},
|
|
90
|
+
))
|
|
91
|
+
prev_end = cues[i - 1].get("end")
|
|
92
|
+
if (prev_end is not None and start is not None
|
|
93
|
+
and start - prev_end > long_pause_frames):
|
|
94
|
+
cuts.append(make_cut(
|
|
95
|
+
"long_pause", prev_end, start, "lift", 0.5,
|
|
96
|
+
f"Pause of {start - prev_end} frames before {text!r}",
|
|
97
|
+
{"frames": start - prev_end},
|
|
98
|
+
))
|
|
99
|
+
return cuts
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_cut_list(
|
|
103
|
+
cues: List[Dict[str, Any]],
|
|
104
|
+
*,
|
|
105
|
+
long_pause_frames: int = 48,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Run Pass-1 and wrap the result as a (dry-run) CutList."""
|
|
108
|
+
cuts = detect_cuts_pass1(cues, long_pause_frames=long_pause_frames)
|
|
109
|
+
return {
|
|
110
|
+
"cuts": cuts,
|
|
111
|
+
"cut_count": len(cuts),
|
|
112
|
+
"basis_cue_count": len(cues),
|
|
113
|
+
"pass": "mechanical",
|
|
114
|
+
"note": "Dry-run proposal. Review before applying; no edits were made.",
|
|
115
|
+
}
|