davinci-resolve-mcp 2.68.1 → 2.69.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +46 -3
  3. package/docs/SKILL.md +181 -1
  4. package/docs/install.md +27 -1
  5. package/install.py +26 -1
  6. package/package.json +1 -1
  7. package/resolve-advanced/LICENSE +21 -0
  8. package/resolve-advanced/package.json +2 -1
  9. package/resolve-advanced/vendor/conform-qc/package.json +1 -0
  10. package/resolve-advanced/vendor/drp-format/package.json +1 -0
  11. package/resolve-advanced/vendor/drt-format/package.json +1 -0
  12. package/scripts/bridge_differential.py +22 -4
  13. package/scripts/doctor.py +129 -3
  14. package/scripts/install_resolve_bridge.py +13 -3
  15. package/src/granular/common.py +1 -1
  16. package/src/server.py +639 -18
  17. package/src/utils/beat_detection.py +242 -0
  18. package/src/utils/bridge_differential.py +27 -1
  19. package/src/utils/broll_placement.py +201 -0
  20. package/src/utils/conform_lint.py +437 -0
  21. package/src/utils/edit_engine.py +633 -1
  22. package/src/utils/edit_handles.py +206 -0
  23. package/src/utils/edit_report.py +360 -0
  24. package/src/utils/first_impression.py +212 -0
  25. package/src/utils/prebalance.py +471 -0
  26. package/src/utils/project_journal.py +360 -0
  27. package/src/utils/reference_match.py +203 -0
  28. package/src/utils/rhythm_audit.py +252 -0
  29. package/src/utils/rule_of_six.py +217 -0
  30. package/src/utils/setup_sheet.py +121 -0
  31. package/src/utils/shot_assembly.py +259 -0
  32. package/src/utils/silence_ripple.py +89 -2
  33. package/src/utils/sound_density.py +253 -0
  34. package/src/utils/split_edits.py +180 -0
  35. package/src/utils/take_ranking.py +206 -0
  36. package/src/utils/transcript_edit.py +101 -5
  37. package/src/utils/turnover.py +187 -0
