Skip to content

Commit 2dcb531

Browse files
committed
fix: v1.9.16 — performance audit, memory leak fixes, prior session cleanup
Performance & Memory Fixes: - Fix /video/auto-zoom returning empty dict instead of result_dict (broken feature) - Replace unbounded Thread spawning in job persistence with ThreadPoolExecutor(2) - Fix rate_limit slot permanently leaked when rate_limit() returns False - Fix GPU memory leak in audio_enhance — delete tensor refs before cuda.empty_cache() - Replace Timer-per-retry temp cleanup with single background worker thread - Add thread-safe locking to Haar cascade singleton in auto_zoom - Free duplicate BGR frame buffers immediately in color_match (~124MB savings) - Add close_all_connections() for SQLite job store shutdown cleanup - Fix health timer setInterval leak in CEP panel (clearInterval before reassign) Prior Session Work (merged): - subprocess.run check=False added across all bare subprocess calls - Exception chaining (raise ... from err) for proper tracebacks - contextlib.suppress replacing bare try/except:pass patterns - Import ordering cleanup (make_install_route hoisted to top-level imports) - FFmpeg dependency check robustness (returncode + version validation) - audio_suite: measure_loudness now raises on ffmpeg failure - clip-notes plugin upgraded to SQLite storage with legacy JSON compat - CEP panel: workspace state persistence, theme/style overhaul - New tests for loudness failure and ffmpeg dependency edge cases
1 parent b7160f7 commit 2dcb531

23 files changed

Lines changed: 6785 additions & 605 deletions

extension/com.opencut.panel/client/index.html

Lines changed: 342 additions & 103 deletions
Large diffs are not rendered by default.

extension/com.opencut.panel/client/main.js

Lines changed: 1467 additions & 227 deletions
Large diffs are not rendered by default.

extension/com.opencut.panel/client/style.css

Lines changed: 4347 additions & 51 deletions
Large diffs are not rendered by default.

opencut/core/audio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def extract_audio_pcm(
5555
"-",
5656
]
5757

58-
result = subprocess.run(cmd, capture_output=True, timeout=300)
58+
result = subprocess.run(cmd, capture_output=True, timeout=300, check=False)
5959
if result.returncode != 0:
6060
raise RuntimeError(f"Audio extraction failed: {result.stderr.decode(errors='replace')}")
6161

@@ -91,7 +91,7 @@ def extract_audio_wav(filepath: str, output_path: Optional[str] = None, sample_r
9191
output_path,
9292
]
9393

94-
result = subprocess.run(cmd, capture_output=True, timeout=300)
94+
result = subprocess.run(cmd, capture_output=True, timeout=300, check=False)
9595
if result.returncode != 0:
9696
raise RuntimeError(f"Audio extraction failed: {result.stderr.decode(errors='replace')}")
9797

opencut/core/audio_enhance.py

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import os
1313
import subprocess
1414
import tempfile
15+
from contextlib import suppress
1516

1617
from opencut.helpers import get_ffmpeg_path, get_ffprobe_path
1718

@@ -25,6 +26,19 @@
2526
_AUDIO_EXTENSIONS = frozenset({".wav", ".mp3", ".flac", ".aac", ".ogg", ".m4a", ".wma", ".opus"})
2627

2728

