From d772909f821c9df641c0864f590fa61da3137621 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sat, 9 Mar 2024 20:22:23 +0400 Subject: [PATCH 01/20] Make frame time calculation more precise -> smooth playback --- sinner/gui/GUIModel.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index d5b7ca5e..afe8f9d5 100644 --- a/sinner/gui/GUIModel.py +++ b/sinner/gui/GUIModel.py @@ -485,24 +485,27 @@ def _process_frame(self, frame_index: int) -> float | None: def _show_frames(self) -> None: 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: + 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}") + 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: From 31f09b998c46414477b8e63c574456e7b0370406 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 15:23:01 +0400 Subject: [PATCH 02/20] Improve frame preparation time speed in pygame player --- sinner/gui/controls/FramePlayer/PygameFramePlayer.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sinner/gui/controls/FramePlayer/PygameFramePlayer.py b/sinner/gui/controls/FramePlayer/PygameFramePlayer.py index 1a1d5293..4de78af1 100644 --- a/sinner/gui/controls/FramePlayer/PygameFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/PygameFramePlayer.py @@ -4,8 +4,6 @@ from time import sleep from typing import Callable -import cv2 -import numpy import pygame from psutil import WINDOWS from pygame import Surface @@ -90,12 +88,12 @@ 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 + frame = frame[::-1, :, [2, 1, 0]] # swaps colors channels from BGR to RGB, flips the frame to a pygame coordinates + if rotate: - frame = self._rotate_frame(frame, self.rotate.next()) + frame = self._rotate_frame(frame, self.rotate.prev()) 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 = self._rotate_frame(frame, rotate_mode=RotateMode.ROTATE_270) # need to bring together numpy/pygame coordinates if resize is True: # resize to the current player size frame = resize_proportionally(frame, (self.screen.get_width(), self.screen.get_height())) From a1e28fb66778d31c29cc11edbd3287cd750b7c70 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 15:28:01 +0400 Subject: [PATCH 03/20] Use the parameter instead of multiple calls --- sinner/gui/controls/FramePlayer/BaseFramePlayer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py index 03c6cbe5..b8a5e83c 100644 --- a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py @@ -102,9 +102,9 @@ def _rotate_frame(self, frame: Frame, rotate_mode: RotateMode | None = None) -> if rotate_mode is RotateMode.ROTATE_90: return numpy.rot90(frame) if rotate_mode is RotateMode.ROTATE_180: - return numpy.rot90(numpy.rot90(frame)) + return numpy.rot90(frame, k=2) if rotate_mode is RotateMode.ROTATE_270: - return numpy.rot90(numpy.rot90(numpy.rot90(frame))) + return numpy.rot90(frame, k=3) @abstractmethod def set_fullscreen(self, fullscreen: bool = True) -> None: From f55cb6137774be23d68bc5c663944995aefb3481 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 16:03:32 +0400 Subject: [PATCH 04/20] Delete show_frame_wait() prototype call, the idea was not useful --- .../gui/controls/FramePlayer/BaseFramePlayer.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py index b8a5e83c..043fff0a 100644 --- a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py @@ -1,11 +1,9 @@ -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 @@ -56,19 +54,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 From cc85f242c8bda1a4bf1a61e0624ff3e738a79ca8 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 16:24:20 +0400 Subject: [PATCH 05/20] Completely rewrite rotation code, make it simpler, fix pygame-related issues --- sinner/gui/GUIForm.py | 16 +++---- .../controls/FramePlayer/BaseFramePlayer.py | 47 ++++++------------- .../controls/FramePlayer/PygameFramePlayer.py | 23 +++++++-- 3 files changed, 41 insertions(+), 45 deletions(-) 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/controls/FramePlayer/BaseFramePlayer.py b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py index 043fff0a..e2393bd5 100644 --- a/sinner/gui/controls/FramePlayer/BaseFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/BaseFramePlayer.py @@ -1,5 +1,4 @@ from abc import abstractmethod -from enum import Enum import numpy @@ -14,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: @@ -67,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: + if rotate_mode is ROTATE_180: return numpy.rot90(frame, k=2) - if rotate_mode is RotateMode.ROTATE_270: + 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 4de78af1..c3360aa6 100644 --- a/sinner/gui/controls/FramePlayer/PygameFramePlayer.py +++ b/sinner/gui/controls/FramePlayer/PygameFramePlayer.py @@ -4,11 +4,12 @@ from time import sleep from typing import Callable +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,10 +91,8 @@ def show_frame(self, frame: Frame | None = None, resize: bool | tuple[int, int] self._last_frame = frame frame = frame[::-1, :, [2, 1, 0]] # swaps colors channels from BGR to RGB, flips the frame to a pygame coordinates - if rotate: - frame = self._rotate_frame(frame, self.rotate.prev()) - else: - frame = self._rotate_frame(frame, rotate_mode=RotateMode.ROTATE_270) # need to bring together numpy/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())) @@ -139,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 From 75f437409793e16b4443bf446e83adc3ed07db56 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 17:19:50 +0400 Subject: [PATCH 06/20] Optimize code of fixes for image reading --- sinner/helpers/FrameHelper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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) From a2feef033074c09e666ee147d12ff7bda199ade1 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Sun, 10 Mar 2024 17:21:31 +0400 Subject: [PATCH 07/20] Use a bisect search to look for the previous suitable frame --- sinner/models/FrameDirectoryBuffer.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index ad44715c..87c22b4b 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -1,5 +1,6 @@ import os import threading +from bisect import bisect_right from pathlib import Path from typing import List @@ -79,15 +80,16 @@ def get_frame(self, index: int, return_previous: bool = True) -> NumberedFrame | 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 + previous_position = bisect_right(self._indices, index - 1) + if previous_position: + previous_index = self._indices[previous_position - 1] + previous_filename = str(previous_index).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: From 3368e8b22c0879c3d9bca41995cda610bf049681 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 15:04:48 +0400 Subject: [PATCH 08/20] Check if a frame index exists instead of check of file existence -> drop heavy I/O operation --- sinner/models/FrameDirectoryBuffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index 87c22b4b..27214099 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -74,7 +74,7 @@ def add_frame(self, frame: NumberedFrame) -> None: 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): + if self.has_frame(index): try: return NumberedFrame(index, read_from_image(filepath)) except Exception: From 993334e65d5112c942facc9d14cb5444474255c6 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 16:24:24 +0400 Subject: [PATCH 09/20] Implementing an additional and optional in-memory caching --- sinner/models/FrameDirectoryBuffer.py | 72 ++++++++++++++++++--------- sinner/models/FrameTimeLine.py | 4 +- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index 27214099..b9a93336 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -1,19 +1,30 @@ 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) + + 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 @@ -62,7 +73,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: @@ -70,33 +81,46 @@ 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 self.has_frame(index): - try: - return NumberedFrame(index, read_from_image(filepath)) - except Exception: - pass - elif return_previous: - previous_position = bisect_right(self._indices, index - 1) - if previous_position: - previous_index = self._indices[previous_position - 1] - previous_filename = str(previous_index).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)) + return NumberedFrame(index, read_from_image(filepath)) + 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/FrameTimeLine.py b/sinner/models/FrameTimeLine.py index 7b1e4e31..9be504d6 100644 --- a/sinner/models/FrameTimeLine.py +++ b/sinner/models/FrameTimeLine.py @@ -76,8 +76,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 From 7bf0343caafe3e64a59642f5a47ae8d76a37cdf0 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 16:25:21 +0400 Subject: [PATCH 10/20] This type of buffer can be deleted due new caching strategy in FrameDirectoryBuffer --- sinner/models/FrameMemoryBuffer.py | 32 ------------------------------ 1 file changed, 32 deletions(-) delete mode 100644 sinner/models/FrameMemoryBuffer.py 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 From 50219e1a2f2ec36ef696b9cf6629d12619338991 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 16:26:34 +0400 Subject: [PATCH 11/20] Method renamed --- sinner/gui/GUIModel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index afe8f9d5..56f78555 100644 --- a/sinner/gui/GUIModel.py +++ b/sinner/gui/GUIModel.py @@ -442,7 +442,7 @@ def process_done(future_: Future[float | None]) -> None: start_frame = self.TimeLine.get_frame_index() 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) From 0461174937bd049f8f5a3d36533185512fa47a9d Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 16:34:29 +0400 Subject: [PATCH 12/20] Added a threading lock when the frame is being read. --- sinner/models/FrameDirectoryBuffer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index b9a93336..694f6665 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -89,7 +89,8 @@ def get_frame(self, index: int, return_previous: bool = True) -> NumberedFrame | 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)) - return NumberedFrame(index, read_from_image(filepath)) + with threading.Lock(): + return NumberedFrame(index, read_from_image(filepath)) elif cache_result is False: if return_previous: previous_position = bisect_right(self._indices, index - 1) From 1650962c8af7770d771ec89456895f72dd899955 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Mon, 11 Mar 2024 20:10:08 +0400 Subject: [PATCH 13/20] Do not show the same frame, just wait for a next iteration --- sinner/gui/GUIModel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index 56f78555..d6416fcf 100644 --- a/sinner/gui/GUIModel.py +++ b/sinner/gui/GUIModel.py @@ -483,6 +483,7 @@ 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() @@ -492,8 +493,9 @@ def _show_frames(self) -> None: self.update_status("No more frames in the timeline") self._event_playback.clear() break - if n_frame is not None: + 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: From 50487b4a67a03c4bed0aeac809c6c6af7d08b8b2 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Tue, 12 Mar 2024 11:51:07 +0400 Subject: [PATCH 14/20] Additional check for case when the frame cannot be read --- sinner/models/FrameDirectoryBuffer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index 694f6665..60b44a36 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -90,7 +90,10 @@ def get_frame(self, index: int, return_previous: bool = True) -> NumberedFrame | filename = str(index).zfill(self.zfill_length) + '.png' filepath = str(os.path.join(self.path, filename)) with threading.Lock(): - return NumberedFrame(index, read_from_image(filepath)) + try: + return NumberedFrame(index, read_from_image(filepath)) + except Exception: + pass elif cache_result is False: if return_previous: previous_position = bisect_right(self._indices, index - 1) From ecc2758d53016ad681347b21bc7571f92bcaba0a Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Tue, 12 Mar 2024 12:28:23 +0400 Subject: [PATCH 15/20] Implement tag feature inside events --- sinner/models/Event.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 From 1d0d344af4c8a8f2857e2f118beb35b94957c141 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Tue, 12 Mar 2024 13:39:03 +0400 Subject: [PATCH 16/20] return None if frame file wasn't read --- sinner/models/FrameDirectoryBuffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index 60b44a36..19447050 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -93,7 +93,7 @@ def get_frame(self, index: int, return_previous: bool = True) -> NumberedFrame | try: return NumberedFrame(index, read_from_image(filepath)) except Exception: - pass + return None # if frame can't be read elif cache_result is False: if return_previous: previous_position = bisect_right(self._indices, index - 1) From d3a7d2819348084e6881771031dbb103a3c57ef2 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Fri, 15 Mar 2024 18:42:56 +0400 Subject: [PATCH 17/20] Refresh the timer on rewound (should fix rewind issues) --- sinner/models/FrameTimeLine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sinner/models/FrameTimeLine.py b/sinner/models/FrameTimeLine.py index 9be504d6..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: From bcafeb4f074faea2dd4e11337e461bcf4a5c8454 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Fri, 15 Mar 2024 18:51:21 +0400 Subject: [PATCH 18/20] Pass rewound frame index via event (to prevent async issues) --- sinner/gui/GUIModel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index d6416fcf..7e0c1a78 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,7 +439,7 @@ 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 self._event_rewind.clear() if not self.TimeLine.has_index(start_frame): From 7077d196dd227b51da2bb3430f583eed69225a96 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Fri, 15 Mar 2024 18:51:21 +0400 Subject: [PATCH 19/20] Pass rewound frame index via event (to prevent async issues) --- sinner/gui/GUIModel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sinner/gui/GUIModel.py b/sinner/gui/GUIModel.py index d6416fcf..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,7 +439,7 @@ 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_index(start_frame): From 40da57c975a995b483332533637b55627b318f95 Mon Sep 17 00:00:00 2001 From: Pozitronik Date: Fri, 15 Mar 2024 18:58:37 +0400 Subject: [PATCH 20/20] Add a note about disabled caching --- sinner/models/FrameDirectoryBuffer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sinner/models/FrameDirectoryBuffer.py b/sinner/models/FrameDirectoryBuffer.py index 19447050..2a12ca33 100644 --- a/sinner/models/FrameDirectoryBuffer.py +++ b/sinner/models/FrameDirectoryBuffer.py @@ -15,6 +15,7 @@ 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: