Skip to content

Commit 8a2a3de

Browse files
refactor(buzz): group video params into _VideoParams dataclass in FfmpegFrameReader (#1543)
Co-authored-by: Raivis Dejus <raivisd@scandiweb.com>
1 parent a591037 commit 8a2a3de

3 files changed

Lines changed: 111 additions & 93 deletions

File tree

buzz/ffmpeg_video_player.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
from dataclasses import dataclass
34
import os
45
import subprocess
56
import sys
@@ -74,6 +75,16 @@ def probe_video(file_path: str) -> dict:
7475
}
7576

7677

78+
@dataclass
79+
class _VideoParams:
80+
file_path: str
81+
width: int
82+
height: int
83+
fps: float
84+
frame_size: int
85+
start_ms: float
86+
87+
7788
class FfmpegFrameReader:
7889
"""
7990
Reads raw RGB24 video frames from a file via ffmpeg in a background thread.
@@ -83,15 +94,17 @@ class FfmpegFrameReader:
8394
MAX_BUFFER = 60
8495

8596
def __init__(self, file_path: str, width: int, height: int, fps: float, start_ms: int = 0):
86-
self._file_path = file_path
87-
self._width = width
88-
self._height = height
89-
self._fps = fps
90-
self._frame_size = width * height * 3
97+
self._params = _VideoParams(
98+
file_path=file_path,
99+
width=width,
100+
height=height,
101+
fps=fps,
102+
frame_size=width * height * 3,
103+
start_ms=float(start_ms),
104+
)
91105

92106
self._lock = threading.Lock()
93107
self._frames: deque = deque()
94-
self._start_ms = float(start_ms)
95108
self._done = False
96109
self._stop_event = threading.Event()
97110
self._proc: Optional[subprocess.Popen] = None
@@ -108,21 +121,21 @@ def _read_loop(self):
108121
ffmpeg = _find_ffmpeg()
109122
cmd = [
110123
ffmpeg,
111-
"-ss", str(self._start_ms / 1000.0),
112-
"-i", self._file_path,
124+
"-ss", str(self._params.start_ms / 1000.0),
125+
"-i", self._params.file_path,
113126
"-f", "rawvideo",
114127
"-pix_fmt", "rgb24",
115-
"-s", f"{self._width}x{self._height}",
116-
"-r", str(self._fps),
128+
"-s", f"{self._params.width}x{self._params.height}",
129+
"-r", str(self._params.fps),
117130
"-an",
118131
"-",
119132
]
120133
try:
121134
self._proc = subprocess.Popen(
122135
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
123136
)
124-
frame_ms = 1000.0 / self._fps
125-
pos = self._start_ms
137+
frame_ms = 1000.0 / self._params.fps
138+
pos = self._params.start_ms
126139

127140
while not self._stop_event.is_set():
128141
# Throttle when buffer is full
@@ -135,14 +148,14 @@ def _read_loop(self):
135148
if self._stop_event.is_set():
136149
break
137150

138-
raw = self._proc.stdout.read(self._frame_size)
139-
if len(raw) < self._frame_size:
151+
raw = self._proc.stdout.read(self._params.frame_size)
152+
if len(raw) < self._params.frame_size:
140153
with self._lock:
141154
self._done = True
142155
break
143156

144157
arr = np.frombuffer(raw, dtype=np.uint8).reshape(
145-
(self._height, self._width, 3)
158+
(self._params.height, self._params.width, 3)
146159
).copy()
147160
with self._lock:
148161
self._frames.append((pos, arr))
@@ -167,7 +180,7 @@ def get_frame_for_position(self, position_ms: float) -> Optional[np.ndarray]:
167180
if not self._frames:
168181
return None
169182

170-
frame_ms = 1000.0 / self._fps
183+
frame_ms = 1000.0 / self._params.fps
171184
# Drain frames that are more than one frame period behind
172185
while len(self._frames) > 1:
173186
ts, _ = self._frames[0]

buzz/file_transcriber_queue_worker.py

Lines changed: 68 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
from typing import Optional, Tuple, List, Set
99
from uuid import UUID
10+
from dataclasses import dataclass
1011

1112
# Fix SSL certificate verification for bundled applications (macOS, Windows)
1213
# This must be done before importing demucs which uses torch.hub with urllib
@@ -97,11 +98,15 @@ def callback(progress):
9798
from buzz.transcriber.whisper_file_transcriber import WhisperFileTranscriber
9899

99100

101+
@dataclass
102+
class _CurrentTranscription:
103+
task: Optional[FileTranscriptionTask] = None
104+
transcriber: Optional[FileTranscriber] = None
105+
transcriber_thread: Optional[QThread] = None
106+
107+
100108
class FileTranscriberQueueWorker(QObject):
101109
tasks_queue: multiprocessing.Queue
102-
current_task: Optional[FileTranscriptionTask] = None
103-
current_transcriber: Optional[FileTranscriber] = None
104-
current_transcriber_thread: Optional[QThread] = None
105110

106111
task_started = pyqtSignal(FileTranscriptionTask)
107112
task_progress = pyqtSignal(FileTranscriptionTask, float)
@@ -115,8 +120,8 @@ class FileTranscriberQueueWorker(QObject):
115120
def __init__(self, parent: Optional[QObject] = None):
116121
super().__init__(parent)
117122
self.tasks_queue = queue.Queue()
123+
self.current = _CurrentTranscription()
118124
self.canceled_tasks: Set[UUID] = set()
119-
self.current_transcriber = None
120125
self.speech_path = None
121126
self.speech_extractor_process = None
122127
self.is_running = False
@@ -143,7 +148,7 @@ def run(self):
143148

144149
self.is_running = True
145150

146-
if self.current_task.transcription_options.extract_speech:
151+
if self.current.task.transcription_options.extract_speech:
147152
status = self._setup_speech_extraction()
148153
if status == "error":
149154
self.is_running = False
@@ -153,22 +158,22 @@ def run(self):
153158
return
154159

155160
logging.debug("Starting next transcription task")
156-
self.task_progress.emit(self.current_task, 0)
161+
self.task_progress.emit(self.current.task, 0)
157162

158163
self._create_transcriber()
159164
self._setup_transcriber_thread()
160165

161166
def _cleanup_previous_transcriber(self):
162-
if self.current_transcriber is not None:
163-
self.current_transcriber.stop()
164-
self.current_transcriber = None
167+
if self.current.transcriber is not None:
168+
self.current.transcriber.stop()
169+
self.current.transcriber = None
165170

166171
def _get_next_task(self) -> bool:
167172
while True:
168-
self.current_task = self.tasks_queue.get()
169-
if self.current_task is None:
173+
self.current.task = self.tasks_queue.get()
174+
if self.current.task is None:
170175
return False
171-
if self.current_task.uid in self.canceled_tasks:
176+
if self.current.task.uid in self.canceled_tasks:
172177
continue
173178
return True
174179

@@ -182,21 +187,21 @@ def _setup_speech_extraction(self) -> str:
182187
import torch
183188
device = "cuda" if torch.cuda.is_available() else "cpu"
184189

185-
task_file_path = Path(self.current_task.file_path)
190+
task_file_path = Path(self.current.task.file_path)
186191
speech_path = task_file_path.with_name(f"{task_file_path.stem}_speech.mp3")
187192

188193
status = self._extract_speech(str(task_file_path), str(speech_path), device)
189194

190195
if status == "error":
191196
self.task_error.emit(
192-
self.current_task,
197+
self.current.task,
193198
_("Speech extraction failed! Check your internet connection \u2014 a model may need to be downloaded."),
194199
)
195200
elif status == "ok":
196201
self.speech_path = speech_path
197-
if not self.current_task.original_file_path:
198-
self.current_task.original_file_path = str(task_file_path)
199-
self.current_task.file_path = str(speech_path)
202+
if not self.current.task.original_file_path:
203+
self.current.task.original_file_path = str(task_file_path)
204+
self.current.task.file_path = str(speech_path)
200205

201206
return status
202207

@@ -208,14 +213,14 @@ def _run_plugins(self) -> bool:
208213
"""
209214
if self.plugin_manager is not None:
210215
try:
211-
self.plugin_manager.run_before_transcription(self.current_task)
216+
self.plugin_manager.run_before_transcription(self.current.task)
212217
except Exception as e:
213218
logging.error(f"Plugin before_transcription failed: {e}", exc_info=True)
214219

