diff --git a/sinner/gui/GUIForm.py b/sinner/gui/GUIForm.py index a618fb89..c0ed8bf5 100644 --- a/sinner/gui/GUIForm.py +++ b/sinner/gui/GUIForm.py @@ -1,16 +1,16 @@ from argparse import Namespace from threading import Thread -from tkinter import filedialog, LEFT, Button, Frame, BOTH, StringVar, NW, X, Event, TOP, CENTER, Menu, CASCADE, COMMAND, RADIOBUTTON, CHECKBUTTON, SEPARATOR, BooleanVar, RIDGE, BOTTOM, NE +from tkinter import filedialog, LEFT, Button, Frame, BOTH, StringVar, NW, X, Event, TOP, CENTER, Menu, CASCADE, COMMAND, RADIOBUTTON, CHECKBUTTON, SEPARATOR, BooleanVar, RIDGE, BOTTOM, NE from tkinter.ttk import Spinbox, Label from typing import List from customtkinter import CTk from psutil import WINDOWS +from sinner.gui.controls.FramePlayer.BaseFramePlayer import ROTATE_90_CLOCKWISE, ROTATE_180, ROTATE_90_COUNTERCLOCKWISE from sinner.models.Event import Event as SinnerEvent from sinner.Status import Status from sinner.gui.GUIModel import GUIModel -from sinner.gui.controls.FramePlayer.BaseFramePlayer import RotateMode from sinner.gui.controls.FramePosition.BaseFramePosition import BaseFramePosition from sinner.gui.controls.FramePosition.SliderFramePosition import SliderFramePosition from sinner.gui.controls.ImageList import ImageList @@ -224,16 +224,16 @@ def save_current_frame() -> None: if save_file != '': self.GUIModel.Player.save_to_file(save_file) - self.RotateModeVar: StringVar = StringVar(value=RotateMode.ROTATE_0.value) + self.RotateModeVar: StringVar = StringVar(value="0°") self.RotateSubMenu: Menu = Menu(self.MainMenu, tearoff=False) self.MainMenu.add(CASCADE, menu=self.RotateSubMenu, label='Rotation') # type: ignore[no-untyped-call] # it is a library method - self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_0.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_0)) # type: ignore[no-untyped-call] # it is a library method - self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_90.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_90)) # type: ignore[no-untyped-call] # it is a library method - self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_180.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_180)) # type: ignore[no-untyped-call] # it is a library method - self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label=RotateMode.ROTATE_270.value, command=lambda: set_rotate_mode(RotateMode.ROTATE_270)) # type: ignore[no-untyped-call] # it is a library method + self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="0°", command=lambda: set_rotate_mode(None)) # type: ignore[no-untyped-call] # it is a library method + self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="90°", command=lambda: set_rotate_mode(ROTATE_90_CLOCKWISE)) # type: ignore[no-untyped-call] # it is a library method + self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="180°", command=lambda: set_rotate_mode(ROTATE_180)) # type: ignore[no-untyped-call] # it is a library method + self.RotateSubMenu.add(RADIOBUTTON, variable=self.RotateModeVar, label="270°", command=lambda: set_rotate_mode(ROTATE_90_COUNTERCLOCKWISE)) # type: ignore[no-untyped-call] # it is a library method - def set_rotate_mode(mode: RotateMode) -> None: + def set_rotate_mode(mode: int | None) -> None: self.GUIModel.Player.rotate = mode self.SoundEnabledVar: BooleanVar = BooleanVar(value=self.GUIModel.enable_sound()) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index d5b7ca5e..3e2e8519 100644 --- a/sinner/gui/GUIModel.py +++ b/sinner/gui/GUIModel.py @@ -349,7 +349,7 @@ def set_volume(self, volume: int) -> None: def rewind(self, frame_position: int) -> None: if self.player_is_started: self.TimeLine.rewind(frame_position - 1) - self._event_rewind.set() + self._event_rewind.set(tag=frame_position - 1) else: self.update_preview() self.position.set(frame_position) @@ -439,10 +439,10 @@ def process_done(future_: Future[float | None]) -> None: with ThreadPoolExecutor(max_workers=self.execution_threads) as executor: # this adds processing operations into a queue while start_frame <= end_frame: if self._event_rewind.is_set(): - start_frame = self.TimeLine.get_frame_index() + start_frame = self._event_rewind.tag or 0 self._event_rewind.clear() - if not self.TimeLine.has_frame(start_frame): + if not self.TimeLine.has_index(start_frame): future: Future[float | None] = executor.submit(self._process_frame, start_frame) future.add_done_callback(process_done) futures.append(future) @@ -483,26 +483,31 @@ def _process_frame(self, frame_index: int) -> float | None: return frame_render_time.execution_time def _show_frames(self) -> None: + last_shown_frame_index: int = -1 if self.Player: while self._event_playback.is_set(): + start_time = time.perf_counter() try: n_frame = self.TimeLine.get_frame() except EOFError: self.update_status("No more frames in the timeline") self._event_playback.clear() break - if n_frame is None: - time.sleep(self.frame_handler.frame_time / 2) - continue - self.Player.show_frame(n_frame.frame) - if self.TimeLine.last_returned_index is None: - self._status("Time position", "There are no ready frames") - else: - if not self._event_rewind.is_set(): - self.position.set(self.TimeLine.last_returned_index) - if self.TimeLine.last_returned_index: - self._status("Time position", seconds_to_hmsms(self.TimeLine.last_returned_index * self.frame_handler.frame_time)) - self._status("Last shown/rendered frame", f"{self.TimeLine.last_returned_index}/{self.TimeLine.last_added_index}") + if n_frame is not None and n_frame.index != last_shown_frame_index: + self.Player.show_frame(n_frame.frame) + last_shown_frame_index = n_frame.index + if self.TimeLine.last_returned_index is None: + self._status("Time position", "There are no ready frames") + else: + if not self._event_rewind.is_set(): + self.position.set(self.TimeLine.last_returned_index) + if self.TimeLine.last_returned_index: + self._status("Time position", seconds_to_hmsms(self.TimeLine.last_returned_index * self.frame_handler.frame_time)) + self._status("Last shown/rendered frame", f"{self.TimeLine.last_returned_index}/{self.TimeLine.last_added_index}") + loop_time = time.perf_counter() - start_time # time for the current loop, sec + sleep_time = self.frame_handler.frame_time - loop_time # time to wait for the next loop, sec + if sleep_time > 0: + time.sleep(sleep_time) self.update_status("_show_frames loop done") def extract_frames(self) -> bool: diff --git a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py index 03c6cbe5..e2393bd5 100644 --- a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py @@ -1,11 +1,8 @@ -import time from abc import abstractmethod -from enum import Enum import numpy from sinner.helpers import FrameHelper -from sinner.models.PerfCounter import PerfCounter from sinner.typing import Frame SWP_NOMOVE = 0x0002 @@ -16,32 +13,15 @@ HWND_NOTOPMOST = -2 SWP_NOACTIVATE = 0x0010 - -class RotateMode(Enum): - ROTATE_0 = "0°" - ROTATE_90 = "90°" - ROTATE_180 = "180°" - ROTATE_270 = "270°" - - def __str__(self) -> str: - return self.value[1] - - def prev(self) -> 'RotateMode': - enum_list = list(RotateMode) - current_index = enum_list.index(self) - previous_index = (current_index - 1) % len(enum_list) - return enum_list[previous_index] - - def next(self) -> 'RotateMode': - enum_list = list(RotateMode) - current_index = enum_list.index(self) - next_index = (current_index + 1) % len(enum_list) - return enum_list[next_index] +# those are similar to cv2 constants with same names +ROTATE_90_CLOCKWISE = 0 +ROTATE_180 = 1 +ROTATE_90_COUNTERCLOCKWISE = 2 class BaseFramePlayer: _last_frame: Frame | None = None # the last viewed frame - _rotate: RotateMode = RotateMode.ROTATE_0 + _rotate: int | None = None @abstractmethod def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int] | None = True, rotate: bool = True) -> None: @@ -56,19 +36,6 @@ def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int] """ pass - def show_frame_wait(self, frame: Frame | None = None, resize: bool | tuple[int, int] | None = True, rotate: bool = True, duration: float = 0) -> float: - """ - Shows a frame for the given duration (awaits after frame being shown). If duration is lesser than the frame show time - function won't wait - :returns await time - """ - with PerfCounter() as timer: - self.show_frame(frame=frame, resize=resize, rotate=rotate) - await_time = duration - timer.execution_time - if await_time > 0: - time.sleep(await_time) - return await_time - @abstractmethod def adjust_size(self, redraw: bool = True, size: tuple[int, int] | None = None) -> None: pass @@ -82,29 +49,30 @@ def clear(self) -> None: pass @property - def rotate(self) -> RotateMode: + def rotate(self) -> int | None: return self._rotate @rotate.setter - def rotate(self, value: RotateMode) -> None: + def rotate(self, value: int | None) -> None: self._rotate = value - self.clear() if self._last_frame is not None: + self.clear() _tmp_frame = self._last_frame - self.show_frame(self._rotate_frame(self._last_frame), rotate=False) + self.show_frame(self._last_frame) self._last_frame = _tmp_frame - def _rotate_frame(self, frame: Frame, rotate_mode: RotateMode | None = None) -> Frame: + def _rotate_frame(self, frame: Frame, rotate_mode: int | None = None) -> Frame: if rotate_mode is None: rotate_mode = self._rotate - if rotate_mode is RotateMode.ROTATE_0: + if rotate_mode is None: return frame - if rotate_mode is RotateMode.ROTATE_90: + if rotate_mode == ROTATE_90_CLOCKWISE: return numpy.rot90(frame) - if rotate_mode is RotateMode.ROTATE_180: - return numpy.rot90(numpy.rot90(frame)) - if rotate_mode is RotateMode.ROTATE_270: - return numpy.rot90(numpy.rot90(numpy.rot90(frame))) + if rotate_mode is ROTATE_180: + return numpy.rot90(frame, k=2) + if rotate_mode == ROTATE_90_COUNTERCLOCKWISE: + return numpy.rot90(frame, k=3) + return frame @abstractmethod def set_fullscreen(self, fullscreen: bool = True) -> None: diff --git a/sinner/gui/controls/FramePlayer/PygameFramePlayer.py b/sinner/gui/controls/FramePlayer/PygameFramePlayer.py index 1a1d5293..c3360aa6 100644 --- a/sinner/gui/controls/FramePlayer/PygameFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/PygameFramePlayer.py @@ -4,13 +4,12 @@ from time import sleep from typing import Callable -import cv2 import numpy import pygame from psutil import WINDOWS from pygame import Surface -from sinner.gui.controls.FramePlayer.BaseFramePlayer import BaseFramePlayer, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, HWND_TOP, RotateMode, SWP_NOACTIVATE +from sinner.gui.controls.FramePlayer.BaseFramePlayer import BaseFramePlayer, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOMOVE, SWP_NOSIZE, HWND_TOP, SWP_NOACTIVATE, ROTATE_90_CLOCKWISE, ROTATE_180, ROTATE_90_COUNTERCLOCKWISE from sinner.helpers.FrameHelper import resize_proportionally from sinner.models.Event import Event from sinner.typing import Frame @@ -90,12 +89,10 @@ def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int] frame = self._last_frame if frame is not None: self._last_frame = frame - if rotate: - frame = self._rotate_frame(frame, self.rotate.next()) - else: - frame = self._rotate_frame(frame, rotate_mode=RotateMode.ROTATE_90) # need to bring together numpy/pygame coordinates - frame = numpy.flip((cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)), 0) - # note: now the frame has the flipped shape (WIDTH, HEIGHT) + frame = frame[::-1, :, [2, 1, 0]] # swaps colors channels from BGR to RGB, flips the frame to a pygame coordinates + + # it's always required rotate frames for pygame to match the X coordinate + frame = self._rotate_frame(frame) if resize is True: # resize to the current player size frame = resize_proportionally(frame, (self.screen.get_width(), self.screen.get_height())) @@ -141,3 +138,17 @@ def bring_to_front(self) -> None: user32.SetWindowPos.restype = wintypes.HWND user32.SetWindowPos.argtypes = [wintypes.HWND, wintypes.HWND, wintypes.INT, wintypes.INT, wintypes.INT, wintypes.INT, wintypes.UINT] user32.SetWindowPos(pygame.display.get_wm_info()['window'], HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) + + # the method is overlapped because pygame uses inverted X coordinate + def _rotate_frame(self, frame: Frame, rotate_mode: int | None = None) -> Frame: + if rotate_mode is None: + rotate_mode = self._rotate + if rotate_mode is None: + return numpy.rot90(frame, k=3) + if rotate_mode == ROTATE_90_CLOCKWISE: + return frame + if rotate_mode is ROTATE_180: + return numpy.rot90(frame) + if rotate_mode == ROTATE_90_COUNTERCLOCKWISE: + return numpy.rot90(frame, k=2) + return frame diff --git a/sinner/helpers/FrameHelper.py b/sinner/helpers/FrameHelper.py index 13e90eee..d90d2bad 100644 --- a/sinner/helpers/FrameHelper.py +++ b/sinner/helpers/FrameHelper.py @@ -3,7 +3,7 @@ from pathlib import Path import cv2 -from numpy import fromfile, uint8, full +from numpy import fromfile, uint8, full, dstack from psutil import WINDOWS from sinner.typing import Frame @@ -20,9 +20,9 @@ def read_from_image(path: str) -> Frame: if WINDOWS: # issue #511 image = cv2.imdecode(fromfile(path, dtype=uint8), cv2.IMREAD_UNCHANGED) if len(image.shape) == 2: # fixes the b/w images issue - image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + image = dstack([image] * 3) if image.shape[2] == 4: # fixes the alpha-channel issue - image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + image = image[:, :, :3] return image else: return cv2.imread(path) diff --git a/sinner/models/Event.py b/sinner/models/Event.py index 1970367e..b476027b 100644 --- a/sinner/models/Event.py +++ b/sinner/models/Event.py @@ -5,11 +5,13 @@ class Event(threading.Event): _on_set_callback: Callable[[], Any] | None = None _on_clear_callback: Callable[[], Any] | None = None + _tag: int | None = None - def __init__(self, on_set_callback: Callable[[], Any] | None = None, on_clear_callback: Callable[[], Any] | None = None): + def __init__(self, on_set_callback: Callable[[], Any] | None = None, on_clear_callback: Callable[[], Any] | None = None, tag: int | None = None): super().__init__() self._on_set_callback = on_set_callback self._on_clear_callback = on_clear_callback + self._tag = tag def set_callback(self, callback: Callable[[], Any] | None = None) -> None: self._on_set_callback = callback @@ -17,12 +19,18 @@ def set_callback(self, callback: Callable[[], Any] | None = None) -> None: def clear_callback(self, callback: Callable[[], Any] | None = None) -> None: self._on_clear_callback = callback - def set(self) -> None: + def set(self, tag: int | None = None) -> None: super().set() + self._tag = tag if self._on_set_callback: self._on_set_callback() def clear(self) -> None: super().clear() + self._tag = None if self._on_clear_callback: self._on_clear_callback() + + @property + def tag(self) -> int | None: + return self._tag diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index ad44715c..2a12ca33 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -1,18 +1,31 @@ import os import threading +from bisect import bisect_right +from enum import Enum from pathlib import Path from typing import List from sinner.helpers.FrameHelper import write_to_image, read_from_image from sinner.models.NumberedFrame import NumberedFrame +from sinner.typing import Frame from sinner.utilities import is_absolute_path, path_exists, get_file_name +class CacheStrategy(Enum): + NONE = 0 # use cache only for indices check + ON_INIT = 1 # cache all existed frames to the memory (very wasteful) + ON_ADD = 2 # cache only fresh frames (default strategy) + # NOTE: caching still have issues on rewound action, so it is disabled until it fixed + + class FrameDirectoryBuffer: _temp_dir: str _zfill_length: int | None _path: str | None = None - _indices: List[int] = [] + _indices: List[int] = [] # it's required to have a separate frame indices list for quick search of frames + _frame_cache: dict[int, Frame] = {} + + cache_strategy: CacheStrategy = CacheStrategy.NONE def __init__(self, source_name: str, target_name: str, temp_dir: str, frames_count: int): self.source_name = source_name @@ -61,7 +74,7 @@ def get_frame_processed_name(self, frame: NumberedFrame) -> str: return str(os.path.join(self.path, filename)) def clean(self) -> None: - pass + self._frame_cache = {} # shutil.rmtree(self._path) def add_frame(self, frame: NumberedFrame) -> None: @@ -69,32 +82,50 @@ def add_frame(self, frame: NumberedFrame) -> None: if not write_to_image(frame.frame, self.get_frame_processed_name(frame)): raise Exception(f"Error saving frame: {self.get_frame_processed_name(frame)}") self._indices.append(frame.index) + if self.cache_strategy in [CacheStrategy.ON_ADD, CacheStrategy.ON_INIT]: + self._frame_cache[frame.index] = frame.frame def get_frame(self, index: int, return_previous: bool = True) -> NumberedFrame | None: - filename = str(index).zfill(self.zfill_length) + '.png' - filepath = str(os.path.join(self.path, filename)) - if path_exists(filepath): - try: - return NumberedFrame(index, read_from_image(filepath)) - except Exception: - pass - elif return_previous: - for previous_number in range(index - 1, 0, -1): - if self.has_frame(previous_number): - previous_filename = str(previous_number).zfill(self.zfill_length) + '.png' - previous_file_path = os.path.join(self.path, previous_filename) - if path_exists(previous_file_path): - try: - return NumberedFrame(index, read_from_image(previous_file_path)) - except Exception: # the file may exist but can be locked in another thread. - pass - return None - - def has_frame(self, index: int) -> bool: + cache_result = self.has_frame(index) + if cache_result is True: # the frame is on the disk + filename = str(index).zfill(self.zfill_length) + '.png' + filepath = str(os.path.join(self.path, filename)) + with threading.Lock(): + try: + return NumberedFrame(index, read_from_image(filepath)) + except Exception: + return None # if frame can't be read + elif cache_result is False: + if return_previous: + previous_position = bisect_right(self._indices, index - 1) + if previous_position > 0: + previous_index = self._indices[previous_position - 1] + return self.get_frame(previous_index, return_previous=False) + else: + return None + else: + return None + return NumberedFrame(index, cache_result) + + def has_frame(self, index: int) -> bool | Frame: + """ + :param index: Requested frame index + :return: Frame if there's in cache, True if frame is on the disk, else return False + """ + if index in self._indices: + if index in self._frame_cache.keys(): + return self._frame_cache[index] # a frame is cached + return True # frame on the disk + return False + + def has_index(self, index: int) -> bool: return index in self._indices def init_indices(self) -> None: with os.scandir(self.path) as entries: for entry in entries: if entry.is_file() and entry.name.endswith(".png"): - self._indices.append(int(get_file_name(entry.name))) + entry_index = int(get_file_name(entry.name)) + self._indices.append(entry_index) + if self.cache_strategy is CacheStrategy.ON_INIT: + self._frame_cache[entry_index] = read_from_image(entry.path) diff --git a/sinner/models/FrameMemoryBuffer.py b/sinner/models/FrameMemoryBuffer.py deleted file mode 100644 index f11dfb83..00000000 --- a/sinner/models/FrameMemoryBuffer.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Dict - -from sinner.models.NumberedFrame import NumberedFrame - - -class FrameMemoryBuffer: - def __init__(self, source_name: str, target_name: str) -> None: - self.source_name = source_name - self.target_name = target_name - # In-memory storage structure, organized by source/target combinations - self.frames: Dict[int, NumberedFrame] = {} - - def clean(self) -> None: - self.frames.clear() - - def add_frame(self, frame: NumberedFrame) -> None: - """Adds a frame to the in-memory buffer.""" - self.frames[frame.index] = frame - - def get_frame(self, frame_index: int, return_previous: bool = True) -> NumberedFrame | None: - """Retrieves a frame by its ID from the in-memory buffer, if it exists.""" - result = self.frames.get(frame_index) - if not result and return_previous: - # Find the nearest previous frame index - closest_prev_index = max((index for index in self.frames.keys() if index < frame_index), default=None) - if closest_prev_index is not None: - return self.frames[closest_prev_index] - return result - - def has_frame(self, frame_index: int) -> bool: - """Checks if a frame exists in the in-memory buffer.""" - return frame_index in self.frames diff --git a/sinner/models/FrameTimeLine.py b/sinner/models/FrameTimeLine.py index 7b1e4e31..a57b114d 100644 --- a/sinner/models/FrameTimeLine.py +++ b/sinner/models/FrameTimeLine.py @@ -33,6 +33,8 @@ def rewind(self, frame_index: int) -> None: self._start_frame_index = frame_index self._start_frame_time = self._start_frame_index * self._frame_time self._FrameBuffer.clean() + if self._is_started: + self._timer = time.perf_counter() # start the time counter def start(self) -> None: @@ -76,8 +78,8 @@ def get_frame(self, time_aligned: bool = True) -> NumberedFrame | None: # print("Last requested/returned frame:", f"{self._last_requested_index}/{self._last_returned_index}") return result_frame - def has_frame(self, index: int) -> bool: - return self._FrameBuffer.has_frame(index) + def has_index(self, index: int) -> bool: + return self._FrameBuffer.has_index(index) # return the index of a frame, is playing right now if it is in self._frames # else return last frame before requested