@@ -0,0 +1,180 @@
1
+ """Split edits — sound leads picture, made measurable.
2
+
3
+ **The ear is faster than the eye.** Sound arrives at the audience ahead of
4
+ picture and is processed ahead of it, which is why the classical advice is to
5
+ consider the cut as a sound event first. Sound can pre-lap into the next scene and pull the
6
+ audience forward, linger from the previous one and create resonance, or carry
7
+ continuity across images that have none.
8
+
9
+ The split edit is that principle made concrete, and it is one of the few classical
10
+ positions that is a straightforward measurement:
11
+
12
+ | Shape | What happens | Effect |
13
+ |---|---|---|
14
+ | **L-cut** | Audio from the *outgoing* shot continues under the incoming picture | Lingers; creates resonance and continuity |
15
+ | **J-cut** | Audio from the *incoming* shot starts under the outgoing picture | Pre-laps; pulls the audience forward |
16
+ | **Straight** | Audio and picture cut together | Neutral, and the NLE default |
17
+
18
+ ## Why the ratio is the finding
19
+
20
+ A timeline of nothing but straight cuts is worth knowing about. It is what an
21
+ NLE produces when nobody has thought about sound: every audio edit sitting
22
+ exactly on its picture edit because that is where the software put it. It is
23
+ rarely what an editor wants and almost never what a dialogue scene wants.
24
+
25
+ **There is no correct ratio and this module does not suggest one.** It reports
26
+ the distribution. An editor reads that instantly; a prescription would be
27
+ nonsense across a montage, a dialogue two-hander and a documentary interview.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, Dict, List, Mapping, Sequence
33
+
34
+ #: Audio and picture edits within this many frames of each other are the same
35
+ #: join. Wider than a frame or two because audio edits get nudged by hand and a
36
+ #: two-frame offset is not a deliberate split edit.
37
+ PAIRING_WINDOW_FRAMES = 48
38
+
39
+ #: Below this the offset is a nudge, not an intent. A one-frame audio slip is
40
+ #: not sound leading picture; it is somebody trimming.
41
+ MIN_SPLIT_FRAMES = 3
42
+
43
+ SHAPE_L = "L-cut"
44
+ SHAPE_J = "J-cut"
45
+ SHAPE_STRAIGHT = "straight"
46
+
47
+
48
+ def _edit_points(items: Sequence[Mapping[str, Any]]) -> List[int]:
49
+ """Cut points on a track — every boundary where one item meets the next."""
50
+ frames = set()
51
+ for item in items:
52
+ for key in ("timeline_start_frame", "timeline_end_frame"):
53
+ value = item.get(key)
54
+ if value is not None:
55
+ frames.add(int(value))
56
+ return sorted(frames)
57
+
58
+
59
+ def classify_joins(
60
+ video_items: Sequence[Mapping[str, Any]],
61
+ audio_items: Sequence[Mapping[str, Any]],
62
+ *,
63
+ fps: float = 24.0,
64
+ pairing_window: int = PAIRING_WINDOW_FRAMES,
65
+ min_split: int = MIN_SPLIT_FRAMES,
66
+ ) -> Dict[str, Any]:
67
+ """Pair each picture edit with the nearest audio edit and classify the shape.
68
+
69
+ An unpaired picture edit is reported as `unpaired`, **not** as a straight
70
+ cut: no audio edit anywhere near a picture cut usually means the audio runs
71
+ continuously across it, which is a different and much stronger form of sound
72
+ carrying picture than a straight cut is.
73
+ """
74
+ picture = _edit_points(video_items)
75
+ sound = _edit_points(audio_items)
76
+ joins: List[Dict[str, Any]] = []
77
+ unpaired: List[int] = []
78
+
79
+ for cut in picture:
80
+ if not sound:
81
+ unpaired.append(cut)
82
+ continue
83
+ nearest = min(sound, key=lambda s: abs(s - cut))
84
+ offset = nearest - cut
85
+ if abs(offset) > pairing_window:
86
+ unpaired.append(cut)
87
+ continue
88
+ if abs(offset) < min_split:
89
+ shape, meaning = SHAPE_STRAIGHT, "audio and picture cut together"
90
+ elif offset > 0:
91
+ # Audio edit is LATER than picture: outgoing sound continues under
92
+ # the incoming image.
93
+ shape, meaning = SHAPE_L, "outgoing audio continues under the new picture — lingers"
94
+ else:
95
+ # Audio edit is EARLIER: incoming sound starts under the outgoing
96
+ # image.
97
+ shape, meaning = SHAPE_J, "incoming audio starts under the old picture — pulls forward"
98
+ joins.append({
99
+ "picture_frame": cut,
100
+ "audio_frame": nearest,
101
+ "offset_frames": offset,
102
+ "offset_seconds": round(offset / fps, 3) if fps else None,
103
+ "shape": shape,
104
+ "meaning": meaning,
105
+ })
106
+
107
+ counts = {
108
+ shape: sum(1 for j in joins if j["shape"] == shape)
109
+ for shape in (SHAPE_L, SHAPE_J, SHAPE_STRAIGHT)
110
+ }
111
+ return {
112
+ "joins": joins,
113
+ "counts": counts,
114
+ "unpaired_picture_cuts": unpaired,
115
+ "paired_count": len(joins),
116
+ }
117
+
118
+
119
+ def audit(
120
+ video_items: Sequence[Mapping[str, Any]],
121
+ audio_items: Sequence[Mapping[str, Any]],
122
+ *,
123
+ fps: float = 24.0,
124
+ ) -> Dict[str, Any]:
125
+ """Report the split-edit distribution across a timeline."""
126
+ if not video_items:
127
+ return {"success": False, "error": "No video items supplied"}
128
+ if not audio_items:
129
+ return {
130
+ "success": False,
131
+ "error": "No audio items supplied — split edits cannot be assessed",
132
+ "remediation": (
133
+ "Pass the timeline's audio track items. Without them this cannot "
134
+ "tell a straight cut from an unexamined one, and reporting 'all "
135
+ "straight' would be a fabrication."
136
+ ),
137
+ }
138
+
139
+ result = classify_joins(video_items, audio_items, fps=fps)
140
+ counts = result["counts"]
141
+ splits = counts[SHAPE_L] + counts[SHAPE_J]
142
+ paired = result["paired_count"]
143
+ findings: List[Dict[str, Any]] = []
144
+
145
+ if paired >= 8 and splits == 0:
146
+ findings.append({
147
+ "criterion": "rhythm",
148
+ "scope": "sequence",
149
+ "at": result["joins"][0]["picture_frame"] if result["joins"] else 0,
150
+ "summary": f"all {paired} joins are straight cuts — sound never leads picture",
151
+ "detail": (
152
+ "Every audio edit sits on its picture edit, which is where the NLE "
153
+ "puts them by default. The ear is faster than the eye: an L-cut lets "
154
+ "the outgoing line breathe under the new image, a J-cut pulls the "
155
+ "audience into the next scene before they see it. A dialogue "
156
+ "sequence with no split edits anywhere is usually one nobody has "
157
+ "listened to yet."
158
+ ),
159
+ })
160
+
161
+ return {
162
+ "success": True,
163
+ "kind": "split_edit_audit",
164
+ "paired_joins": paired,
165
+ "counts": counts,
166
+ "split_ratio": round(splits / paired, 3) if paired else None,
167
+ "unpaired_picture_cuts": len(result["unpaired_picture_cuts"]),
168
+ "joins": result["joins"],
169
+ "findings": findings,
170
+ "note": (
171
+ f"{counts[SHAPE_L]} L-cuts, {counts[SHAPE_J]} J-cuts, "
172
+ f"{counts[SHAPE_STRAIGHT]} straight across {paired} paired joins"
173
+ + (f"; {len(result['unpaired_picture_cuts'])} picture cuts had no audio "
174
+ "edit nearby, which usually means sound runs continuously across them"
175
+ if result["unpaired_picture_cuts"] else "")
176
+ + ". There is NO correct ratio and none is suggested — a montage, a "
177
+ "two-hander and an interview all want different distributions. This is "
178
+ "a reading, not a score."
179
+ ),
180
+ }
@@ -0,0 +1,206 @@
1
+ """Ranking takes on what can actually be measured.
2
+
3
+ Two people asked for this after watching an automated first pass — "can it
4
+ determine which takes are worse or better when it comes to the script?" — and
5
+ one answered his own question in the next comment: "I'd trust it even less to
6
+ choose the right take."
7
+
8
+ He is right, and the honest version of this feature says so out loud.
9
+
10
+ **What this measures:** fluency. Filler density, stammered restarts, how long
11
+ the speaker goes without stumbling, and — when a script is supplied — how much
12
+ of the intended line actually got said. All countable, all reproducible, none of
13
+ them a matter of opinion.
14
+
15
+ **What this cannot measure:** whether the take is any good. In the editorial
16
+ weighting every cutter works from, emotion is roughly half the decision and
17
+ story most of the rest; fluency is not on the list at all. The take that lands
18
+ is regularly the one that stumbles — the hesitation *is* the performance, the
19
+ breath before the answer *is* the beat. A ranking that put the smoothest read on
20
+ top and called it "best" would be confidently wrong in exactly the cases that
21
+ matter most, and would train its user to stop looking.
22
+
23
+ So this returns a **fluency ranking with its evidence**, never a "best take",
24
+ and every result says which one it is. Use it to find the clean safety take, to
25
+ spot the one where someone dried, or to skip the six warm-ups before the read
26
+ got going — not to choose the performance. That is the editor's call and this
27
+ module has nothing useful to say about it.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, Dict, Mapping, Optional, Sequence
33
+
34
+ from src.utils import transcript_edit as _te
35
+
36
+ #: Below this, a take is too short for rate-based metrics to mean anything —
37
+ #: three fluent words is not evidence of fluency.
38
+ MIN_MEANINGFUL_SECONDS = 3.0
39
+
40
+
41
+ def _duration(words: Sequence[Mapping[str, Any]]) -> float:
42
+ spans = [s for s in (_te._word_span(w) for w in words) if s is not None]
43
+ if not spans:
44
+ return 0.0
45
+ return max(e for _, e in spans) - min(s for s, _ in spans)
46
+
47
+
48
+ def _longest_fluent_run(words: Sequence[Mapping[str, Any]], disfluent_spans: Sequence[Dict[str, Any]]) -> float:
49
+ """Longest stretch with no filler and no restart in it."""
50
+ spans = [s for s in (_te._word_span(w) for w in words) if s is not None]
51
+ if not spans:
52
+ return 0.0
53
+ start, end = min(s for s, _ in spans), max(e for _, e in spans)
54
+ breaks = sorted(
55
+ (float(d["start_seconds"]), float(d["end_seconds"]))
56
+ for d in disfluent_spans
57
+ if d.get("start_seconds") is not None and d.get("end_seconds") is not None
58
+ )
59
+ longest, cursor = 0.0, start
60
+ for b_start, b_end in breaks:
61
+ longest = max(longest, b_start - cursor)
62
+ cursor = max(cursor, b_end)
63
+ return round(max(longest, end - cursor), 3)
64
+
65
+
66
+ def _script_coverage(words: Sequence[Mapping[str, Any]], script: Optional[str]) -> Optional[Dict[str, Any]]:
67
+ """Fraction of the script's content words that appear in the take.
68
+
69
+ Bag-of-words on purpose: word order in a delivered read legitimately differs
70
+ from the page, and penalising that would rank paraphrase below a stumbling
71
+ literal reading. This answers "did they get through it", not "was it
72
+ word-perfect".
73
+ """
74
+ if not script or not script.strip():
75
+ return None
76
+ wanted = {_te.normalize_token(t) for t in script.split()}
77
+ wanted = {t for t in wanted if t and t not in _te.FILLER_WORDS}
78
+ if not wanted:
79
+ return None
80
+ said = {_te.normalize_token(w.get("word")) for w in words}
81
+ hit = wanted & said
82
+ return {
83
+ "covered": len(hit),
84
+ "script_words": len(wanted),
85
+ "ratio": round(len(hit) / len(wanted), 3),
86
+ "missing_sample": sorted(wanted - hit)[:12],
87
+ }
88
+
89
+
90
+ def score_take(
91
+ words: Sequence[Mapping[str, Any]],
92
+ *,
93
+ label: str,
94
+ script: Optional[str] = None,
95
+ ) -> Dict[str, Any]:
96
+ """Countable fluency metrics for one take. No judgement of performance."""
97
+ duration = _duration(words)
98
+ fillers = _te.detect_fillers(words)
99
+ false_starts = _te.detect_false_starts(words)
100
+ per_minute = (len(fillers) / (duration / 60.0)) if duration > 0 else 0.0
101
+ wpm = (len(words) / (duration / 60.0)) if duration > 0 else 0.0
102
+
103
+ result: Dict[str, Any] = {
104
+ "label": label,
105
+ "duration_seconds": round(duration, 2),
106
+ "word_count": len(words),
107
+ "words_per_minute": round(wpm, 1),
108
+ "filler_count": len(fillers),
109
+ "fillers_per_minute": round(per_minute, 2),
110
+ "false_start_count": len(false_starts),
111
+ "longest_fluent_run_seconds": _longest_fluent_run(words, list(fillers) + list(false_starts)),
112
+ "script_coverage": _script_coverage(words, script),
113
+ "reliable": duration >= MIN_MEANINGFUL_SECONDS,
114
+ }
115
+ if not result["reliable"]:
116
+ result["caveat"] = (
117
+ f"only {result['duration_seconds']}s of speech — too short for these "
118
+ "rates to mean anything; ranked last rather than dropped so it stays visible"
119
+ )
120
+ result["fluency_score"] = _fluency_score(result)
121
+ result["evidence"] = _evidence_line(result)
122
+ return result
123
+
124
+
125
+ def _fluency_score(m: Mapping[str, Any]) -> float:
126
+ """0-100, higher = more fluent. Deliberately simple and explainable.
127
+
128
+ Not tuned or learned: a weighting nobody can read is not reviewable, and the
129
+ components are published alongside so a reader can disagree with the
130
+ arithmetic instead of having to trust it.
131
+ """
132
+ if not m["reliable"]:
133
+ return 0.0
134
+ score = 100.0
135
+ score -= min(40.0, float(m["fillers_per_minute"]) * 5.0)
136
+ score -= min(30.0, float(m["false_start_count"]) * 6.0)
137
+ coverage = m.get("script_coverage")
138
+ if coverage:
139
+ score -= (1.0 - float(coverage["ratio"])) * 30.0
140
+ return round(max(0.0, score), 1)
141
+
142
+
143
+ def _evidence_line(m: Mapping[str, Any]) -> str:
144
+ bits = [
145
+ f"{m['filler_count']} fillers ({m['fillers_per_minute']}/min)",
146
+ f"{m['false_start_count']} restarts",
147
+ f"longest clean run {m['longest_fluent_run_seconds']}s",
148
+ ]
149
+ coverage = m.get("script_coverage")
150
+ if coverage:
151
+ bits.append(f"{int(coverage['ratio'] * 100)}% of script covered")
152
+ return "; ".join(bits)
153
+
154
+
155
+ #: Attached to every ranking. Not a disclaimer to be skimmed — it is the
156
+ #: difference between a useful tool and a misleading one.
157
+ RANKING_CAVEAT = (
158
+ "This ranks FLUENCY, not quality. It counts fillers, restarts and script "
159
+ "coverage — all measurable. It cannot hear performance, and performance is "
160
+ "most of what makes a take the right one. The take that plays best is "
161
+ "regularly the least fluent one: the hesitation is often the acting. Use "
162
+ "this to find the clean safety take or to skip the warm-ups, not to choose "
163
+ "the read."
164
+ )
165
+
166
+
167
+ def rank_takes(
168
+ takes: Sequence[Mapping[str, Any]],
169
+ *,
170
+ script: Optional[str] = None,
171
+ ) -> Dict[str, Any]:
172
+ """Rank takes by measurable fluency, with the evidence and the caveat.
173
+
174
+ Each take: `{"label": str, "words": [transcript_words rows]}`.
175
+ """
176
+ if not takes:
177
+ return {"success": False, "error": "No takes supplied"}
178
+
179
+ scored = [
180
+ score_take(t.get("words") or [], label=str(t.get("label") or f"take {i + 1}"), script=script)
181
+ for i, t in enumerate(takes)
182
+ ]
183
+ scored.sort(key=lambda s: (-s["fluency_score"], -s["longest_fluent_run_seconds"]))
184
+ for position, take in enumerate(scored, start=1):
185
+ take["rank"] = position
186
+
187
+ unreliable = [s["label"] for s in scored if not s["reliable"]]
188
+ return {
189
+ "success": True,
190
+ "kind": "take_ranking",
191
+ "ranked_by": "fluency",
192
+ "take_count": len(scored),
193
+ "takes": scored,
194
+ "most_fluent": scored[0]["label"] if scored else None,
195
+ "script_supplied": bool(script and script.strip()),
196
+ "unreliable_takes": unreliable,
197
+ "caveat": RANKING_CAVEAT,
198
+ "note": (
199
+ f"Ranked {len(scored)} takes by fluency. "
200
+ + (f"{len(unreliable)} {'take was' if len(unreliable) == 1 else 'takes were'} "
201
+ "too short to score reliably and sit at the bottom — not necessarily "
202
+ "bad, just unmeasurable. "
203
+ if unreliable else "")
204
+ + "The top entry is the most fluent, which is NOT the same as the best."
205
+ ),
206
+ }
@@ -151,19 +151,112 @@ def detect_fillers(words: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
151
151
  return out
152
152
 
153
153
 
154
+ #: Terminal punctuation on the *preceding* word. A pause after one of these is
155
+ #: where a thought ended, which is where an audience expects to breathe.
156
+ _SENTENCE_FINAL_PUNCT = (".", "?", "!", "…")
157
+ #: A pause here separates clauses — a real beat, but a smaller one.
158
+ _CLAUSE_FINAL_PUNCT = (",", ";", ":", "—", "–")
159
+
160
+ #: How much longer a *sentence-final* pause must be before it counts as dead air.
161
+ #:
162
+ #: A silence gate cannot tell these apart, because acoustically they are
163
+ #: identical: 900 ms of room tone after "…and that changed everything." and
164
+ #: 900 ms of room tone in the middle of "the thing is — uh — we had to leave"
165
+ #: measure the same. Editorially they are opposites. The first is the beat that
166
+ #: lets the line land; removing it is the single most common way an automated
167
+ #: tighten makes a cut feel machine-made. The second is a stumble.
168
+ #:
169
+ #: So sentence-final pauses are not exempt — a four-second hole after a full
170
+ #: stop is still dead air — they simply have to be much longer before we touch
171
+ #: them. Clause-final sits between the two.
172
+ BREATHING_ROOM_MULTIPLIER = 2.5
173
+ CLAUSE_PAUSE_MULTIPLIER = 1.5
174
+
175
+
176
+ def classify_pause(prev_word: Optional[Dict[str, Any]], next_word: Optional[Dict[str, Any]]) -> Dict[str, Any]:
177
+ """Where a pause sits syntactically, which decides whether it is craft or noise.
178
+
179
+ Honest limit: this reads punctuation, so it is only as good as the
180
+ transcriber's. ASR output with no punctuation at all cannot be classified —
181
+ that returns `unpunctuated` with low confidence rather than silently
182
+ assuming every pause is removable, which would reintroduce exactly the
183
+ behaviour this function exists to prevent.
184
+ """
185
+ raw = str((prev_word or {}).get("word") or "").strip()
186
+ if not raw:
187
+ return {"position": "unknown", "is_breathing_room": False,
188
+ "multiplier": 1.0, "confidence": "low",
189
+ "reason": "no preceding word text"}
190
+ if raw.endswith(_SENTENCE_FINAL_PUNCT):
191
+ return {"position": "sentence_final", "is_breathing_room": True,
192
+ "multiplier": BREATHING_ROOM_MULTIPLIER, "confidence": "high",
193
+ "reason": f"pause follows end of sentence ({raw[-1]!r}) — breathing room"}
194
+ if raw.endswith(_CLAUSE_FINAL_PUNCT):
195
+ return {"position": "clause_final", "is_breathing_room": True,
196
+ "multiplier": CLAUSE_PAUSE_MULTIPLIER, "confidence": "medium",
197
+ "reason": f"pause follows a clause break ({raw[-1]!r})"}
198
+ # No terminal punctuation. If nothing in the transcript is punctuated at all
199
+ # we must not read that as "every pause is mid-sentence".
200
+ return {"position": "intra_clause", "is_breathing_room": False,
201
+ "multiplier": 1.0, "confidence": "high",
202
+ "reason": "pause falls inside a phrase — not a beat the writing asked for"}
203
+
204
+
205
+ def transcript_is_punctuated(words: Sequence[Dict[str, Any]]) -> bool:
206
+ """Whether this transcript carries sentence punctuation at all.
207
+
208
+ Used to downgrade pause classification honestly: without punctuation every
209
+ pause looks intra-clause, and acting on that would strip every beat in the
210
+ programme.
211
+ """
212
+ for w in words:
213
+ raw = str((w or {}).get("word") or "").strip()
214
+ if raw.endswith(_SENTENCE_FINAL_PUNCT) or raw.endswith(_CLAUSE_FINAL_PUNCT):
215
+ return True
216
+ return False
217
+
218
+
154
219
  def detect_long_pauses(
155
- words: Sequence[Dict[str, Any]], *, max_pause: float, handle: float, min_cut: float
220
+ words: Sequence[Dict[str, Any]],
221
+ *,
222
+ max_pause: float,
223
+ handle: float,
224
+ min_cut: float,
225
+ preserve_breathing_room: bool = True,
156
226
  ) -> List[Dict[str, Any]]:
157
227
  """Gaps between words longer than `max_pause`, trimmed by a handle each side.
158
228
 
159
229
  Word-boundary based, so unlike a silence gate this never cuts into a word
160
230
  that merely dipped below a threshold.
231
+
232
+ With `preserve_breathing_room` (the default), the threshold is scaled by the
233
+ pause's syntactic position — see `classify_pause`. A pause after a full stop
234
+ has to be markedly longer than one mid-phrase before it is proposed for
235
+ removal, because the first is usually deliberate and the second usually is
236
+ not. Set it False to treat every gap as equal, which is the older behaviour.
161
237
  """
162
238
  out: List[Dict[str, Any]] = []
163
- spans = [s for s in (_word_span(w) for w in words) if s is not None]
164
- for (_, prev_end), (next_start, _) in zip(spans, spans[1:]):
239
+ indexed = [(i, s) for i, s in enumerate(_word_span(w) for w in words) if s is not None]
240
+ punctuated = transcript_is_punctuated(words) if preserve_breathing_room else False
241
+ for (i_prev, (_, prev_end)), (_, (next_start, _)) in zip(indexed, indexed[1:]):
165
242
  gap = next_start - prev_end
166
- if gap <= max_pause:
243
+ if preserve_breathing_room and punctuated:
244
+ classification = classify_pause(words[i_prev], None)
245
+ else:
246
+ classification = {
247
+ "position": "unpunctuated" if preserve_breathing_room else "unclassified",
248
+ "is_breathing_room": False,
249
+ "multiplier": 1.0,
250
+ "confidence": "low" if preserve_breathing_room else "high",
251
+ "reason": (
252
+ "transcript carries no punctuation, so a pause cannot be placed "
253
+ "syntactically — treated as removable, review before accepting"
254
+ if preserve_breathing_room
255
+ else "breathing-room preservation disabled by caller"
256
+ ),
257
+ }
258
+ effective_max = max_pause * float(classification["multiplier"])
259
+ if gap <= effective_max:
167
260
  continue
168
261
  start, end = prev_end + handle, next_start - handle
169
262
  if end - start >= min_cut:
@@ -171,7 +264,10 @@ def detect_long_pauses(
171
264
  "start_seconds": round(start, 3),
172
265
  "end_seconds": round(end, 3),
173
266
  "pause_seconds": round(gap, 3),
174
- "confidence": "high",
267
+ "confidence": classification["confidence"],
268
+ "pause_position": classification["position"],
269
+ "threshold_seconds": round(effective_max, 3),
270
+ "classification_reason": classification["reason"],
175
271
  })
176
272
  return out
177
273