215-
should_skip, skip_segments = self.plugin_manager.run_check_skip(self.current_task)
220+
should_skip, skip_segments = self.plugin_manager.run_check_skip(self.current.task)
216221
if should_skip:
217222
logging.debug("Skipping transcription task (plugin signaled skip)")
218-
self.current_task.status = FileTranscriptionTask.Status.SKIPPED
223+
self.current.task.status = FileTranscriptionTask.Status.SKIPPED
219224
self.on_task_completed(skip_segments)
220225
self.is_running = False
221226
self._on_task_finished()
@@ -224,49 +229,49 @@ def _run_plugins(self) -> bool:
224229
return True
225230

226231
def _create_transcriber(self):
227-
model_type = self.current_task.transcription_options.model.model_type
232+
model_type = self.current.task.transcription_options.model.model_type
228233
if model_type == ModelType.OPEN_AI_WHISPER_API:
229-
self.current_transcriber = OpenAIWhisperAPIFileTranscriber(
230-
task=self.current_task
234+
self.current.transcriber = OpenAIWhisperAPIFileTranscriber(
235+
task=self.current.task
231236
)
232237
elif (
233238
model_type == ModelType.WHISPER_CPP
234239
or model_type == ModelType.HUGGING_FACE
235240
or model_type == ModelType.WHISPER
236241
or model_type == ModelType.FASTER_WHISPER
237242
):
238-
self.current_transcriber = WhisperFileTranscriber(task=self.current_task)
243+
self.current.transcriber = WhisperFileTranscriber(task=self.current.task)
239244
else:
240245
raise Exception(f"Unknown model type: {model_type}")
241246

242247
def _setup_transcriber_thread(self):
243-
self.current_transcriber_thread = QThread(self)
248+
self.current.transcriber_thread = QThread(self)
244249

245-
self.current_transcriber.moveToThread(self.current_transcriber_thread)
250+
self.current.transcriber.moveToThread(self.current.transcriber_thread)
246251

247-
self.current_transcriber_thread.started.connect(self.current_transcriber.run)
248-
self.current_transcriber.completed.connect(self.current_transcriber_thread.quit)
249-
self.current_transcriber.error.connect(self.current_transcriber_thread.quit)
252+
self.current.transcriber_thread.started.connect(self.current.transcriber.run)
253+
self.current.transcriber.completed.connect(self.current.transcriber_thread.quit)
254+
self.current.transcriber.error.connect(self.current.transcriber_thread.quit)
250255

251-
self.current_transcriber.completed.connect(self.current_transcriber.deleteLater)
252-
self.current_transcriber.error.connect(self.current_transcriber.deleteLater)
253-
self.current_transcriber_thread.finished.connect(
254-
self.current_transcriber_thread.deleteLater
256+
self.current.transcriber.completed.connect(self.current.transcriber.deleteLater)
257+
self.current.transcriber.error.connect(self.current.transcriber.deleteLater)
258+
self.current.transcriber_thread.finished.connect(
259+
self.current.transcriber_thread.deleteLater
255260
)
256261

257-
self.current_transcriber.progress.connect(self.on_task_progress)
258-
self.current_transcriber.download_progress.connect(
262+
self.current.transcriber.progress.connect(self.on_task_progress)
263+
self.current.transcriber.download_progress.connect(
259264
self.on_task_download_progress
260265
)
261-
self.current_transcriber.error.connect(self.on_task_error)
266+
self.current.transcriber.error.connect(self.on_task_error)
262267

263-
self.current_transcriber.completed.connect(self.on_task_completed)
268+
self.current.transcriber.completed.connect(self.on_task_completed)
264269

265-
self.current_transcriber.error.connect(lambda: self._on_task_finished())
266-
self.current_transcriber.completed.connect(lambda: self._on_task_finished())
270+
self.current.transcriber.error.connect(lambda: self._on_task_finished())
271+
self.current.transcriber.completed.connect(lambda: self._on_task_finished())
267272

268-
self.task_started.emit(self.current_task)
269-
self.current_transcriber_thread.start()
273+
self.task_started.emit(self.current.task)
274+
self.current.transcriber_thread.start()
270275

271276
def _extract_speech(self, file_path: str, speech_path: str, device: str) -> str:
272277
"""Run demucs speech extraction in a separate process.
@@ -298,7 +303,7 @@ def _extract_speech(self, file_path: str, speech_path: str, device: str) -> str:
298303
audio_length = int(message[2] * 100)
299304
if audio_length:
300305
self.task_progress.emit(
301-
self.current_task,
306+
self.current.task,
302307
int(message[1] * 100) / audio_length,
303308
)
304309
elif kind == "done":
@@ -364,44 +369,44 @@ def add_task(self, task: FileTranscriptionTask):
364369
def cancel_task(self, task_id: UUID):
365370
self.canceled_tasks.add(task_id)
366371

367-
if self.current_task is not None and self.current_task.uid == task_id:
372+
if self.current.task is not None and self.current.task.uid == task_id:
368373
self._terminate_speech_extractor_process()
369374

370-
if self.current_transcriber is not None:
371-
self.current_transcriber.stop()
375+
if self.current.transcriber is not None:
376+
self.current.transcriber.stop()
372377

373-
if self.current_transcriber_thread is not None:
374-
if not self.current_transcriber_thread.wait(5000):
378+
if self.current.transcriber_thread is not None:
379+
if not self.current.transcriber_thread.wait(5000):
375380
logging.warning("Transcriber thread did not terminate gracefully")
376-
self.current_transcriber_thread.terminate()
381+
self.current.transcriber_thread.terminate()
377382

378383
def on_task_error(self, error: str):
379384
if (
380-
self.current_task is not None
381-
and self.current_task.uid not in self.canceled_tasks
385+
self.current.task is not None
386+
and self.current.task.uid not in self.canceled_tasks
382387
):
383388
# Check if the error indicates cancellation
384389
if "canceled" in error.lower() or "cancelled" in error.lower():
385-
self.current_task.status = FileTranscriptionTask.Status.CANCELED
386-
self.current_task.error = error
390+
self.current.task.status = FileTranscriptionTask.Status.CANCELED
391+
self.current.task.error = error
387392
else:
388-
self.current_task.status = FileTranscriptionTask.Status.FAILED
389-
self.current_task.error = error
390-
self.task_error.emit(self.current_task, error)
393+
self.current.task.status = FileTranscriptionTask.Status.FAILED
394+
self.current.task.error = error
395+
self.task_error.emit(self.current.task, error)
391396

392397
@pyqtSlot(tuple)
393398
def on_task_progress(self, progress: Tuple[int, int]):
394-
if self.current_task is not None:
395-
self.task_progress.emit(self.current_task, progress[0] / progress[1])
399+
if self.current.task is not None:
400+
self.task_progress.emit(self.current.task, progress[0] / progress[1])
396401

397402
def on_task_download_progress(self, fraction_downloaded: float):
398-
if self.current_task is not None:
399-
self.task_download_progress.emit(self.current_task, fraction_downloaded)
403+
if self.current.task is not None:
404+
self.task_download_progress.emit(self.current.task, fraction_downloaded)
400405

401406
@pyqtSlot(list)
402407
def on_task_completed(self, segments: List[Segment]):
403-
if self.current_task is not None:
404-
self.task_completed.emit(self.current_task, segments)
408+
if self.current.task is not None:
409+
self.task_completed.emit(self.current.task, segments)
405410

406411
if self.speech_path is not None:
407412
try:
@@ -412,8 +417,8 @@ def on_task_completed(self, segments: List[Segment]):
412417

413418
def stop(self):
414419
self.tasks_queue.put(None)
415-
if self.current_transcriber is not None:
416-
self.current_transcriber.stop()
420+
if self.current.transcriber is not None:
421+
self.current.transcriber.stop()
417422

418423
# Terminate the speech extraction process if one is still running.
419424
self._terminate_speech_extractor_process()

0 commit comments

Comments
 (0)