29+
def _binary_env(resolved_path):
30+
"""Prefer invoking ffmpeg/ffprobe by name while honoring bundled binaries."""
31+
if not resolved_path:
32+
return None
33+
binary_dir = os.path.dirname(resolved_path)
34+
if not binary_dir:
35+
return None
36+
env = os.environ.copy()
37+
current_path = env.get("PATH", "")
38+
env["PATH"] = binary_dir if not current_path else binary_dir + os.pathsep + current_path
39+
return env
40+
41+
2842
def _is_video(filepath):
2943
"""Check if file is a video (vs pure audio) by extension."""
3044
ext = os.path.splitext(filepath)[1].lower()
@@ -34,10 +48,11 @@ def _is_video(filepath):
3448
return False
3549
# Unknown extension — probe for video stream
3650
try:
51+
ffprobe_path = get_ffprobe_path()
3752
result = subprocess.run(
38-
[get_ffprobe_path(), "-v", "quiet", "-select_streams", "v:0",
53+
["ffprobe", "-v", "quiet", "-select_streams", "v:0",
3954
"-show_entries", "stream=codec_type", "-of", "csv=p=0", filepath],
40-
capture_output=True, text=True, timeout=10,
55+
capture_output=True, text=True, timeout=10, env=_binary_env(ffprobe_path), check=False,
4156
)
4257
return "video" in result.stdout.lower()
4358
except Exception:
@@ -58,8 +73,9 @@ def _extract_audio(input_path, output_wav):
5873
Raises:
5974
RuntimeError: If FFmpeg extraction fails.
6075
"""
76+
ffmpeg_path = get_ffmpeg_path()
6177
cmd = [
62-
get_ffmpeg_path(), "-hide_banner", "-loglevel", "error",
78+
"ffmpeg", "-hide_banner", "-loglevel", "error",
6379
"-y",
6480
"-i", input_path,
6581
"-vn",
@@ -70,11 +86,18 @@ def _extract_audio(input_path, output_wav):
7086
]
7187

7288
try:
73-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
74-
except FileNotFoundError:
75-
raise RuntimeError("FFmpeg not found. Install FFmpeg: https://ffmpeg.org/download.html")
76-
except subprocess.TimeoutExpired:
77-
raise RuntimeError(f"Audio extraction timed out for '{os.path.basename(input_path)}'")
89+
result = subprocess.run(
90+
cmd,
91+
capture_output=True,
92+
text=True,
93+
timeout=300,
94+
env=_binary_env(ffmpeg_path),
95+
check=False,
96+
)
97+
except FileNotFoundError as err:
98+
raise RuntimeError("FFmpeg not found. Install FFmpeg: https://ffmpeg.org/download.html") from err
99+
except subprocess.TimeoutExpired as err:
100+
raise RuntimeError(f"Audio extraction timed out for '{os.path.basename(input_path)}'") from err
78101

79102
if result.returncode != 0:
80103
stderr = result.stderr.strip()[-500:] if result.stderr else "unknown error"
@@ -134,20 +157,20 @@ def enhance_speech(
134157
try:
135158
import torch
136159
import torchaudio
137-
except ImportError:
160+
except ImportError as err:
138161
raise RuntimeError(
139162
"torch and torchaudio are required. Install with: "
140163
"pip install torch torchaudio"
141-
)
164+
) from err
142165

143166
try:
144167
from resemble_enhance.enhancer.inference import denoise as _denoise_fn
145168
from resemble_enhance.enhancer.inference import enhance as _enhance_fn
146-
except ImportError:
169+
except ImportError as err:
147170
raise RuntimeError(
148171
"resemble-enhance is required. Install with: "
149172
"pip install resemble-enhance"
150-
)
173+
) from err
151174

152175
# If input is video, extract audio to temp WAV
153176
temp_wav = None
@@ -157,9 +180,12 @@ def enhance_speech(
157180
if on_progress:
158181
on_progress(10, "Extracting audio from video...")
159182

160-
_tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="opencut_enhance_", delete=False)
161-
temp_wav = _tmp.name
162-
_tmp.close()
183+
with tempfile.NamedTemporaryFile(
184+
suffix=".wav",
185+
prefix="opencut_enhance_",
186+
delete=False,
187+
) as _tmp:
188+
temp_wav = _tmp.name
163189
_extract_audio(input_path, temp_wav)
164190
audio_path = temp_wav
165191

@@ -249,20 +275,19 @@ def enhance_speech(
249275
finally:
250276
# Clean up temp file
251277
if temp_wav and os.path.isfile(temp_wav):
252-
try:
278+
with suppress(OSError):
253279
os.remove(temp_wav)
254-
except OSError:
255-
pass
256-
# Free GPU memory
257-
try:
258-
del audio # noqa: F821
259-
except Exception:
260-
pass
261-
try:
280+
# Free GPU memory — delete all tensor references before clearing cache
281+
for _var in ("audio", "mono", "sr"):
282+
with suppress(Exception):
283+
if _var in locals():
284+
obj = locals()[_var]
285+
if hasattr(obj, "cpu"):
286+
obj.cpu() # move off GPU before delete
287+
del obj
288+
with suppress(Exception):
262289
if torch.cuda.is_available():
263290
torch.cuda.empty_cache()
264-
except Exception:
265-
pass
266291

267292

268293
# ---------------------------------------------------------------------------
@@ -304,10 +329,10 @@ def enhance_speech_clearvoice(
304329

305330
try:
306331
from clearvoice import ClearVoice
307-
except ImportError:
332+
except ImportError as err:
308333
raise RuntimeError(
309334
"clearvoice is required. Install with: pip install clearvoice"
310-
)
335+
) from err
311336

312337
# If input is video, extract audio to temp WAV
313338
temp_wav = None
@@ -317,9 +342,12 @@ def enhance_speech_clearvoice(
317342
if on_progress:
318343
on_progress(10, "Extracting audio from video...")
319344

320-
_tmp = tempfile.NamedTemporaryFile(suffix=".wav", prefix="opencut_cv_", delete=False)
321-
temp_wav = _tmp.name
322-
_tmp.close()
345+
with tempfile.NamedTemporaryFile(
346+
suffix=".wav",
347+
prefix="opencut_cv_",
348+
delete=False,
349+
) as _tmp:
350+
temp_wav = _tmp.name
323351
_extract_audio(input_path, temp_wav)
324352
audio_path = temp_wav
325353

@@ -361,13 +389,14 @@ def enhance_speech_clearvoice(
361389

362390
finally:
363391
if temp_wav and os.path.isfile(temp_wav):
364-
try:
392+
with suppress(OSError):
365393
os.remove(temp_wav)
366-
except OSError:
367-
pass
368-
try:
394+
# Release model and result tensors before clearing GPU cache
395+
for _var in ("cv", "result"):
396+
with suppress(Exception):
397+
if _var in locals():
398+
del locals()[_var]
399+
with suppress(Exception):
369400
import torch
370401
if torch.cuda.is_available():
371402
torch.cuda.empty_cache()
372-
except Exception:
373-
pass

opencut/core/audio_suite.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def denoise_audio(
119119
output_path,
120120
]
121121

122-
result = subprocess.run(cmd, capture_output=True, timeout=600)
122+
result = subprocess.run(cmd, capture_output=True, timeout=600, check=False)
123123
if result.returncode != 0:
124124
raise RuntimeError(f"Noise reduction failed: {result.stderr.decode(errors='replace')}")
125125

@@ -183,7 +183,7 @@ def isolate_voice(
183183
output_path,
184184
]
185185

186-
result = subprocess.run(cmd, capture_output=True, timeout=600)
186+
result = subprocess.run(cmd, capture_output=True, timeout=600, check=False)
187187
if result.returncode != 0:
188188
raise RuntimeError(f"Voice isolation failed: {result.stderr.decode(errors='replace')}")
189189

@@ -222,7 +222,10 @@ def measure_loudness(input_path: str) -> LoudnessInfo:
222222
"-f", "null", "-",
223223
]
224224

225-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
225+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, check=False)
226+
if result.returncode != 0:
227+
stderr = result.stderr.strip()[-500:] if result.stderr else "unknown error"
228+
raise RuntimeError(f"Loudness analysis failed: {stderr}")
226229
# loudnorm prints JSON to stderr
227230
output = result.stderr
228231

@@ -318,7 +321,7 @@ def normalize_loudness(
318321
output_path,
319322
]
320323

321-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
324+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, check=False)
322325
if result.returncode != 0:
323326
stderr = result.stderr.strip()[-500:] if result.stderr else "unknown error"
324327
raise RuntimeError(f"Loudness normalization failed: {stderr}")
@@ -367,7 +370,7 @@ def detect_beats(
367370
"-f", "s16le", "-",
368371
]
369372

370-
result = subprocess.run(cmd, capture_output=True, timeout=300)
373+
result = subprocess.run(cmd, capture_output=True, timeout=300, check=False)
371374
if result.returncode != 0:
372375
raise RuntimeError(f"Audio extraction failed: {result.stderr.decode(errors='replace')}")
373376

@@ -520,7 +523,7 @@ def generate_ducking_keyframes(
520523
"-f", "s16le", "-",
521524
]
522525

523-
result = subprocess.run(cmd, capture_output=True, timeout=300)
526+
result = subprocess.run(cmd, capture_output=True, timeout=300, check=False)
524527
if result.returncode != 0:
525528
raise RuntimeError(f"Audio extraction failed: {result.stderr.decode(errors='replace')}")
526529

@@ -707,7 +710,7 @@ def apply_audio_effect(
707710
output_path,
708711
]
709712

710-
result = subprocess.run(cmd, capture_output=True, timeout=600)
713+
result = subprocess.run(cmd, capture_output=True, timeout=600, check=False)
711714
if result.returncode != 0:
712715
raise RuntimeError(f"Audio effect failed: {result.stderr.decode(errors='replace')}")
713716

opencut/core/auto_edit.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def check_auto_editor_version():
6464
result = subprocess.run(
6565
["auto-editor", "--version"],
6666
capture_output=True, text=True, timeout=10,
67+
check=False,
6768
)
6869
if result.returncode == 0:
6970
return result.stdout.strip()
@@ -75,6 +76,7 @@ def check_auto_editor_version():
7576
result = subprocess.run(
7677
[sys.executable, "-m", "auto_editor", "--version"],
7778
capture_output=True, text=True, timeout=10,
79+
check=False,
7880
)
7981
if result.returncode == 0:
8082
return result.stdout.strip()
@@ -374,11 +376,12 @@ def _run_auto_edit(input_path, method, threshold, margin, min_clip_length,
374376
try:
375377
result = subprocess.run(
376378
cmd, capture_output=True, text=True, timeout=timeout,
379+
check=False,
377380
)
378-
except subprocess.TimeoutExpired:
381+
except subprocess.TimeoutExpired as err:
379382
raise RuntimeError(
380383
f"auto-editor timed out after {timeout}s processing '{os.path.basename(input_path)}'"
381-
)
384+
) from err
382385

383386
if result.returncode != 0:
384387
stderr = result.stderr.strip()[-500:] if result.stderr else "unknown error"

opencut/core/auto_zoom.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import os
11+
import threading
1112
from typing import List, Optional
1213

1314
logger = logging.getLogger("opencut")
@@ -70,39 +71,45 @@ def _ease(t: float, mode: str) -> float:
7071
# ---------------------------------------------------------------------------
7172

7273
_CASCADE: Optional[object] = None
74+
_CASCADE_LOCK = threading.Lock()
7375

7476

7577
def _get_cascade():
76-
"""Load the frontal-face Haar cascade (cached after first load)."""
78+
"""Load the frontal-face Haar cascade (cached after first load). Thread-safe."""
7779
global _CASCADE
7880
if _CASCADE is not None:
7981
return _CASCADE
8082

81-
# Try the built-in OpenCV data path first
82-
cascade_name = "haarcascade_frontalface_default.xml"
83-
builtin_path = cv2.data.haarcascades + cascade_name # type: ignore[union-attr]
84-
if os.path.exists(builtin_path):
85-
_CASCADE = cv2.CascadeClassifier(builtin_path)
86-
return _CASCADE
83+
with _CASCADE_LOCK:
84+
# Double-check after acquiring lock
85+
if _CASCADE is not None:
86+
return _CASCADE
8787

88-
# Fallback: search common locations
89-
candidates = [
90-
os.path.join(os.path.dirname(__file__), cascade_name),
91-
os.path.join(os.path.expanduser("~"), ".opencut", cascade_name),
92-
]
93-
for path in candidates:
94-
if os.path.exists(path):
95-
_CASCADE = cv2.CascadeClassifier(path)
88+
# Try the built-in OpenCV data path first
89+
cascade_name = "haarcascade_frontalface_default.xml"
90+
builtin_path = cv2.data.haarcascades + cascade_name # type: ignore[union-attr]
91+
if os.path.exists(builtin_path):
92+
_CASCADE = cv2.CascadeClassifier(builtin_path)
9693
return _CASCADE
9794

98-
# Return empty classifier — detection will always return no faces,
99-
# and we fall back to centre-crop anchors.
100-
logger.warning(
101-
"Haar cascade not found at %s; face detection disabled, using centre crop.",
102-
builtin_path,
103-
)
104-
_CASCADE = cv2.CascadeClassifier()
105-
return _CASCADE
95+
# Fallback: search common locations
96+
candidates = [
97+
os.path.join(os.path.dirname(__file__), cascade_name),
98+
os.path.join(os.path.expanduser("~"), ".opencut", cascade_name),
99+
]
100+
for path in candidates:
101+
if os.path.exists(path):
102+
_CASCADE = cv2.CascadeClassifier(path)
103+
return _CASCADE
104+
105+
# Return empty classifier — detection will always return no faces,
106+
# and we fall back to centre-crop anchors.
107+
logger.warning(
108+
"Haar cascade not found at %s; face detection disabled, using centre crop.",
109+
builtin_path,
110+
)
111+
_CASCADE = cv2.CascadeClassifier()
112+
return _CASCADE
106113

107114

108115
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)