Skip to content

Commit 921f535

Browse files
committed
[save-images] Make all threads exception-safe
Ensure errors are re-raised safely from worker threads by using non-blocking puts and monitoring a common error queue.
1 parent 86b31aa commit 921f535

1 file changed

Lines changed: 81 additions & 35 deletions

File tree

scenedetect/__init__.py

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import math
1919
import queue
20+
import sys
2021
import threading
2122
import typing as ty
2223
from dataclasses import dataclass
@@ -300,70 +301,115 @@ def run(
300301
total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True
301302
)
302303

304+
timecode_list = self.generate_timecode_list(scene_list)
305+
image_filenames = {i: [] for i in range(len(timecode_list))}
306+
303307
filename_template = Template(self._image_name_template)
308+
logger.debug("Writing images with template %s", filename_template.template)
304309
scene_num_format = "%0"
305310
scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d"
306311
image_num_format = "%0"
307312
image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d"
308313

309-
timecode_list = self.generate_timecode_list(scene_list)
310-
image_filenames = {i: [] for i in range(len(timecode_list))}
311-
logger.debug("Writing images with template %s", filename_template.template)
314+
def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode):
315+
return "%s.%s" % (
316+
filename_template.safe_substitute(
317+
VIDEO_NAME=video.name,
318+
SCENE_NUMBER=scene_num_format % (scene_number + 1),
319+
IMAGE_NUMBER=image_num_format % (image_number + 1),
320+
FRAME_NUMBER=image_timecode.get_frames(),
321+
TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000),
322+
TIMECODE=image_timecode.get_timecode().replace(":", ";"),
323+
),
324+
self._image_extension,
325+
)
312326

313327
MAX_QUEUED_ENCODE_FRAMES = 4
314328
MAX_QUEUED_SAVE_IMAGES = 4
315329
encode_queue = queue.Queue(MAX_QUEUED_ENCODE_FRAMES)
316330
save_queue = queue.Queue(MAX_QUEUED_SAVE_IMAGES)
317-
encode_thread = threading.Thread(
318-
target=self._image_encode_thread,
319-
args=(video, encode_queue, save_queue, self._image_extension),
320-
daemon=True,
321-
)
322-
save_thread = threading.Thread(
323-
target=self._save_files_thread,
324-
args=(save_queue, progress_bar),
325-
daemon=True,
331+
error_queue = queue.Queue(2) # Queue size must be the same as the # of worker threads!
332+
333+
def check_error_queue():
334+
try:
335+
return error_queue.get(block=False)
336+
except queue.Empty:
337+
pass
338+
return None
339+
340+
def launch_thread(callable, *args, **kwargs):
341+
def capture_errors(callable, *args, **kwargs):
342+
try:
343+
return callable(*args, **kwargs)
344+
# Errors we capture in `error_queue` will be re-raised by this thread.
345+
except: # noqa: E722
346+
error_queue.put(sys.exc_info())
347+
348+
thread = threading.Thread(
349+
target=capture_errors,
350+
args=(
351+
callable,
352+
*args,
353+
),
354+
kwargs=kwargs,
355+
daemon=True,
356+
)
357+
thread.start()
358+
return thread
359+
360+
def checked_put(work_queue: queue.Queue, item: ty.Any):
361+
error = None
362+
while True:
363+
try:
364+
work_queue.put(item, timeout=0.1)
365+
break
366+
except queue.Full:
367+
error = check_error_queue()
368+
if error is None:
369+
continue
370+
if error is not None:
371+
raise error[1].with_traceback(error[2])
372+
373+
encode_thread = launch_thread(
374+
self._encode_images,
375+
video,
376+
encode_queue,
377+
save_queue,
378+
self._image_extension,
326379
)
327-
encode_thread.start()
328-
save_thread.start()
380+
save_thread = launch_thread(self._save_images, save_queue, progress_bar)
329381

330382
for i, scene_timecodes in enumerate(timecode_list):
331-
for j, image_timecode in enumerate(scene_timecodes):
332-
video.seek(image_timecode)
383+
for j, timecode in enumerate(scene_timecodes):
384+
video.seek(timecode)
333385
frame_im = video.read()
334386
if frame_im is not None and frame_im is not False:
335-
# TODO: Add extension to template.
336-
# TODO: Allow NUM to be a valid suffix in addition to NUMBER.
337-
file_path = "%s.%s" % (
338-
filename_template.safe_substitute(
339-
VIDEO_NAME=video.name,
340-
SCENE_NUMBER=scene_num_format % (i + 1),
341-
IMAGE_NUMBER=image_num_format % (j + 1),
342-
FRAME_NUMBER=image_timecode.get_frames(),
343-
TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000),
344-
TIMECODE=image_timecode.get_timecode().replace(":", ";"),
345-
),
346-
self._image_extension,
347-
)
387+
file_path = format_filename(i, j, timecode)
348388
image_filenames[i].append(file_path)
349-
encode_queue.put((frame_im, get_and_create_path(file_path, output_dir)))
389+
checked_put(
390+
encode_queue, (frame_im, get_and_create_path(file_path, output_dir))
391+
)
350392
else:
351393
completed = False
352394
break
353395

354-
# *WARNING*: We do not handle errors or exceptions yet, and this can deadlock on errors!
355-
encode_queue.put((None, None))
356-
save_queue.put((None, None))
396+
checked_put(encode_queue, (None, None))
397+
checked_put(save_queue, (None, None))
357398
encode_thread.join()
358399
save_thread.join()
400+
401+
error = check_error_queue()
402+
if error is not None:
403+
raise error[1].with_traceback(error[2])
404+
359405
if progress_bar is not None:
360406
progress_bar.close()
361407
if not completed:
362408
logger.error("Could not generate all output images.")
363409

364410
return image_filenames
365411

366-
def _image_encode_thread(
412+
def _encode_images(
367413
self,
368414
video: VideoStream,
369415
encode_queue: queue.Queue,
@@ -393,7 +439,7 @@ def _image_encode_thread(
393439
continue
394440
save_queue.put((encoded, dest_path))
395441

396-
def _save_files_thread(self, save_queue: queue.Queue, progress_bar: tqdm):
442+
def _save_images(self, save_queue: queue.Queue, progress_bar: tqdm):
397443
while True:
398444
encoded, dest_path = save_queue.get()
399445
if encoded is None:

0 commit comments

Comments
 (0)