diff --git a/dvr_scan/app/application.py b/dvr_scan/app/application.py index 3d16089..49cd637 100644 --- a/dvr_scan/app/application.py +++ b/dvr_scan/app/application.py @@ -147,8 +147,7 @@ def __init__(self, root: tk.Widget): variable=self._concatenate, onvalue=True, offvalue=False, - command=self._on_set_time, - state=tk.DISABLED, # TODO: Enable when implemented. + command=self._on_concatenate_changed, ).grid(row=2, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL) # TODO: Need to prevent start_time >= end_time. @@ -304,15 +303,26 @@ def _on_move_down(self): else: break + def _on_concatenate_changed(self): + """Handle change in concatenation mode.""" + if not self.concatenate: + # When not concatenating, disable and uncheck "Set Time" + self._set_time.set(False) + self._on_set_time() + def _on_set_time(self): - # TODO: When disabled, set start time 0 and end time duration of video. - state = tk.NORMAL if self._set_time.get() else tk.DISABLED - self._set_time_button["state"] = tk.NORMAL if self.concatenate else tk.DISABLED + # When not concatenating, set time is not available + can_set_time = self.concatenate + self._set_time_button["state"] = tk.NORMAL if can_set_time else tk.DISABLED + if not can_set_time: + self._set_time.set(False) + + state = tk.NORMAL if self._set_time.get() and can_set_time else tk.DISABLED self._start_time_label["state"] = state self._start_time["state"] = state self._end_time_label["state"] = state self._end_time["state"] = state - if state == tk.NORMAL and self.concatenate: + if state == tk.NORMAL: self._start_time_label.grid(row=3, column=1, sticky=EXPAND_HORIZONTAL) self._start_time.grid(row=3, column=2, padx=PADDING, sticky=EXPAND_HORIZONTAL) self._end_time.grid(row=3, column=4, padx=PADDING, sticky=EXPAND_HORIZONTAL) @@ -1529,7 +1539,12 @@ def _start_scan(self): if not settings: return - logger.debug(f"ui settings:\n{settings.app_settings}") + if len(settings) > 1: + logger.debug(f"ui settings (multi-video mode, {len(settings)} videos)") + for i, s in enumerate(settings): + logger.debug(f" video {i + 1}: {s.get('input')}") + else: + logger.debug(f"ui settings:\n{settings[0].app_settings}") def on_closed(): logger.debug("scan window closed, restoring focus") @@ -1659,8 +1674,14 @@ def _get_config_settings(self) -> ScanSettings: settings = self._scan_area.save(settings) return settings - def _get_settings(self) -> ty.Optional[ScanSettings]: - """Get current UI state with all options to run a scan.""" + def _get_settings(self) -> ty.Optional[ty.List[ScanSettings]]: + """Get current UI state with all options to run a scan. + + Returns: + A list of ScanSettings objects (one per video when not concatenating, + or a single-element list when concatenating). + None if there are no videos or the user cancelled. + """ settings = copy.deepcopy(self._settings) settings = self._input_area.update(settings) if not settings: @@ -1674,7 +1695,7 @@ def _get_settings(self) -> ty.Optional[ScanSettings]: OutputMode.SCAN_ONLY if settings.get("scan-only") else settings.get("output-mode") ) # Check if we are going to create any output files. We will create files as long as we're - # not in scan-only mode, or if we + # not in scan-only mode, or if we need mask output if not settings.get("output-dir") and ( output_mode != OutputMode.SCAN_ONLY or settings.get("mask-output") ): @@ -1687,15 +1708,29 @@ def _get_settings(self) -> ty.Optional[ScanSettings]: return None settings.set("output-dir", output_dir) - video_name = Path(settings.get("input")[0]).stem - if self._output_area.combine: - settings.set("output", f"{video_name}-events.avi") - - if settings.get("mask-output"): - settings.set("mask-output", f"{video_name}-mask.avi") - - if not self._input_area.concatenate and len(settings.get_arg("input")) > 1: - logger.error("ERROR - TODO: Handle non-concatenated inputs.") - return None - - return settings + videos = settings.get("input") + concatenate = self._input_area.concatenate + mask_output_enabled = settings.get("mask-output") + combine_events = self._output_area.combine + + if concatenate: + # Single scan with all videos concatenated + video_name = Path(videos[0]).stem + if combine_events: + settings.set("output", f"{video_name}-events.avi") + if mask_output_enabled: + settings.set("mask-output", f"{video_name}-mask.avi") + return [settings] + else: + # Separate scan for each video + settings_list = [] + for video_path in videos: + video_settings = copy.deepcopy(settings) + video_settings.set("input", [video_path]) + video_name = Path(video_path).stem + if combine_events: + video_settings.set("output", f"{video_name}-events.avi") + if mask_output_enabled: + video_settings.set("mask-output", f"{video_name}-mask.avi") + settings_list.append(video_settings) + return settings_list diff --git a/dvr_scan/app/scan_window.py b/dvr_scan/app/scan_window.py index ed52634..eaf16c7 100644 --- a/dvr_scan/app/scan_window.py +++ b/dvr_scan/app/scan_window.py @@ -17,6 +17,7 @@ import traceback import typing as ty from logging import getLogger +from pathlib import Path from scenedetect import FrameTimecode from tqdm import tqdm @@ -34,19 +35,30 @@ class ScanWindow: def __init__( - self, root: tk.Tk, settings: ScanSettings, on_destroyed: ty.Callable[[], None], padding: int + self, + root: tk.Tk, + settings: ty.List[ScanSettings], + on_destroyed: ty.Callable[[], None], + padding: int, ): self._root = tk.Toplevel(master=root) self._root.withdraw() self._root.title(TITLE) self._root.resizable(True, True) - self._scanner = init_scanner(settings) - self._scanner.set_callbacks( - scan_started=self._on_scan_started, - processed_frame=self._on_processed_frame, - ) + + self._settings_list = settings + self._current_video_index = 0 + self._total_videos = len(settings) + self._multi_video_mode = self._total_videos > 1 + + # Scanner is created in _do_scan for each video + self._scanner = None + self._scanner_lock = threading.Lock() + + # Determine if we should open folder on completion + first_settings = self._settings_list[0] self._open_on_completion = ( - settings.get("output-dir") if settings.get("open-output-dir") else None + first_settings.get("output-dir") if first_settings.get("open-output-dir") else None ) self._root.bind("<>", self.stop) @@ -56,42 +68,86 @@ def __init__( self._scan_thread = threading.Thread(target=self._do_scan) self._on_destroyed = on_destroyed + # Layout setup + width = self._root.winfo_reqwidth() + self._root.columnconfigure(0, weight=1, minsize=width / 2) + self._root.columnconfigure(1, weight=1, minsize=width / 2) + + current_row = 0 + + # Multi-video progress (only shown in multi-video mode) + if self._multi_video_mode: + self._video_progress_label = tk.Label( + self._root, text="Video 1 of " + str(self._total_videos) + ) + self._video_progress_label.grid( + row=current_row, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0) + ) + current_row += 1 + + self._video_progress = tk.IntVar(self._root, value=0) + self._video_progress_bar = ttk.Progressbar( + self._root, variable=self._video_progress, maximum=self._total_videos + ) + self._video_progress_bar.grid( + sticky=tk.NSEW, + row=current_row, + columnspan=2, + pady=(padding // 2, padding), + padx=padding, + ) + current_row += 1 + + # Current video name label + self._current_video_name = Path(self._settings_list[0].get("input")[0]).name + self._current_video_label = tk.Label(self._root, text=f"{self._current_video_name}") + self._current_video_label.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + current_row += 1 + else: + self._video_progress_label = None + self._video_progress_bar = None + self._video_progress = None + self._current_video_label = None + + # Events found label + self._events_label = tk.Label(self._root) + self._events_label.grid( + row=current_row, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0) + ) + current_row += 1 + + # Frame progress bar self._progress = tk.IntVar(self._root, value=0) self._progress_bar = ttk.Progressbar(self._root, variable=self._progress, maximum=10) - self._elapsed_label = tk.Label(self._root) - self._remaining_label = tk.Label(self._root) - self._stop_button = tk.Button(self._root, text="Stop", command=self.prompt_stop) - self._close_button = tk.Button( - self._root, text="Close", command=self._destroy, state=tk.DISABLED + self._progress_bar.grid( + sticky=tk.NSEW, row=current_row, columnspan=2, pady=padding, padx=padding ) - self._events_label = tk.Label(self._root) + self._progress_bar_row = current_row + current_row += 1 + + # Time remaining label + self._remaining_label = tk.Label(self._root) + self._remaining_label.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + current_row += 1 + + # Empty row for spacing + self._root.rowconfigure(current_row, weight=32) + current_row += 1 + + # Stats labels self._processed_label = tk.Label(self._root) + self._processed_label.grid(row=current_row, column=0, sticky=tk.NW, padx=padding) self._speed_label = tk.Label(self._root) - self._total_label = tk.Label(self._root) + self._speed_label.grid(row=current_row, column=1, sticky=tk.NE, padx=padding) + current_row += 1 - # Layout - width = self._root.winfo_reqwidth() - self._root.columnconfigure(0, weight=1, minsize=width / 2) - self._root.columnconfigure(1, weight=1, minsize=width / 2) - self._root.rowconfigure(0, weight=1) - self._root.rowconfigure(1, weight=1) - self._root.rowconfigure(2, weight=1) - self._root.rowconfigure(3, weight=32) - self._root.rowconfigure(4, weight=1) - self._root.rowconfigure(5, weight=1) - self._root.rowconfigure(6, weight=1) - self._root.rowconfigure(7, weight=2) - self._root.minsize( - width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight() - ) + self._total_label = tk.Label(self._root) + self._total_label.grid(row=current_row, column=0, sticky=tk.NW, padx=padding) + self._elapsed_label = tk.Label(self._root) + self._elapsed_label.grid(row=current_row, column=1, sticky=tk.NE, padx=padding) + current_row += 1 - self._events_label.grid(row=0, column=0, columnspan=2, sticky=tk.NSEW, pady=(padding, 0)) - self._progress_bar.grid(sticky=tk.NSEW, row=1, columnspan=2, pady=padding, padx=padding) - self._elapsed_label.grid(row=5, column=1, sticky=tk.NE, padx=padding) - self._remaining_label.grid(row=2, column=0, columnspan=2, sticky=tk.NSEW) - self._processed_label.grid(row=4, column=0, sticky=tk.NW, padx=padding) - self._total_label.grid(row=5, column=0, sticky=tk.NW, padx=padding) - self._speed_label.grid(row=4, column=1, sticky=tk.NE, padx=padding) + # Buttons frame frame = tk.Frame(self._root) frame.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) @@ -102,20 +158,29 @@ def __init__( ) self._stop_button.grid(row=0, column=0, pady=padding, sticky=tk.NSEW, padx=padding) self._close_button.grid(row=0, column=1, pady=padding, sticky=tk.NSEW, padx=padding) - frame.grid(row=7, column=0, columnspan=2, sticky=tk.NSEW) + frame.grid(row=current_row, column=0, columnspan=2, sticky=tk.NSEW) + self._root.minsize( + width=2 * self._root.winfo_reqwidth(), height=self._root.winfo_reqheight() + ) + + # State variables self._scan_started = threading.Event() self._scan_finished = threading.Event() + self._video_finished = threading.Event() self._scan_exception = None self._start_time = 0.0 self._last_stats_update_ns = 0 self._expected_num_frames = 0 self._num_events = 0 + self._total_events = 0 # Aggregate across all videos self._frames_processed = 0 + self._total_frames_processed = 0 # Aggregate across all videos self._elapsed = "00:00" self._remaining = "N/A" self._rate = "N/A" self._padding = padding + self._stopped = False def _update(self): if self._scan_finished.is_set(): @@ -124,20 +189,29 @@ def _update(self): logger.critical(f"error during scan:\n{formatted_exception}") messagebox.showerror( "Scan Error", - "Error during scanning. See log messages for more info." - f"\nSummary: {self._scan_exception}", + f"Error during scan: {self._scan_exception}\n\nSee log messages for more info.", parent=self._root, ) self._destroy() return else: logger.debug("scan complete") + + # Update final state + if self._multi_video_mode: + self._video_progress.set(self._total_videos) + self._video_progress_label["text"] = f"Scanned {self._total_videos} Videos" + self._events_label["text"] = f"Total Events: {self._total_events}" + self._current_video_label.config(text=f"{self._current_video_name}") + else: + self._events_label["text"] = f"Total Events: {self._num_events}" + self._progress.set(self._expected_num_frames) self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" self._remaining_label["text"] = "\n" self._stop_button["state"] = tk.DISABLED self._close_button["state"] = tk.NORMAL - self._processed_label["text"] = f"Processed: {self._expected_num_frames} frames" + self._processed_label["text"] = f"Processed: {self._total_frames_processed} frames" self._speed_label["text"] = f"Rate: {self._rate} FPS" return False @@ -149,7 +223,7 @@ def _update(self): ) self._progress_bar.grid( sticky=tk.NSEW, - row=1, + row=self._progress_bar_row, columnspan=2, padx=self._padding, pady=self._padding, @@ -158,7 +232,16 @@ def _update(self): # Frames processed is updated from the worker thread, but we don't care about stale values. self._progress.set(self._frames_processed) - self._events_label["text"] = f"Events Found: {self._num_events}" + + if self._multi_video_mode: + self._video_progress.set(self._current_video_index) + self._video_progress_label["text"] = ( + f"Video {self._current_video_index + 1} of {self._total_videos}" + ) + self._events_label["text"] = f"Events: {self._num_events} (Total: {self._total_events})" + else: + self._events_label["text"] = f"Events: {self._num_events}" + self._elapsed_label["text"] = f"Elapsed: {self._elapsed}" self._remaining_label["text"] = f"Time Remaining:\n{self._remaining}" self._processed_label["text"] = f"Processed: {self._frames_processed:4} frames" @@ -181,8 +264,13 @@ def _destroy(self): logger.debug("root destroyed") def prompt_stop(self): - if self._scanner.is_stopped(): - return + with self._scanner_lock: + if ( + self._scanner is not None + and self._scanner.is_stopped() + and self._scan_finished.is_set() + ): + return if messagebox.askyesno( title="Stop scan?", message="Are you sure you want to stop the current scan?", @@ -192,9 +280,12 @@ def prompt_stop(self): self._destroy() def stop(self): - if not self._scanner.is_stopped(): - logger.debug("stopping scan thread") - self._scanner.stop() + with self._scanner_lock: + self._stopped = True + if self._scanner is not None and not self._scanner.is_stopped(): + logger.debug("stopping scan thread") + self._scanner.stop() + if self._scan_thread.is_alive(): self._scan_thread.join() def show(self): @@ -231,28 +322,58 @@ def _on_processed_frame(self, progress_bar: tqdm, num_events: int): (self._elapsed, self._remaining, self._rate, _unit) = values def _do_scan(self): - # We'll handle any errors below in the main Tkinter thread. + """Main scanning loop that handles both single and multi-video modes.""" + overall_start_time = time.time() + total_frames = 0 + try: - result = self._scanner.scan() - self._frames_processed = result.num_frames - except Exception as ex: # noqa: E722 + for i, settings in enumerate(self._settings_list): + self._current_video_index = i + + # Update video label in multi-video mode + if self._multi_video_mode and self._current_video_label: + self._current_video_name = Path(settings.get("input")[0]).name + + # Create scanner for this video + with self._scanner_lock: + if self._stopped: + break + self._scanner = init_scanner(settings) + self._scanner.set_callbacks( + scan_started=self._on_scan_started, + processed_frame=self._on_processed_frame, + ) + # Reset per-video state + self._frames_processed = 0 + self._num_events = 0 + self._progress.set(0) + + logger.info( + f"Scanning video {i + 1} of {self._total_videos}: {settings.get('input')[0]}" + ) + + result = self._scanner.scan() + self._frames_processed = result.num_frames + total_frames += result.num_frames + self._total_events += len(result.event_list) + self._total_frames_processed = total_frames + + logger.info(f"Finished scanning video: {len(result.event_list)} events found") + + except Exception as ex: self._scan_exception = ex finally: self._scan_finished.set() - elapsed = time.time() - self._start_time + + elapsed = time.time() - overall_start_time self._elapsed = ( FrameTimecode(elapsed, 1000.0) .get_timecode(precision=0, use_rounding=True) .removeprefix("00:") ) - # TODO: This rate will always be a tiny bit slower than the one shown in stdout since we - # use different timers. This can be fixed if we add a callback to the MotionScanner that - # we can use to access the progress bar the scanner created. - self._rate = ( - "%.2f" % (float(self._frames_processed) / elapsed) if self._frames_processed else "N/A" - ) + self._rate = "%.2f" % (float(total_frames) / elapsed) if total_frames else "N/A" # Open the output folder on a successful scan. On error, or if the user stopped the scan, # we don't open the window. - if self._open_on_completion and not self._scanner.is_stopped() and not self._scan_exception: + if self._open_on_completion and not self._stopped and not self._scan_exception: logger.debug("scan complete, opening output folder") open_path(self._open_on_completion)