src/open_vp_cal/widgets/timeline_widget.py (290 lines of code) (raw):
"""
Copyright 2024 Netflix Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Module describes the class which is responsible for the timeline widget.
"""
import os
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QVBoxLayout, QHBoxLayout, QSlider, QLabel, QPushButton, QSpinBox
from PySide6.QtCore import QObject, Signal, Slot, Qt, QEvent
from open_vp_cal.framework.sequence_loader import SequenceLoader, FrameRangeException
from open_vp_cal.framework.frame import Frame
from open_vp_cal.imaging.imaging_utils import load_image_buffer_to_qpixmap
from open_vp_cal.widgets import utils
from open_vp_cal.core import constants
from open_vp_cal.led_wall_settings import LedWallSettings
from open_vp_cal.widgets.project_settings_widget import ProjectSettingsModel
from open_vp_cal.widgets.utils import LockableWidget
class PixMapFrame(Frame):
"""
A Frame which holds a QPixmap instead of an ImageBuf
"""
def __init__(self, project_settings: ProjectSettingsModel):
super().__init__(project_settings)
self._pixmap = None
@property
def pixmap(self) -> QPixmap:
"""
Property for _frame_num.
Returns:
int: The frame number of this frame.
"""
if not self._pixmap:
self.load_pixmap()
return self._pixmap
def load_pixmap(self) -> None:
""" Load the pixmap from the image buffer if it does not exist
"""
if not self._pixmap:
self._pixmap = load_image_buffer_to_qpixmap(self._image_buf, self._project_settings)
def clear_pixmap(self) -> None:
""" Clears the pixmap
"""
self._pixmap = None
class TimelineModel(QObject):
"""
Timeline model, which holds data for start, end, and current frames.
"""
start_frame_changed = Signal(int)
end_frame_changed = Signal(int)
current_frame_changed = Signal(int)
current_frame_changed_frame = Signal(Frame)
sequence_loaded = Signal(object)
no_sequence_loaded = Signal()
has_sequence_loaded = Signal()
def __init__(self, project_settings: ProjectSettingsModel):
super().__init__()
self.project_settings = project_settings
self.sequence_changed = False
def set_start_frame(self, frame: int) -> None:
""" Set start frame value and emit a signal
Args:
frame: The frame to set
"""
if self.project_settings.current_wall.sequence_loader.set_start_frame(frame) or self.sequence_changed:
self.start_frame_changed.emit(frame)
def set_end_frame(self, frame: int) -> None:
""" Set end frame value and emit a signal.
Args:
frame: The frame to set
"""
if self.project_settings.current_wall.sequence_loader.set_end_frame(frame) or self.sequence_changed:
self.end_frame_changed.emit(frame)
def set_current_frame(self, frame: int) -> tuple[bool, Frame]:
""" Sets the current frame and emits a signal if the frame or sequence has changed
Args:
frame: The frame to set
Returns: A tuple of if the frame has changed and the frame object
"""
frame_changed, result = self.project_settings.current_wall.sequence_loader.set_current_frame(frame)
if frame_changed or self.sequence_changed:
self.current_frame_changed.emit(frame)
self.current_frame_changed_frame.emit(result)
return frame_changed, result
def led_wall_selection_changed(self) -> None:
""" Called when the LED wall selection has changed. We need to update the model to reflect the new wall
"""
if self.current_frame == -1:
self.no_sequence_loaded.emit()
return
if not self.project_settings.current_wall:
self.no_sequence_loaded.emit()
return
self.sequence_changed = True
self.has_sequence_loaded.emit()
self.set_start_frame(self.project_settings.current_wall.sequence_loader.start_frame)
self.set_end_frame(self.project_settings.current_wall.sequence_loader.end_frame)
# We cant keep the current frame as sequences could be different frame numbers
self.set_current_frame(self.project_settings.current_wall.sequence_loader.start_frame)
self.sequence_changed = False
def load_sequence(self, folder_path: str) -> None:
""" Loads a sequence into the sequence loader and stores the folder path in the LED wall we have set as the
current wall
Args:
folder_path: The folder path to load
"""
# Load the sequence into the sequence loader and store the folder path
self.project_settings.current_wall.sequence_loader.load_sequence(folder_path)
self.project_settings.current_wall.input_sequence_folder = folder_path
# We now force the system to note that the LED wall selection has changed because the sequence has changed
# which causes the signals to fire ensuring the model and ui are now in sync
self.led_wall_selection_changed()
self.sequence_loaded.emit(self.project_settings.current_wall)
def load_all_sequences_for_led_walls(self) -> None:
""" Loads all sequences for all led walls
Args:
file_type: The file type to load
"""
for wall in self.project_settings.led_walls:
if wall.input_sequence_folder:
wall.sequence_loader.load_sequence(wall.input_sequence_folder)
@property
def start_frame(self) -> int:
""" Returns the start frame of the current sequence if one is loaded, otherwise -1
Returns: The start frame of the current sequence if one is loaded, otherwise -1
"""
if not self.project_settings.current_wall:
return -1
return self.project_settings.current_wall.sequence_loader.start_frame
@property
def current_frame(self) -> int:
""" Returns the current frame of the current sequence if one is loaded, otherwise -1
Returns: The current frame of the current sequence if one is loaded, otherwise -1
"""
if not self.project_settings.current_wall:
return -1
return self.project_settings.current_wall.sequence_loader.current_frame
@property
def end_frame(self) -> int:
""" Returns the end frame of the current sequence if one is loaded, otherwise -1
Returns: The end frame of the current sequence if one is loaded, otherwise -1
"""
if not self.project_settings.current_wall:
return -1
return self.project_settings.current_wall.sequence_loader.end_frame
def on_input_plate_gamut_changed(self) -> None:
""" Triggered when the input plate gamut changes, and we force the sequence
loader to refresh the preview. We then toggle from the first frame and back
so the timeline loader re loads the preview
"""
if not self.project_settings.current_wall:
return
self.project_settings.current_wall.sequence_loader.refresh_preview()
if self.start_frame > -1:
self.set_current_frame(self.start_frame + 1)
self.set_current_frame(self.start_frame)
class TimelineLoader(SequenceLoader):
"""
Which inherits from SequenceLoader and specializes in loading PixMapFrames.
"""
def __init__(self, led_wall_settings: LedWallSettings):
"""
Initialize the model with start, end, and current frames.
"""
SequenceLoader.__init__(self, led_wall_settings)
self.frame_class = PixMapFrame
def _load_and_cache(self, frame):
super()._load_and_cache(frame)
self.cache[frame].load_pixmap()
def refresh_preview(self) -> None:
""" When called it clears each of the pixmaps for every frame stored in the
cache
"""
for frame in self.cache:
self.cache[frame].clear_pixmap()
class TimelineWidget(LockableWidget):
"""
Widget to control the timeline.
"""
def __init__(self, model, event_filter, parent=None):
"""
Initialize the widget and set up UI.
"""
super().__init__()
self.setFocusPolicy(Qt.StrongFocus)
self.transport_layout = None
self.to_start_button = None
self.step_back_button = None
self.step_back_pattern_button = None
self.step_forward_button = None
self.step_forward_pattern_button = None
self.current_frame_spinbox = None
self.current_frame_label = None
self.end_frame_spinbox = None
self.end_frame_label = None
self.slider = None
self.start_frame_spinbox = None
self.start_frame_label = None
self.h_layout = None
self.layout = None
self.to_end_button = None
self.model = model
self.parent = parent
self.event_filter = event_filter
self.init_ui()
self.installEventFilter(self.event_filter)
self.event_filter.left_arrow_pressed.connect(self.step_back_pattern)
self.event_filter.right_arrow_pressed.connect(self.step_forward_pattern)
self.model.start_frame_changed.connect(self.slider.setMinimum)
self.model.start_frame_changed.connect(self.start_frame_spinbox.setMinimum)
self.model.start_frame_changed.connect(self.end_frame_spinbox.setMinimum)
self.model.start_frame_changed.connect(self.start_frame_spinbox.setValue)
self.model.start_frame_changed.connect(self.current_frame_spinbox.setMinimum)
self.model.end_frame_changed.connect(self.current_frame_spinbox.setMaximum)
self.model.end_frame_changed.connect(self.end_frame_spinbox.setMaximum)
self.model.end_frame_changed.connect(self.end_frame_spinbox.setValue)
self.model.end_frame_changed.connect(self.start_frame_spinbox.setMaximum)
self.model.end_frame_changed.connect(self.slider.setMaximum)
self.model.current_frame_changed.connect(self.update_slider_value)
self.model.current_frame_changed.connect(self.current_frame_spinbox.setValue)
self.slider.valueChanged.connect(self.model.set_current_frame)
self.start_frame_spinbox.editingFinished.connect(self.update_start_frame)
self.end_frame_spinbox.editingFinished.connect(self.update_end_frame)
self.current_frame_spinbox.editingFinished.connect(self.update_current_frame)
self.disable()
def init_ui(self):
"""
Set up UI elements.
"""
self.layout = QVBoxLayout()
self.h_layout = QHBoxLayout()
self.start_frame_label = QLabel("Start Frame:")
self.start_frame_spinbox = QSpinBox()
self.start_frame_spinbox.setMinimum(0)
self.start_frame_spinbox.setMaximum(1000)
self.start_frame_spinbox.setValue(self.model.start_frame)
self.slider = QSlider(Qt.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(1000)
self.slider.setValue(self.model.current_frame)
self.end_frame_label = QLabel("End Frame:")
self.end_frame_spinbox = QSpinBox()
self.end_frame_spinbox.setMinimum(0)
self.end_frame_spinbox.setMaximum(1000)
self.end_frame_spinbox.setValue(self.model.end_frame)
self.current_frame_label = QLabel("Current Frame:")
self.current_frame_spinbox = QSpinBox()
self.current_frame_spinbox.setMinimum(0)
self.current_frame_spinbox.setMaximum(1000)
self.current_frame_spinbox.setValue(self.model.current_frame)
self.h_layout.addWidget(self.start_frame_label)
self.h_layout.addWidget(self.start_frame_spinbox)
self.h_layout.addWidget(self.slider)
self.h_layout.addWidget(self.end_frame_label)
self.h_layout.addWidget(self.end_frame_spinbox)
self.h_layout.addWidget(self.current_frame_label)
self.h_layout.addWidget(self.current_frame_spinbox)
self.layout.addLayout(self.h_layout)
self.transport_controls()
self.setLayout(self.layout)
def transport_controls(self):
"""
Create transport controls.
"""
self.transport_layout = QHBoxLayout()
self.transport_layout.setAlignment(Qt.AlignCenter)
self.step_back_pattern_button = QPushButton("#<<")
self.step_back_pattern_button.clicked.connect(self.step_back_pattern)
self.to_start_button = QPushButton("|<<")
self.to_start_button.clicked.connect(self.set_to_start)
self.step_back_button = QPushButton("|<")
self.step_back_button.clicked.connect(self.step_back)
self.step_forward_button = QPushButton(">|")
self.step_forward_button.clicked.connect(self.step_forward)
self.to_end_button = QPushButton(">>|")
self.to_end_button.clicked.connect(self.set_to_end)
self.step_forward_pattern_button = QPushButton(">>#")
self.step_forward_pattern_button.clicked.connect(self.step_forward_pattern)
self.transport_layout.addWidget(self.to_start_button)
self.transport_layout.addWidget(self.step_back_pattern_button)
self.transport_layout.addWidget(self.step_back_button)
self.transport_layout.addWidget(self.step_forward_button)
self.transport_layout.addWidget(self.step_forward_pattern_button)
self.transport_layout.addWidget(self.to_end_button)
self.layout.addLayout(self.transport_layout)
# pylint: disable=W0613(unused-argument)
def enterEvent(self, event: QEvent) -> None:
self.setFocus()
@staticmethod
def select_folder() -> str:
""" returns the file path to the selected folder if one was selected
:return: returns the file path to the selected folder if one was selected
"""
return utils.select_folder()
def _set_active_state(self, value: bool):
"""
Either enables or disables all the UI components
"""
for i in range(self.layout.count()):
layout = self.layout.itemAt(i)
for j in range(self.layout.itemAt(i).count()):
widget = layout.itemAt(j).widget()
if widget is not None:
widget.setDisabled(value)
def load_sequence(self):
""" Loads an image sequence into the model
"""
folder_path = self.select_folder()
if not folder_path:
return
if not os.path.exists(folder_path):
return
self.model.load_sequence(folder_path)
frame = self.model.current_frame
self.model.set_current_frame(frame + 1)
self.model.set_current_frame(frame)
@Slot()
def update_slider_value(self, value):
"""
Update slider value.
"""
self.slider.repaint()
self.slider.setValue(value)
self.slider.repaint()
@Slot()
def update_start_frame(self):
"""
Update start frame in the model.
"""
self.model.set_start_frame(self.start_frame_spinbox.value())
self.slider.setMinimum(self.start_frame_spinbox.value())
self.slider.repaint()
@Slot()
def update_end_frame(self):
"""
Update end frame in the model.
"""
self.model.set_end_frame(self.end_frame_spinbox.value())
self.slider.setMaximum(self.end_frame_spinbox.value())
self.slider.repaint()
@Slot()
def update_current_frame(self):
"""
Update current frame in model.
"""
self.model.set_current_frame(self.current_frame_spinbox.value())
@Slot()
def set_to_start(self):
"""
Set current frame to start frame.
"""
self.model.set_current_frame(self.start_frame_spinbox.value())
self.slider.repaint()
@Slot()
def step_back(self):
"""
Step back one frame.
"""
try:
self.model.set_current_frame(max(self.model.current_frame - 1, self.start_frame_spinbox.value()))
self.slider.repaint()
except FrameRangeException:
return
@Slot()
def step_forward(self):
"""
Step forward one frame.
"""
try:
self.model.set_current_frame(min(self.model.current_frame + 1, self.end_frame_spinbox.value()))
self.slider.repaint()
except FrameRangeException:
return
@Slot()
def step_forward_pattern(self):
"""
Step forward to next pattern frame.
"""
try:
first_red_frame = self.model.project_settings.current_wall.separation_results.first_red_frame
target_frame = self.model.current_frame
if target_frame < first_red_frame.frame_num:
target_frame = first_red_frame.frame_num
elif target_frame >= first_red_frame.frame_num:
target_frame += self.model.project_settings.current_wall.separation_results.separation
self.model.set_current_frame(target_frame)
self.slider.repaint()
except FrameRangeException:
return
@Slot()
def step_back_pattern(self):
"""
Step back to next pattern frame.
"""
try:
first_red_frame = self.model.project_settings.current_wall.separation_results.first_red_frame
target_frame = self.model.current_frame
if target_frame < first_red_frame.frame_num:
target_frame = first_red_frame.frame_num
elif target_frame >= first_red_frame.frame_num:
target_frame -= self.model.project_settings.current_wall.separation_results.separation
self.model.set_current_frame(target_frame)
self.slider.repaint()
except FrameRangeException:
return
@Slot()
def set_to_end(self):
"""
Set current frame to end frame.
"""
self.model.set_current_frame(self.end_frame_spinbox.value())
self.slider.repaint()