diff --git a/src/controller/cli/cli_context.py b/src/controller/cli/cli_context.py index 51d67875..a0011d8b 100644 --- a/src/controller/cli/cli_context.py +++ b/src/controller/cli/cli_context.py @@ -118,7 +118,7 @@ def __init__(self, show: BoardConfiguration, network_manager: NetworkManager, ex help="subcommands help", dest="subparser_name" ) for c in self._commands: - c.configure_parser(subparsers.add_parser(c.name, help=c.help, exit_on_error=False)) + c.configure_parser(subparsers.add_parser(c.name, help=c.help_text, exit_on_error=False)) if exit_available: subparsers.add_parser("exit", exit_on_error=False, help="Close this remote connection") self._return_text = "" diff --git a/src/controller/cli/command.py b/src/controller/cli/command.py index fa9767d0..6d625aee 100644 --- a/src/controller/cli/command.py +++ b/src/controller/cli/command.py @@ -48,10 +48,10 @@ def name(self) -> str: return "Unnamed Command" @property - def help(self) -> str: + def help_text(self) -> str: """Help text.""" return self._help_text - @help.setter - def help(self, new_help: str) -> None: + @help_text.setter + def help_text(self, new_help: str) -> None: self._help_text = str(new_help) diff --git a/src/controller/file/read.py b/src/controller/file/read.py index 6737c371..cf45a06d 100644 --- a/src/controller/file/read.py +++ b/src/controller/file/read.py @@ -12,7 +12,8 @@ from controller.file.deserialization.migrations import replace_old_filter_configurations from controller.file.deserialization.post_load_operations import link_patched_fixtures from controller.utils.process_notifications import get_process_notifier -from model import BoardConfiguration, ColorHSI, Filter, Scene, UIPage, Universe +from model import BoardConfiguration, Filter, Scene, UIPage, Universe +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn, FaderBank, RawDeskColumn from model.events import EventSender, mark_sender_persistent from model.filter import VirtualFilter @@ -264,6 +265,14 @@ def _parse_filter_page(element: ET.Element, parent_scene: Scene, instantiated_pa return True +def _parse_dmx_default_value(scene: Scene, child: ET.Element) -> None: + scene.insert_dmx_default_value( + int(child.attrib["universe"]), + int(child.attrib["channel"]), + int(child.attrib["value"]) + ) + + def _parse_scene( scene_element: ET.Element, board_configuration: BoardConfiguration, loaded_banksets: dict[str, BankSet] ) -> None: @@ -300,6 +309,8 @@ def _parse_scene( filter_pages.append(child) case "uipage": ui_page_elements.append(child) + case "dmxdefaultvalue": + _parse_dmx_default_value(scene, child) case _: logger.warning("Scene %s contains unknown element: %s", human_readable_name, child.tag) diff --git a/src/controller/file/serializing/scene_serialization.py b/src/controller/file/serializing/scene_serialization.py index 10c2ab21..3f86f2ce 100644 --- a/src/controller/file/serializing/scene_serialization.py +++ b/src/controller/file/serializing/scene_serialization.py @@ -107,6 +107,13 @@ def generate_scene_xml_description( for ui_page in scene.ui_pages: _add_ui_page_to_element(scene_element, ui_page) + scene.sort_dmx_default_values() + for default_value in scene.dmx_default_values: + ET.SubElement(scene_element, "dmxdefaultvalue", attrib={ + "universe": default_value.universe_id, + "channel": str(default_value.channel), + "value": str(default_value.value), + }) def _create_scene_element(scene: Scene, parent: ET.Element) -> ET.Element: diff --git a/src/controller/joystick/joystick_handling.py b/src/controller/joystick/joystick_handling.py index 0c6de60e..c0d3ea28 100644 --- a/src/controller/joystick/joystick_handling.py +++ b/src/controller/joystick/joystick_handling.py @@ -51,5 +51,5 @@ def reformat(key: Key) -> None: def __new__(cls) -> Self: """Connect a joystick and setup the key bindings.""" - mngr = pyjoystick.ThreadEventManager(event_loop=run_event_loop, handle_key_event=lambda key: cls.reformat(key)) + mngr = pyjoystick.ThreadEventManager(event_loop=run_event_loop, handle_key_event=cls.reformat) mngr.start() diff --git a/src/main.py b/src/main.py index 2dc7556d..11bf6fbe 100644 --- a/src/main.py +++ b/src/main.py @@ -2,12 +2,13 @@ if __name__ == "__main__": import os + import sys from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QSplashScreen QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseDesktopOpenGL) - app = QApplication([]) + app = QApplication(sys.argv) from PySide6.QtGui import QPixmap from utility import resource_path @@ -44,7 +45,6 @@ import logging.config import logging.handlers import pathlib - import sys from PySide6.QtCore import QEventLoop diff --git a/src/model/__init__.py b/src/model/__init__.py index 18d851e6..ae1f2436 100644 --- a/src/model/__init__.py +++ b/src/model/__init__.py @@ -1,8 +1,7 @@ -"""Public classes and methods from the model package""" +"""Public classes and methods from the model package.""" from .board_configuration import BoardConfiguration from .broadcaster import Broadcaster -from .color_hsi import ColorHSI from .device import Device from .filter import DataType, Filter from .scene import Scene diff --git a/src/model/board_configuration.py b/src/model/board_configuration.py index 48ba7b61..281261a0 100644 --- a/src/model/board_configuration.py +++ b/src/model/board_configuration.py @@ -2,6 +2,7 @@ from collections.abc import Callable, Sequence from logging import getLogger +from uuid import UUID import numpy as np from PySide6 import QtCore, QtGui @@ -30,7 +31,7 @@ def __init__(self, show_name: str = "", default_active_scene: int = 0, notes: st self._scenes_index: dict[int, int] = {} self._devices: list[Device] = [] self._universes: dict[int, Universe] = {} - self._fixtures: list[UsedFixture] = [] + self._fixtures: dict[str, UsedFixture] = {} self._ui_hints: dict[str, str] = {} self._macros: list[Macro] = [] @@ -100,7 +101,7 @@ def _add_universe(self, universe: Universe) -> None: self._universes.update({universe.id: universe}) def _add_fixture(self, used_fixture: UsedFixture) -> None: - self._fixtures.append(used_fixture) + self._fixtures[str(used_fixture.uuid)] = used_fixture def _delete_universe(self, universe: Universe) -> None: """Remove the passed universe from the list of universes. @@ -110,7 +111,10 @@ def _delete_universe(self, universe: Universe) -> None: """ try: - del self._universes[universe.id] + uid = universe.id if isinstance(universe, Universe) else universe if isinstance(universe, int) else None + if uid is None: + raise ValueError("Expected a universe.") + del self._universes[uid] except ValueError: logger.exception("Unable to remove universe %s", universe.name) @@ -146,7 +150,7 @@ def universe(self, universe_id: int) -> Universe | None: @property def fixtures(self) -> Sequence[UsedFixture]: """Fixtures associated with this Show.""" - return self._fixtures + return self._fixtures.values() @property def show_name(self) -> str: @@ -312,3 +316,28 @@ def get_occupied_channels(self, universe_id: int) -> np.typing.NDArray[int]: ] return np.concatenate(ranges) if ranges else np.array([], dtype=int) + + def get_fixture(self, fixture_id: str | UUID) -> UsedFixture | None: + """Get the fixture specified by its id.""" + if fixture_id == "" or fixture_id is None: + return None + if isinstance(fixture_id, UUID): + fixture_id = str(fixture_id) + return self._fixtures.get(fixture_id) + + def get_fixture_by_address(self, fixture_univ: int, fixture_chan: int) -> UsedFixture | None: + """Search for a fixture matching the provided address. + + Args: + fixture_univ: The universe of the fixture. + fixture_chan: A channel of the fixture. + + Returns: + The fixture or None if no fixture was found. + + """ + for fixture in self._fixtures.values(): + if fixture.universe_id == fixture_univ and \ + fixture.start_index <= fixture_chan < fixture.start_index + fixture.channel_length: + return fixture + return None diff --git a/src/model/broadcaster.py b/src/model/broadcaster.py index 0f8fd07c..7d8d54e7 100644 --- a/src/model/broadcaster.py +++ b/src/model/broadcaster.py @@ -57,6 +57,7 @@ class Broadcaster(QtCore.QObject, metaclass=QObjectSingletonMeta): scene_open_in_editor_requested: QtCore.Signal = QtCore.Signal(object) # FilterPage bankset_open_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) uipage_opened_in_editor_requested: QtCore.Signal = QtCore.Signal(dict) + default_dmx_value_editor_opening_requested: QtCore.Signal = QtCore.Signal(object) delete_scene: QtCore.Signal = QtCore.Signal(object) delete_universe: QtCore.Signal = QtCore.Signal(object) device_created: QtCore.Signal = QtCore.Signal(object) # device diff --git a/src/model/color_hsi.py b/src/model/color_hsi.py index 1721e1f5..3be69924 100644 --- a/src/model/color_hsi.py +++ b/src/model/color_hsi.py @@ -5,6 +5,7 @@ import colorsys from typing import TYPE_CHECKING +import numpy as np from PySide6.QtGui import QColor if TYPE_CHECKING: @@ -55,6 +56,62 @@ def from_filter_str(cls, filter_format: str) -> ColorHSI: intensity = 1.0 return ColorHSI(hue, saturation, intensity) + @classmethod + def from_rgb(cls, red: int, green: int, blue: int) -> ColorHSI: + """Initialize an HSI color from the given RGB color. + + Args: + red: Red component of the color. It must be in the range [0, 255] + green: Green component of the color. It must be in the range [0, 255] + blue: Blue component of the color. It must be in the range [0, 255] + + Returns: + The HSI color object. + + """ + hue, luminescence, saturation = colorsys.rgb_to_hls(red / 255, green / 255, blue / 255) + return ColorHSI(hue, luminescence, saturation) + + @classmethod + def from_color_temperature(cls, temperature: float | str) -> ColorHSI: + """Initialize an HSI color from the given color temperature. + + If a string is provided it will be converted automatically. + + Args: + temperature: Color temperature in degrees Kelvin. + + Returns: + A ColorHSI object representing the given color temperature. + + """ + + def cutoff(value: float, min_val: int, max_val: int) -> int: + return max(min(int(value), max_val), min_val) + + if isinstance(temperature, str): + temperature = float(temperature.lower().replace(" ", "").replace("k", "")) + temperature = temperature / 100 + if temperature <= 66: + red = 255 + green = temperature + green = 99.4708025861 * np.log(green) - 161.1195681661 + if temperature <= 19: + blue = 0 + else: + blue = temperature - 10 + blue = 138.5177312231 * np.log(blue) - 305.0447927307 + else: + red = temperature - 60 + red = 329.698727446 * (red ** -0.1332047592) + green = temperature - 60 + green = 288.1221695283 * (green ** -0.0755148492) + blue = 255 + red = cutoff(red, 0, 255) + green = cutoff(green, 0, 255) + blue = cutoff(blue, 0, 255) + return ColorHSI.from_rgb(red, green, blue) + @property def hue(self) -> confloat(ge=0, le=360): """Color itself in the form of an angle between [0,360] degrees.""" diff --git a/src/model/control_desk.py b/src/model/control_desk.py index f8008b1d..996c37df 100644 --- a/src/model/control_desk.py +++ b/src/model/control_desk.py @@ -34,7 +34,7 @@ def __init__(self, uid: str | None = None) -> None: uid: Unique identifier for the column. """ - self.id = uid if uid else _generate_unique_id() + self.id = uid or _generate_unique_id() self.bank_set: BankSet | None = None self._bottom_display_line_inverted = False self._top_display_line_inverted = False diff --git a/src/model/filter.py b/src/model/filter.py index 696f6874..cf4edb4f 100644 --- a/src/model/filter.py +++ b/src/model/filter.py @@ -82,6 +82,8 @@ class FilterTypeEnumeration(IntFlag): Negative values indicate virtual filters. """ + VFILTER_COLOR_TO_COLORWHEEL = -14 + VFILTER_DIMMER_BRIGHTNESS_MIXIN = -13 VFILTER_SEQUENCER = -12 VFILTER_COLOR_MIXER = -11 VFILTER_IMPORT = -10 @@ -303,16 +305,16 @@ def copy(self, new_scene: Scene = None, new_id: str | None = None) -> Filter: if self.is_virtual_filter: f = construct_virtual_filter_instance( - new_scene if new_scene else self.scene, + new_scene or self.scene, self._filter_type, - new_id if new_id else self._filter_id, + new_id or self._filter_id, pos=self._pos, ) f.filter_configurations.update(self.filter_configurations.copy()) else: f = Filter( - new_scene if new_scene else self.scene, - new_id if new_id else self._filter_id, + new_scene or self.scene, + new_id or self._filter_id, self._filter_type, self._pos, self.filter_configurations.copy(), diff --git a/src/model/filter_data/cues/cue.py b/src/model/filter_data/cues/cue.py index 4ae8056a..9bdd559f 100644 --- a/src/model/filter_data/cues/cue.py +++ b/src/model/filter_data/cues/cue.py @@ -18,7 +18,8 @@ from logging import getLogger from typing import TYPE_CHECKING, Never, Union, override -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.transfer_function import TransferFunction from model.filter_data.utility import format_seconds diff --git a/src/model/filter_data/sequencer/sequencer_channel.py b/src/model/filter_data/sequencer/sequencer_channel.py index fd83fdfa..9e582f73 100644 --- a/src/model/filter_data/sequencer/sequencer_channel.py +++ b/src/model/filter_data/sequencer/sequencer_channel.py @@ -5,7 +5,8 @@ from enum import Enum from logging import getLogger -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.sequencer._utils import _rf logger = getLogger(__name__) diff --git a/src/model/filter_data/sequencer/transition.py b/src/model/filter_data/sequencer/transition.py index 13f07211..319136c9 100644 --- a/src/model/filter_data/sequencer/transition.py +++ b/src/model/filter_data/sequencer/transition.py @@ -11,7 +11,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING -from model import ColorHSI, DataType +from model import DataType +from model.color_hsi import ColorHSI from model.filter_data.cues.cue import Cue, KeyFrame, State, StateColor, StateDouble, StateEightBit, StateSixteenBit from model.filter_data.sequencer._utils import _rf from model.filter_data.transfer_function import TransferFunction diff --git a/src/model/media_assets/asset.py b/src/model/media_assets/asset.py index 02dbc984..3c08ebc1 100644 --- a/src/model/media_assets/asset.py +++ b/src/model/media_assets/asset.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from model.media_assets.registry import register +from model.media_assets.registry import register, unregister if TYPE_CHECKING: from PySide6.QtGui import QPixmap @@ -102,3 +102,7 @@ def is_local_resource(self) -> bool: Non-local resources need to be provided and copied together with the show file. """ return False + + def unregister(self) -> None: + """Unregister this asset.""" + unregister(self) diff --git a/src/model/media_assets/registry.py b/src/model/media_assets/registry.py index dacc2cc5..4abaf857 100644 --- a/src/model/media_assets/registry.py +++ b/src/model/media_assets/registry.py @@ -30,6 +30,25 @@ def register(asset: MediaAsset, uuid: str) -> bool: d[uuid] = asset return True +def unregister(asset: MediaAsset) -> bool: + """Method unregisters a media asset. + + Args: + asset: the media asset to unregister + + Returns: + True if the asset was successfully unregistered, False otherwise. + + """ + reg = _asset_library.get(asset.get_type()) + if reg is None: + return False + try: + reg.pop(asset.id) + return True + except KeyError: + return False + def get_asset_by_uuid(uuid: str) -> MediaAsset | None: """Get a media asset by its UUID. diff --git a/src/model/ofl/color_name_dict.py b/src/model/ofl/color_name_dict.py new file mode 100644 index 00000000..bede6fe3 --- /dev/null +++ b/src/model/ofl/color_name_dict.py @@ -0,0 +1,41 @@ +"""Contains method to query color by name.""" + +import csv +import os +from logging import getLogger +from typing import TYPE_CHECKING + +from model.color_hsi import ColorHSI +from utility import resource_path + +logger = getLogger(__name__) +_COLOR_DICT: dict[str, tuple[float, float, float]] = {} + +with open(resource_path(os.path.join("resources", "data", "colornames.csv")), "r") as f: + for row in csv.reader(f, delimiter=";"): + name, hue, saturation, value = row + _COLOR_DICT[name.lower()] = (float(hue), float(saturation), float(value)) + +def get_color_by_name(name: str) -> ColorHSI: + """Method queries the color name database and returns black if none was found. + + HTML color codes are supported. + + Args: + name: The name or color code of the color. + + Returns: + The inferred color object. + + """ + if name.startswith("#"): + name = name.replace("#", "") + r = int(name[0:2], 16) + g = int(name[2:4], 16) + b = int(name[4:6], 16) + return ColorHSI.from_rgb(r, g, b) + color_tuple = _COLOR_DICT.get(name.lower()) + if color_tuple is not None: + return ColorHSI(color_tuple[0], color_tuple[1], color_tuple[2]) + logger.warning("No color name found for %s", name) + return ColorHSI(0, 0, 0) diff --git a/src/model/ofl/fixture.py b/src/model/ofl/fixture.py index 129b197e..bf53681d 100644 --- a/src/model/ofl/fixture.py +++ b/src/model/ofl/fixture.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import math import os import random from collections import defaultdict @@ -14,7 +15,7 @@ import numpy as np from PySide6 import QtCore -from model.ofl.ofl_fixture import FixtureMode, MatrixChannelInsert, OflFixture +from model.ofl.ofl_fixture import CapabilityType, FixtureMode, MatrixChannelInsert, OflFixture from model.patching.fixture_channel import FixtureChannel, FixtureChannelType if TYPE_CHECKING: @@ -22,7 +23,7 @@ from numpy.typing import NDArray - from model import BoardConfiguration + from model import BoardConfiguration, ColorHSI logger = getLogger(__name__) @@ -61,11 +62,48 @@ def load_fixture(file: str) -> OflFixture | None: logger.error("Fixture definition %s not found.", file) return None with open(file, "r", encoding="UTF-8") as f: - ob: dict = json.load(f) + try: + ob: dict = json.load(f) + except json.decoder.JSONDecodeError as e: + logger.error("Fixture definition (%s) JSON error: %s", file, e) ob.update({"fileName": file.split("/fixtures/")[1]}) return OflFixture.model_validate(ob) +def _load_colorwheel_mappings(f: OflFixture, channels: list[FixtureChannel]) -> \ + list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]]: + """Load color wheel mappings from OFL model.""" + outer_mapping_list = [] + for channel in channels: + fcl: list[tuple[int, ColorHSI, ColorHSI | None]] = [] + if channel.type != FixtureChannelType.COLORWHEEL: + continue + if channel.channel_template is None: + logger.error("The channel %s is a color wheel but the template was not found.", channel.name) + continue + color_wheel = f.wheels.get(channel.name) + if color_wheel is None: + logger.warning("The channel %s is has a color wheel but the wheel definition was not found.", + channel.name) + continue + for capability in channel.channel_template.get_capabilities(): + if capability.type == CapabilityType.WHEEL_SLOT: + slot_number = capability.capabilityProperties.get("slotNumber") + if len(capability.dmxRange) > 1: + capability_dmx_value = int((capability.dmxRange[0] + capability.dmxRange[1]) / 2) + else: + capability_dmx_value = capability.dmxRange[0] + if isinstance(slot_number, int): + wheel_slot = color_wheel.slots[slot_number % len(color_wheel.slots)] + fcl.append((capability_dmx_value, wheel_slot, None)) + else: + wheel_slot_a = color_wheel.slots[math.floor(slot_number) % len(color_wheel.slots)] + wheel_slot_b = color_wheel.slots[math.ceil(slot_number) % len(color_wheel.slots)] + fcl.append((capability_dmx_value, wheel_slot_a, wheel_slot_b)) + if len(fcl) > 0: + outer_mapping_list.append((channel, fcl)) + return outer_mapping_list + class UsedFixture(QtCore.QObject): """Fixture in use with a specific mode.""" @@ -96,22 +134,25 @@ def __init__( super().__init__() self._board_configuration: Final[BoardConfiguration] = board_configuration self._fixture: Final[OflFixture] = fixture - self._uuid: Final[UUID] = uuid if uuid else uuid4() + self._uuid: Final[UUID] = uuid or uuid4() self._start_index: int = start_index self._mode_index: int = mode_index self._universe_id: int = parent_universe - channels, segment_map, color_support = self._generate_fixture_channels() + channels, segment_map, color_support = self._generate_fixture_channels(fixture) self._fixture_channels: Final[list[FixtureChannel]] = channels self._segment_map: dict[FixtureChannelType, NDArray[np.int_]] = segment_map self._color_support: Final[ColorSupport] = color_support + self._colorwheel_mappings: list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]] = \ + _load_colorwheel_mappings(fixture, self._fixture_channels) + self._color_on_stage: str = ( - color if color else "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret + color or "#" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) # noqa: S311 not a secret ) - self._name_on_stage: str = self.short_name if self.short_name else self.name + self._name_on_stage: str = self.short_name or self.name self.parent_universe: int = parent_universe self._board_configuration.broadcaster.add_fixture.emit(self) @@ -121,6 +162,20 @@ def uuid(self) -> UUID: """UUID of the fixture.""" return self._uuid + @property + def colorwheel_mappings(self) -> list[tuple[FixtureChannel, list[tuple[int, ColorHSI, ColorHSI | None]]]]: + """Get the color wheels of this fixture. + + This list contains tuples of the channels that contain color wheels as well as their colors. + Each slot is a tuple of the DMX value that should be send in order to get the color as well as the colors of the + position. If the third parameter of this tuple is not None, the position is right in between two slots. + + Returns: + A copy of the list. + + """ + return list(self._colorwheel_mappings) + @property def power(self) -> float: """Fixture maximum continuous power draw (not accounting for capacitor charging as well as lamp warmup) in W.""" @@ -215,13 +270,13 @@ def get_segment_in_universe_by_type(self, segment_type: FixtureChannelType) -> S return tuple((self._segment_map[segment_type] + self.start_index).tolist()) def _generate_fixture_channels( - self, + self, fixture_template: OflFixture ) -> tuple[list[FixtureChannel], dict[FixtureChannelType, NDArray[np.int_]], ColorSupport]: segment_map: dict[FixtureChannelType, list[int]] = defaultdict(list) fixture_channels: list[FixtureChannel] = [] def append_channel(cn: str, i: int) -> None: - channel = FixtureChannel(cn) + channel = FixtureChannel(cn, fixture_template) fixture_channels.append(channel) for channel_type in channel.type_as_list: segment_map[channel_type].append(i) diff --git a/src/model/ofl/ofl_fixture.py b/src/model/ofl/ofl_fixture.py index 45817930..f3d634aa 100644 --- a/src/model/ofl/ofl_fixture.py +++ b/src/model/ofl/ofl_fixture.py @@ -3,11 +3,15 @@ # ruff: noqa: N815 from __future__ import annotations +from enum import Enum from logging import getLogger -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, ConfigDict +from model.color_hsi import ColorHSI +from model.ofl.color_name_dict import get_color_by_name + logger = getLogger(__name__) @@ -151,6 +155,164 @@ def resolve_list(obj: list | str, prefix: str = "") -> None: return repetition_list +class CapabilityType(Enum): + """Defines the capability type as used by OFL.""" + + NO_FUNCTION = "NoFunction" + GENERIC = "Generic" + SHUTTER_STROBE = "ShutterStrobe" + STROBE_SPEED = "StrobeSpeed" + STROBE_DURATION = "StrobeDuration" + INTENSITY = "Intensity" + COLOR_INTENSITY = "ColorIntensity" + COLOR_PRESET = "ColorPreset" + COLOR_TEMPERATURE = "ColorTemperature" + PAN = "Pan" + PAN_CONTINUOUS = "PanContinuous" + TILT = "Tilt" + TILT_CONTINUOUS = "TiltContinuous" + PAN_TILT_SPEED = "PanTiltSpeed" + WHEEL_SLOT = "WheelSlot" + WHEEL_SHAKE = "WheelShake" + WHEEL_SLOT_ROTATION = "WheelSlotRotation" + WHEEL_ROTATION = "WheelRotation" + EFFECT = "Effect" + EFFECT_SPEED = "EffectSpeed" + EFFECT_DURATION = "EffectDuration" + EFFECT_PARAMETER ="EffectParameter" + SOUND_SENSITIVITY = "SoundSensitivity" + BEAM_ANGLE = "BeamAngle" + BEAM_POSITION = "BeamPosition" + FOCUS = "Focus" + ZOOM = "Zoom" + IRIS = "Iris" + IRIS_EFFECT = "IrisEffect" + FROST = "Frost" + FROST_EFFECT = "FrostEffect" + PRISM = "Prism" + PRISM_ROTATION = "PrismRotation" + BLADE_INSERTION = "BladeInsertion" + BLADE_ROTATION = "BladeRotation" + BLADE_SYSTEM_ROTATION = "BladeSystemRotation" + FOG = "Fog" + FOG_OUTPUT = "FogOutput" + FOG_TYPE = "FogType" + ROTATION = "Rotation" + SPEED = "Speed" + TIME = "Time" + MAINTENANCE = "Maintenance" + + +class Capability(BaseModel): + """Capability of a channel.""" + + dmxRange: tuple[int, int] = (0, 0) + """Defines the range in which this capability is active.""" + + type: CapabilityType = CapabilityType.GENERIC + """Capability type.""" + + comment: str = "" + """Description of the capability if not obvious.""" + + capabilityProperties: dict[str, Any] = {} + """Contains the properties of the capability.""" + + def __init__(self, **kwargs: dict[str, Any]) -> None: + """Overrides default constructor and populates capability settings.""" + super().__init__(**kwargs) + + if "capabilityProperties" in kwargs: + raise ValueError("Fixme: this should not be a property of the OFL JSON model.") + + # This is an ugly hack. Once we know how to deal with the fact that the capabilities are only available based + # on the specified type, we should improve this. + for k, v in kwargs.items(): + if k not in ["type", "comment", "dmxRange"]: + self.capabilityProperties[k] = v + + +class ChannelTemplate(BaseModel): + """Capability templates of channel.""" + + fineChannelAliases: list[str] | None = None + """Channels matching this name will be associated as a file channel for this template.""" + + capabilities: list[Capability] = [] + """The capabilities of this channel.""" + + capability: Capability | None = None + """If this channel has only a single capability, this will be set and capabilities left empty""" + + switchChannels: dict[str, str] = {} + """Defines possible functionality switches based on the value of other channels.""" + + defaultValue: int | str = 0 + """The default DMX value that should be output. If a string is found, if is most likely a number followed by %.""" + + dmxValueResolution: str = "8bit" + """Defines the resolution of the channel. This might be 8bit 16bit or 24bit.""" + + def get_capabilities(self) -> list[Capability]: + """A unified method to access the capabilities.""" + if len(self.capabilities) > 0: + return self.capabilities + return [self.capability] if self.capability is not None else [] + + +class WheelSlotType(Enum): + """The type of wheel slot.""" + + GENERIC = "" + OPEN = "Open" + COLOR = "Color" + GOBO = "Gobo" + ANIMATED_GOBO_START = "AnimationGoboStart" + ANIMATED_GOBO_END = "AnimationGoboEnd" + IRIS = "Iris" + PRISM = "Prism" + FROST = "Frost" + CLOSED = "Closed" + + +class WheelSlot(BaseModel): + """Defines a rotatable wheel slot.""" + + type: WheelSlotType = WheelSlotType.GENERIC + """The type of wheel slot.""" + + name: str = "" + + colorTemperature: str = "" + + resource: dict[str, Any] = {} + """Contains the gobo image, if any.""" + + colors: list[str] = [] + """Some fixtures contain a colors array instead of a name.""" + + @property + def resulting_color(self) -> ColorHSI: + """Returns the color of the wheel slot.""" + # query color parameters and use name as last resort + if self.type == WheelSlotType.OPEN: + return get_color_by_name("white") + if self.type == WheelSlotType.CLOSED: + return get_color_by_name("black") + if self.colorTemperature != "": + return ColorHSI.from_color_temperature(self.colorTemperature) + if len(self.colors) > 0: + return get_color_by_name(self.colors[0]) + return get_color_by_name(self.name) + + +class Wheel(BaseModel): + """Defines a rotatable wheel.""" + + slots: list[WheelSlot] = [] + """The slots of the wheel.""" + + class OflFixture(BaseModel): """Complete fixture definition conforming to the Open Fixture Library schema.""" @@ -201,4 +363,10 @@ class OflFixture(BaseModel): Extended """ + availableChannels: dict[str, ChannelTemplate] = {} + """Contains the capability mappings of the channels.""" + + wheels: dict[str, Wheel] = {} + """Containts the rotatable wheels""" + model_config = ConfigDict(frozen=True) diff --git a/src/model/patching/fixture_channel.py b/src/model/patching/fixture_channel.py index 0d359621..f6376a73 100644 --- a/src/model/patching/fixture_channel.py +++ b/src/model/patching/fixture_channel.py @@ -1,11 +1,18 @@ """Channels of a Fixture.""" +from __future__ import annotations + from enum import IntFlag from logging import getLogger -from typing import Final +from typing import TYPE_CHECKING, Final from PySide6 import QtCore +from model.ofl.ofl_fixture import CapabilityType + +if TYPE_CHECKING: + from model.ofl.ofl_fixture import ChannelTemplate, OflFixture + logger = getLogger(__name__) @@ -25,6 +32,7 @@ class FixtureChannelType(IntFlag): TILT = 256 ROTATION = 512 SPEED = 1024 + COLORWHEEL = 2048 # TODO vielleicht als enum @@ -33,10 +41,11 @@ class FixtureChannel: updated: QtCore.Signal(int) = QtCore.Signal(int) - def __init__(self, name: str) -> None: + def __init__(self, name: str, parent_fixture_template: OflFixture) -> None: """Initialize a fixture channel.""" self._name: Final[str] = name - self._type: Final[FixtureChannelType] = self._get_channel_type_from_string() + self._channel_template: Final[ChannelTemplate | None] = parent_fixture_template.availableChannels.get(self.name) + self._type: Final[FixtureChannelType] = self._get_channel_type_from_template_or_string() self._ignore_black = True @property @@ -59,17 +68,55 @@ def ignore_black(self) -> bool: """Ignore this channel blackout.""" return self._ignore_black + @property + def channel_template(self) -> ChannelTemplate | None: + """Returns the channel template.""" + return self._channel_template + @ignore_black.setter def ignore_black(self, ignore_black: bool) -> None: self._ignore_black = ignore_black - def _get_channel_type_from_string(self) -> FixtureChannelType: + def _get_channel_type_from_template_or_string(self) -> FixtureChannelType: """Returns the channel type.""" types: FixtureChannelType = FixtureChannelType.UNDEFINED - # TODO vielleicht aus OFL sauber extrahieren + + name = self._name.lower() + + if self._channel_template: + for capability in self._channel_template.get_capabilities(): + match capability.type: + case CapabilityType.COLOR_INTENSITY: + if "red" in name: + types |= FixtureChannelType.RED + elif "green" in name: + types |= FixtureChannelType.GREEN + elif "blue" in name: + types |= FixtureChannelType.BLUE + elif "white" in name: + types |= FixtureChannelType.WHITE + elif "amber" in name: + types |= FixtureChannelType.AMBER + elif "uv" in name: + types |= FixtureChannelType.UV + case CapabilityType.PAN: + types |= FixtureChannelType.PAN + case CapabilityType.TILT: + types |= FixtureChannelType.TILT + case CapabilityType.ROTATION: + types |= FixtureChannelType.ROTATION + case CapabilityType.SPEED | CapabilityType.EFFECT_SPEED | CapabilityType.STROBE_SPEED | \ + CapabilityType.PAN_TILT_SPEED: + types |= FixtureChannelType.SPEED + case CapabilityType.WHEEL_SLOT: + if "color" in name: + types |= FixtureChannelType.COLORWHEEL + case _: + continue + return types + for channel_type in FixtureChannelType: - name = self._name - if str(channel_type.name).lower() in name.lower(): + if str(channel_type.name).lower() in name: types &= channel_type if channel_type in (FixtureChannelType.PAN, FixtureChannelType.TILT): types &= FixtureChannelType.POSITION diff --git a/src/model/scene.py b/src/model/scene.py index b49ce750..93ebeee2 100644 --- a/src/model/scene.py +++ b/src/model/scene.py @@ -2,7 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, NamedTuple, override + +from PySide6.QtCore import QObject, Signal + +from .universe import Universe if TYPE_CHECKING: from .board_configuration import BoardConfiguration @@ -62,11 +66,22 @@ def copy(self, new_scene: Scene = None) -> FilterPage: return new_fp -class Scene: +class DmxDefaultValue(NamedTuple): + """Contains a single default entry to be applied on scene activation by fish.""" + + universe_id: int + channel: int + value: int + + +class Scene(QObject): """Scene for a show file.""" + default_values_changed = Signal() + def __init__(self, scene_id: int, human_readable_name: str, board_configuration: BoardConfiguration) -> None: """Scene for a show file.""" + super().__init__() self._scene_id: int = scene_id self._human_readable_name: str = human_readable_name self._board_configuration: BoardConfiguration = board_configuration @@ -75,6 +90,7 @@ def __init__(self, scene_id: int, human_readable_name: str, board_configuration: self._filter_pages: list[FilterPage] = [] self._associated_bankset: BankSet | None = None self._ui_pages: list[UIPage] = [] + self._dmx_default_values: list[DmxDefaultValue] = [] @property def scene_id(self) -> int: @@ -116,6 +132,58 @@ def pages(self) -> list[FilterPage]: self._filter_pages.append(default_page) return self._filter_pages + @property + def dmx_default_values(self) -> list[DmxDefaultValue]: + """Get the list of default values to be applied on scene switch.""" + return self._dmx_default_values.copy() + + def insert_dmx_default_value(self, universe: Universe | int, channel: int, value: int, + supress_emission: bool = False) -> bool: + """Add a new default value to the scene. + + Existing values will be updated. + + Args: + universe: target universe or its ID. + channel: target channel. + value: value to set on scene entry. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. + + Returns: + True if a value was updated and false if it was added. + + """ + universe_id = universe.id if isinstance(universe, Universe) else universe + value_to_remove = None + for existing_value in self._dmx_default_values: + if existing_value.universe_id == universe_id and existing_value.channel == channel: + value_to_remove = existing_value + if value_to_remove is not None: + self._dmx_default_values.remove(value_to_remove) + self._dmx_default_values.append(DmxDefaultValue(universe_id, channel, value)) + if not supress_emission: + self.default_values_changed.emit() + return value_to_remove is not None + + def remove_dmx_default_value(self, universe: Universe | int, channel: int, supress_emission: bool = False) -> None: + """Remove a default DMX value from the scene. + + Args: + universe: target universe or its ID. + channel: target channel. + supress_emission: If this is enabled to change signal will be enabled. Only use this if you're certain + you're taking care of all updates yourself. + + """ + universe_id = universe.id if isinstance(universe, Universe) else universe + values_to_remove = [val for val in self._dmx_default_values if + val.universe_id == universe_id and val.channel == channel] + for item in values_to_remove: + self._dmx_default_values.remove(item) + if len(values_to_remove) > 0 and not supress_emission: + self.default_values_changed.emit() + def insert_filterpage(self, fp: FilterPage) -> None: """Add a filterpage to the scene.""" self._filter_pages.append(fp) @@ -184,6 +252,8 @@ def copy(self, existing_scenes: list[Scene]) -> Scene: scene.linked_bankset = self._associated_bankset.copy() for page in self._ui_pages: scene._ui_pages.append(page.copy(scene)) + for ddv in self._dmx_default_values: + scene._dmx_default_values.append(ddv) return scene def get_filter_by_id(self, fid: str) -> Filter | None: @@ -266,3 +336,12 @@ def notify_about_filter_rename_action(self, sender: Filter, old_id: str) -> None for page in self._ui_pages: for widget in page.widgets: widget.notify_id_rename(old_id, sender.filter_id) + + def sort_dmx_default_values(self) -> None: + """Sorts the dmx defaults by their universe and channel. + + This improves human interaction and performance of fish. + + """ + self._dmx_default_values.sort(key=lambda x: (x.universe_id * 512) + x.channel) + self.default_values_changed.emit() diff --git a/src/model/virtual_filters/color_to_colorwheel.py b/src/model/virtual_filters/color_to_colorwheel.py new file mode 100644 index 00000000..0d314687 --- /dev/null +++ b/src/model/virtual_filters/color_to_colorwheel.py @@ -0,0 +1,182 @@ +"""Contains ColorToColorwheel vFilter.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, override + +from jinja2 import Environment + +from model.filter import Filter, FilterTypeEnumeration, VirtualFilter +from utility import resource_path + +if TYPE_CHECKING: + from jinja2.environment import Template + + from model import Scene + from model.ofl.fixture import UsedFixture + +with open(resource_path(os.path.join("resources", "data", "color-to-colorwheel-template.lua.j2")), "r") as f: + _FILTER_CONTENT_TEMPLATE: Template = Environment().from_string(f.read()) # NOQA: S701 the editor is not a web page. + + +def extract_colorwheel_mappings_from_fixture(f: UsedFixture, selected_slot_index: int = 0) -> str: + """Get a color mapping from the provided fixture. + + Args: + f: The fixture to extract the filter configuration from. + selected_slot_index: Which color wheel of the fixture should be used? + + Returns: + A string usable as the color-mappings property of the filter configuration. + + """ + if f is None: + return "" + color_mappings: list[tuple[float, float, int]] = [] + template_colorwheel_mappings = f.colorwheel_mappings + if len(template_colorwheel_mappings) < 1: + return "" + template_colorwheel_mappings = template_colorwheel_mappings[selected_slot_index] + mappings = template_colorwheel_mappings[1] + for selection_value, color1, color2 in mappings: + if color2 is not None: + # We have a value that is in the middle between two slots. + continue + resulting_color = color1.resulting_color + color_mappings.append((resulting_color.hue, resulting_color.saturation, selection_value)) + return ";".join([f"{hue}:{saturation}:{slot}" for hue, saturation, slot in color_mappings]) + + +class ColorToColorWheel(VirtualFilter): + """A vFilter that takes a color channel as an input and maps it to a color wheel channel.""" + + def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) -> None: + """Initialize the vFilter. + + The following filter configuration properties are available: + - mode: Either automatic (the filter loads its color points from the fixture) or manual. + - fixture-uuid: The fixture UUID to use in automatic mode. + - color-mappings: The color mappings to use in manual mode. Format: h:s:cw;h:s:cw;... + - dimmer-input: Should a dimmer input be used? Can either be "8bit", "16bit", "float" or "". + - dimmer-output: The data type of the dimmer output channel. Can either be "8bit", "16bit", "float" or "". + - colorwheel-datatype: The data type of the color wheel slot. Can either be "8bit" or "16bit". + - wheel_speed: Time in ms to switch between two adjacent wheel slots in manual mode. + - dim_when_off: If true, the brightness will be suspended as long as the wrong color wheel slot is dialed in. + - colorwheel-id: If in automatic mode, specifies which color wheel to use. + + The following ports are provided: + - input: The color input channel (input, color) + - in_dimmer: Optional, Input dimmer mixin (input, 8bit or 16bit or float) + - dimmer: Output dimmer channel (output, 8bit or 16bit or float) + - colorwheel: The color wheel slot to use (output, 8bit or 16bit) + + Args: + scene: The scene to use for this filter. + filter_id: The id of the filter to use. + pos: Optional, The position of the filter to use. + + """ + super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, pos=pos) + if "mode" not in self._filter_configurations: + self.filter_configurations["mode"] = "automatic" + if "fixture-uuid" not in self._filter_configurations: + self.filter_configurations["fixture-uuid"] = "" + if "color-mappings" not in self._filter_configurations: + self.filter_configurations["color-mappings"] = "" + if "dimmer-input" not in self._filter_configurations: + self.filter_configurations["dimmer-input"] = "8bit" + if "dimmer-output" not in self._filter_configurations: + self.filter_configurations["dimmer-output"] = "" + if "colorwheel-datatype" not in self._filter_configurations: + self.filter_configurations["colorwheel-datatype"] = "8bit" + if "wheel_speed" not in self.filter_configurations: + self.filter_configurations["wheel_speed"] = "300" + if "dim_when_off" not in self.filter_configurations: + self.filter_configurations["dim_when_off"] = "true" + if "colorwheel-id" not in self.filter_configurations: + self.filter_configurations["colorwheel-id"] = "0" + + @override + def resolve_output_port_id(self, virtual_port_id: str) -> str | None: + if virtual_port_id not in ["dimmer", "colorwheel"]: + raise ValueError(f"Invalid virtual port ID. Filter ID: {self.filter_id}, Requested Port: {virtual_port_id}") + return f"{self.filter_id}:{virtual_port_id}" + + def _get_fixture(self) -> UsedFixture: + fixture_id = self.filter_configurations["fixture-uuid"] + if fixture_id == "": + raise ValueError(f"Fixture UUID cannot be empty. Filter-ID: {self.filter_id}") + f = self.scene.board_configuration.get_fixture(fixture_id) + if f is None: + raise ValueError(f"Fixture with UUID {fixture_id} not found in showfile. Filter-ID: {self.filter_id}") + return f + + @override + def instantiate_filters(self, filter_list: list[Filter]) -> None: + if self.filter_configurations["mode"] == "automatic": + color_mapping_string = extract_colorwheel_mappings_from_fixture( + self._get_fixture(), selected_slot_index=int(self.filter_configurations["colorwheel-id"]) + ) + else: + color_mapping_string = self.filter_configurations.get("color-mappings", "") + input_dimmer_channel = self.channel_links.get("in_dimmer", "") + hue_values: list[float] = [] + saturation_values: list[float] = [] + slots: list[int] = [] + required_dimmer_output_data_type = self.filter_configurations["dimmer-output"] + dimmer_input_data_type = self.filter_configurations["dimmer-input"] + + colorwheel_is_16bit = self.filter_configurations["colorwheel-datatype"] == "16bit" + + input_dimmer_configured = input_dimmer_channel != "" + dimmer_output_required = required_dimmer_output_data_type != "" + wheel_speed = self.filter_configurations.get("wheel_speed", "300") + + for mapping_str in color_mapping_string.split(";"): + hue, saturation, slot = mapping_str.split(":") + hue_values.append(float(hue)) + saturation_values.append(float(saturation)) + slots.append(int(slot)) + + match required_dimmer_output_data_type: + case "8bit": + output_dimmer_multiplier = "255" + case "16bit": + output_dimmer_multiplier = "65535" + case _: + output_dimmer_multiplier = "1" + + match dimmer_input_data_type: + case "8bit": + input_dimmer_multiplier = "255" + case "16bit": + input_dimmer_multiplier = "65535" + case _: + input_dimmer_multiplier = "1" + + script = _FILTER_CONTENT_TEMPLATE.render({ + "input_dimmer_channel_connected": input_dimmer_configured, + "input_dimmer_channel_data_type": dimmer_input_data_type, + "input_dimmer_multiplier": input_dimmer_multiplier, + "hue_values": hue_values, + "saturation_values": saturation_values, + "slots": slots, + "wheel_speed": wheel_speed, + "dimmer_output_required": dimmer_output_required, + "output_dimmer_data_type": required_dimmer_output_data_type, + "output_dimmer_multiplier": output_dimmer_multiplier, + "dim_when_off": self.filter_configurations.get("dim_when_off", "true") == "true", + "colorwheel_datatype": self.filter_configurations.get("colorwheel-datatype", "8bit"), + }) + + f = Filter(self.scene, self.filter_id, FilterTypeEnumeration.FILTER_SCRIPTING_LUA, pos=self.pos) + f.initial_parameters["script"] = script + f.filter_configurations["in_mapping"] = "input:color;time:float" + if input_dimmer_configured: + f.filter_configurations["in_mapping"] += f";in_dimmer:{dimmer_input_data_type}" + f.filter_configurations["out_mapping"] = f"colorwheel:{"16bit" if colorwheel_is_16bit else "8bit"}" + if dimmer_output_required: + f.filter_configurations["out_mapping"] += f";dimmer:{required_dimmer_output_data_type}" + f.channel_links.update(self.channel_links) + filter_list.append(f) diff --git a/src/model/virtual_filters/effects_stacks/vfilter.py b/src/model/virtual_filters/effects_stacks/vfilter.py index 831fedb1..a7086a5b 100644 --- a/src/model/virtual_filters/effects_stacks/vfilter.py +++ b/src/model/virtual_filters/effects_stacks/vfilter.py @@ -1,6 +1,7 @@ -"""This file provides the v-filter implementation of the effects stack system""" +"""Provides the v-filter implementation of the effects stack system.""" from logging import getLogger +from typing import override from model import Filter, Scene from model.filter import FilterTypeEnumeration, VirtualFilter @@ -14,18 +15,24 @@ class EffectsStack(VirtualFilter): - """The v-filter providing the effects stack. This filter provides a system enabling one to assign stackable effects - to fixtures, groups of fixtures or configurable output ports.""" + """The v-filter providing the effects stack. + + This filter provides a system enabling one to assign stackable effects + to fixtures, groups of fixtures or configurable output ports. + """ def __init__(self, scene: Scene, filter_id: str, pos: tuple[int] | None = None) -> None: + """Initialize vFilter.""" super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_EFFECTSSTACK, pos=pos) self.sockets: list[EffectsSocket] = [] self.deserialize() + @override def resolve_output_port_id(self, virtual_port_id: str) -> str | None: # We only need to resolve ports for explicitly configured outputs pass + @override def instantiate_filters(self, filter_list: list[Filter]) -> None: for socket in self.sockets: socket_target = socket.target @@ -112,12 +119,10 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: ("b", socket_target.get_segment_in_universe_by_type(FixtureChannelType.BLUE)), ("w", socket_target.get_segment_in_universe_by_type(FixtureChannelType.WHITE)), ("a", socket_target.get_segment_in_universe_by_type(FixtureChannelType.AMBER))]: - i = 0 - for segment in segment_list: + for i, segment in enumerate(segment_list): universe_filter.filter_configurations[str(segment)] = str(segment) universe_filter.channel_links[str(segment)] = \ f"{adapter_filters[i % len(adapter_filters)].filter_id}:{segment_channel_name}" - i += 1 else: for segment_list in [socket_target.get_segment_in_universe_by_type(FixtureChannelType.RED), socket_target.get_segment_in_universe_by_type(FixtureChannelType.GREEN), @@ -144,6 +149,7 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: constant_filter.initial_parameters["value"] = "0.0" filter_list.append(constant_filter) + @override def serialize(self) -> None: d = self.filter_configurations d.clear() @@ -152,6 +158,7 @@ def serialize(self) -> None: # TODO Encode start addresses in case of group or use uuid of fixture d[name] = s.serialize() + @override def deserialize(self) -> None: self.sockets.clear() for k, v in self._filter_configurations.items(): diff --git a/src/model/virtual_filters/range_adapters.py b/src/model/virtual_filters/range_adapters.py index e92793ea..1575c340 100644 --- a/src/model/virtual_filters/range_adapters.py +++ b/src/model/virtual_filters/range_adapters.py @@ -115,6 +115,219 @@ def instantiate_filters(self, filter_list: list[Filter]) -> None: filter_list.append(filter_) +class DimmerGlobalBrightnessMixinVFilter(VirtualFilter): + """V-Filter that allows brightness mixin for 8bit and 16bit values. + + The filter allows the configuration of an input and a mixin input channel. + Their defaults are the global brightness and a constant 1. + If they're connected their input data typed can both be configured as either 8bit or 16bit. + The optional offset input channel needs to be a float. Reasonable values range from (-1, 1). + + The outputs can be configured as 8bit or 16bit. + """ + + def __init__(self, scene: Scene, filter_id: str, pos: tuple[int, int] | None = None) -> None: + """Instantiate a new dimmer brightness mixin vfilter.""" + super().__init__(scene, filter_id, FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, pos=pos) + self._configuration_supported = True + self.filter_configurations.setdefault("has_16bit_output", "true") + self.filter_configurations.setdefault("has_8bit_output", "true") + self.filter_configurations.setdefault("input_method", "8bit") + self.filter_configurations.setdefault("input_method_mixin", "8bit") + self._out_data_types["dimmer_out8b"] = DataType.DT_8_BIT + self._out_data_types["dimmer_out16b"] = DataType.DT_16_BIT + self._in_data_types["offset"] = DataType.DT_DOUBLE + self.deserialize() + + @override + def resolve_output_port_id(self, virtual_port_id: str) -> str | None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + if virtual_port_id == "dimmer_out8b": + if out_8b and out_16b: + return f"{self.filter_id}_16b_downsampler:value_upper" + if out_8b: + return f"{self._filter_id}_8b_range:value" + raise ValueError(f"Requested 8bit output port but 8bit output is disabled. Filter ID: {self.filter_id}") + if virtual_port_id == "dimmer_out16b": + if out_16b: + return f"{self._filter_id}_16b_range:value" + raise ValueError(f"Requested 16bit output port but 16bit output is disabled. Filter ID: {self.filter_id}") + raise ValueError("Unknown output port") + + @override + def instantiate_filters(self, filter_list: list[Filter]) -> None: + out_16b = self.filter_configurations.get("has_16bit_output") == "true" + out_8b = self.filter_configurations.get("has_8bit_output") == "true" + needs_8bit_input = self.filter_configurations["input_method"] == "8bit" + required_mixin_input_method = 1 if self.filter_configurations["input_method_mixin"] == "8bit" else 2 + needs_global_brightness_input = self.channel_links.get("input") is None + needs_const_mixin = self.channel_links.get("mixin") is None + needs_offset = self.channel_links.get("offset") is None + + if needs_const_mixin and (not needs_global_brightness_input or not needs_offset): + const_mixin_filter = Filter( + self.scene, + f"{self.filter_id}_const_mixin", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + const_mixin_filter.initial_parameters["value"] = "1.0" + filter_list.append(const_mixin_filter) + mixin_port_name = f"{self.filter_id}_const_mixin:value" + required_mixin_input_method = 0 + elif needs_const_mixin: + required_mixin_input_method = 0 + mixin_port_name = None + else: + mixin_port_name = self.channel_links.get("mixin") + + if needs_global_brightness_input: + global_brightness_filter = Filter( + self.scene, + f"{self.filter_id}_global_brightness_input", + FilterTypeEnumeration.FILTER_TYPE_MAIN_BRIGHTNESS, + pos=self.pos + ) + filter_list.append(global_brightness_filter) + input_port_name = f"{self.filter_id}_global_brightness_input:brightness" + needs_8bit_input = False + else: + input_port_name = self.channel_links.get("input") + + if needs_8bit_input: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, input_port_name) + input_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + else: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, input_port_name) + input_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if required_mixin_input_method == 1: + range_8b_to_float_filter = self._generate_8b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_8b_to_float_filter.resolve_output_port_id("value") + elif required_mixin_input_method == 2: + range_16b_to_float_filter = self._generate_16b_to_float_range(filter_list, mixin_port_name) + mixin_port_name = range_16b_to_float_filter.resolve_output_port_id("value") + + if not (needs_global_brightness_input and needs_const_mixin and needs_offset): + if needs_offset: + offset_filter = Filter( + self.scene, + f"{self.filter_id}_offset", + FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=self.pos + ) + offset_filter.initial_parameters["value"] = "0.0" + filter_list.append(offset_filter) + offset_input_port = offset_filter.filter_id + ":value" + else: + offset_input_port = self.channel_links.get("offset") + mac_filter = Filter( + self.scene, + f"{self.filter_id}_mac", + FilterTypeEnumeration.FILTER_ARITHMETICS_MAC, + pos=self.pos + ) + mac_filter.channel_links["factor1"] = input_port_name + mac_filter.channel_links["factor2"] = mixin_port_name + mac_filter.channel_links["summand"] = offset_input_port + filter_list.append(mac_filter) + input_port_name = mac_filter.filter_id + ":value" + + if out_16b: + range16b_out_filter = Filter( + self.scene, + f"{self._filter_id}_16b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "65565", + "limit_range": "1" + } + ) + range16b_out_filter.channel_links["value_in"] = input_port_name + filter_list.append(range16b_out_filter) + if out_8b: + downsampling_filter = Filter( + self.scene, + f"{self._filter_id}_16b_downsampler", + FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=self.pos + ) + downsampling_filter.channel_links["value"] = f"{range16b_out_filter.filter_id}:value" + filter_list.append(downsampling_filter) + else: + range8b_out_filter = Filter( + self.scene, + f"{self._filter_id}_8b_range", + FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, + pos=self.pos, + filter_configurations={ + "lower_bound_in": "0.0", + "upper_bound_in": "1.0", + "lower_bound_out": "0", + "upper_bound_out": "255", + "limit_range": "1" + } + ) + range8b_out_filter.channel_links["value_in"] = input_port_name + filter_list.append(range8b_out_filter) + + def _generate_16b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> SixteenBitToFloatRange: + range_16b_to_float_filter = SixteenBitToFloatRange( + self.scene, + f"{self.filter_id}_16bit_to_float", + pos=self.pos + ) + range_16b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "65565", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_16b_to_float_filter.channel_links["value_in"] = input_port_name + range_16b_to_float_filter.instantiate_filters(filter_list) + return range_16b_to_float_filter + + def _generate_8b_to_float_range(self, filter_list: list[Filter], input_port_name: str) -> EightBitToFloatRange: + range_8b_to_float_filter = EightBitToFloatRange( + self.scene, + f"{self.filter_id}_8bit_to_float", + pos=self.pos + ) + range_8b_to_float_filter.filter_configurations.update({ + "lower_bound_in": "0", + "upper_bound_in": "255", + "lower_bound_out": "0.0", + "upper_bound_out": "1.0", + "limit_range": "1" + }) + range_8b_to_float_filter.channel_links["value_in"] = input_port_name + range_8b_to_float_filter.instantiate_filters(filter_list) + return range_8b_to_float_filter + + @override + def deserialize(self) -> None: + if self.filter_configurations.get("has_8bit_output") is None: + self.filter_configurations["has_8bit_output"] = "true" + if self.filter_configurations.get("has_16bit_output") is None: + self.filter_configurations["has_16bit_output"] = "false" + if self.filter_configurations.get("input_method") is None: + self.filter_configurations["input_method"] = "16bit" + if self.filter_configurations.get("input_method") == "8bit": + self._in_data_types["input"] = DataType.DT_8_BIT + else: + self._in_data_types["input"] = DataType.DT_16_BIT + if self.filter_configurations.get("input_method_mixin") == "8bit": + self._in_data_types["mixin"] = DataType.DT_8_BIT + else: + self._in_data_types["mixin"] = DataType.DT_16_BIT + + class ColorGlobalBrightnessMixinVFilter(VirtualFilter): """V-Filter that provides the global brightness property.""" diff --git a/src/model/virtual_filters/vfilter_factory.py b/src/model/virtual_filters/vfilter_factory.py index b8494eb0..41ca20c5 100644 --- a/src/model/virtual_filters/vfilter_factory.py +++ b/src/model/virtual_filters/vfilter_factory.py @@ -11,12 +11,14 @@ from model.filter import FilterTypeEnumeration from model.virtual_filters.auto_tracker_filter import AutoTrackerFilter from model.virtual_filters.color_mixer_vfilter import ColorMixerVFilter +from model.virtual_filters.color_to_colorwheel import ColorToColorWheel from model.virtual_filters.cue_vfilter import CueFilter from model.virtual_filters.effects_stacks.vfilter import EffectsStack from model.virtual_filters.import_vfilter import ImportVFilter from model.virtual_filters.pan_tilt_constant import PanTiltConstantFilter from model.virtual_filters.range_adapters import ( ColorGlobalBrightnessMixinVFilter, + DimmerGlobalBrightnessMixinVFilter, EightBitToFloatRange, SixteenBitToFloatRange, ) @@ -51,11 +53,11 @@ def construct_virtual_filter_instance( return None case FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: return PanTiltConstantFilter(scene, filter_id, pos=pos) - + case FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerGlobalBrightnessMixinVFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_CUES: return CueFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_EFFECTSSTACK: - # TODO implement effects stack virtual filter (as described in issue #87) return EffectsStack(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_AUTOTRACKER: return AutoTrackerFilter(scene, filter_id, pos=pos) @@ -76,5 +78,7 @@ def construct_virtual_filter_instance( return ColorMixerVFilter(scene, filter_id, pos=pos) case FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerFilter(scene, filter_id, pos=pos) + case FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL: + return ColorToColorWheel(scene, filter_id, pos=pos) case _: raise ValueError(f"The requested filter type {filter_type} is not yet implemented.") diff --git a/src/resources/data/color-to-colorwheel-template.lua.j2 b/src/resources/data/color-to-colorwheel-template.lua.j2 new file mode 100644 index 00000000..a476bd88 --- /dev/null +++ b/src/resources/data/color-to-colorwheel-template.lua.j2 @@ -0,0 +1,71 @@ +----------------- Parameters --------------------- +wheel_slots_hue = {% raw %}{{% endraw %}{% for hv in hue_values %}{{ hv }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} +wheel_slots_saturation = {% raw %}{{% endraw %}{% for sv in saturation_values %}{{ sv }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} +wheel_values = {% raw %}{{% endraw %}{% for slot in slots %}{{ slot }}{{ ", " if not loop.last }}{% endfor %}{% raw %}}{% endraw %} +------------ End of Parameters --------------- + +------------ Required Ports ------------------- +-- input : color in +-- time : float in +{% if input_dimmer_channel_connected %} +-- in_dimmer : {{ input_dimmer_channel_data_type }} in +{% endif %} +-- dimmer : {{ output_dimmer_data_type }} out +-- colorwheel : {{ colorwheel_datatype }} out +----------------------------------------------- + +current_wheel_pos = -1 +last_change_time = 0 + +-- Convert colors to cartesian coordinates in order to save redoing it every time +for i = 1, #wheel_slots_hue do + h = math.rad(wheel_slots_hue[i]) + s = wheel_slots_saturation[i] + wheel_slots_hue[i] = s * math.cos(h) + wheel_slots_saturation[i] = s * math.sin(h) +end + +function get_target_position(hue, saturation) + best_diff = 361 + best_position = 0 + target_x = saturation * math.cos(hue) + target_y = saturation * math.sin(hue) + for i = 1, #wheel_slots_hue do + diff = math.sqrt(math.pow(target_x - wheel_slots_hue[i], 2) + math.pow(target_y - wheel_slots_saturation[i], 2)) + if diff < best_diff then + best_diff = diff + best_position = i + end + end + return best_position +end + +function update() + target_position = get_target_position(math.rad(input["h"]), input["s"]) + {% if dim_when_off %} + if current_wheel_pos ~= target_position then + dimmer = 0 + else + dimmer = {% if output_dimmer_multiplier != "1" %}{{ output_dimmer_multiplier }} * {% endif %}input["i"]{% if input_dimmer_channel_connected %} * (in_dimmer{% if input_dimmer_multiplier != "1" %} / {{ input_dimmer_multiplier }}{% endif %}){% endif %} + end + {% else %} + dimmer = {% if output_dimmer_multiplier != "1" %}{{ output_dimmer_multiplier }} * {% endif %}input["i"]{% if input_dimmer_channel_connected %} * (in_dimmer{% if input_dimmer_multiplier != "1" %} / {{ input_dimmer_multiplier }}{% endif %}){% endif %} + {% endif %} + if last_change_time + {{ wheel_speed }} < time then + if current_wheel_pos < target_position then + current_wheel_pos = current_wheel_pos + 1 + end + if current_wheel_pos > target_position then + current_wheel_pos = current_wheel_pos - 1 + end + last_change_time = time + end + colorwheel = wheel_values[target_position] +end + +function scene_activated() + current_wheel_pos = -1 + if time ~= nil then + last_change_time = time + end +end \ No newline at end of file diff --git a/src/resources/data/colornames.csv b/src/resources/data/colornames.csv new file mode 100644 index 00000000..51c6af85 --- /dev/null +++ b/src/resources/data/colornames.csv @@ -0,0 +1,9 @@ +red;0.0;1.0;1.0 +green;120.0;1.0;1.0 +blue;240.0;1.0;1.0 +white;0.0;0.0;1.0 +black;0.0;0.0;0.0 +dark blue;240.0;1.0;0.3 +yellow;59.0;1.0;0.6 +light blue;240.0;0.5;1.0 +magenta;300.0;1.0;0.6 \ No newline at end of file diff --git a/src/view/action_setup_view/constant_update_dialog.py b/src/view/action_setup_view/constant_update_dialog.py index cbb562ea..da2102ba 100644 --- a/src/view/action_setup_view/constant_update_dialog.py +++ b/src/view/action_setup_view/constant_update_dialog.py @@ -15,7 +15,8 @@ QWidget, ) -from model import BoardConfiguration, ColorHSI +from model import BoardConfiguration +from model.color_hsi import ColorHSI from model.filter import FilterTypeEnumeration from model.macro import Macro from view.action_setup_view._command_insertion_dialog import _CommandInsertionDialog diff --git a/src/view/console_mode/console_universe_selector.py b/src/view/console_mode/console_universe_selector.py index d29cbcb4..d424f0b4 100644 --- a/src/view/console_mode/console_universe_selector.py +++ b/src/view/console_mode/console_universe_selector.py @@ -2,11 +2,12 @@ from PySide6 import QtWidgets from PySide6.QtGui import QAction, Qt -from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QMessageBox, QPushButton, QSizePolicy, QWidget from model import BoardConfiguration from model.universe import Universe from view.console_mode.console_universe_widget import DirectUniverseWidget +from view.dialogs.selection_dialog import SelectionDialog from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog @@ -32,6 +33,7 @@ def __init__(self, board_configuration: BoardConfiguration, parent: QWidget) -> initial_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.addTab(initial_label, "") self._initial_tab_present: bool = True + self._dialog: SelectionDialog | None = None def add_universe(self, universe: Universe) -> None: """Add a new Universe to universe Selector. @@ -54,6 +56,12 @@ def add_universe(self, universe: Universe) -> None: automap_button.setToolTip("Would you like to automatically map all channels to bank sets?") automap_button.clicked.connect(self._automap) row_layout.addWidget(automap_button) + row_layout.addSpacing(25) + save_as_scene_default_button = QPushButton("Save as Scene default") + save_as_scene_default_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + save_as_scene_default_button.setToolTip("Save the current setup as a default for a scene.") + save_as_scene_default_button.clicked.connect(self._save_to_scene_default_clicked) + row_layout.addWidget(save_as_scene_default_button) row_layout.addStretch() layout.addLayout(row_layout) @@ -79,3 +87,25 @@ def notify_activate(self) -> None: def _automap(self) -> None: for uw in self._universe_widgets: uw.automap() + + def _save_to_scene_default_clicked(self) -> None: + if len(self._board_configuration.scenes) == 0: + self._dialog = QMessageBox(QMessageBox.Icon.Information, "No Scenes Created", + "You need to create at least one scene.") + self._dialog.show() + return + scene_list = [f"{scene.scene_id}: {scene.human_readable_name}" for scene in self._board_configuration.scenes] + self._dialog = SelectionDialog("Select Scene", "Please select the scene to apply the values to", + scene_list, self, False, + self._scene_selected_for_default_value_add) + self._dialog.show() + + def _scene_selected_for_default_value_add(self, dialog: SelectionDialog) -> None: + scene = self._board_configuration.get_scene_by_id(int(dialog.selected_items[0].split(": ", 1)[0])) + if scene is None: + return + for univ_widget in self._universe_widgets: + univ_widget.add_settings_to_scenes_default_values(scene) + scene.sort_dmx_default_values() + self._dialog.deleteLater() + self._dialog = None diff --git a/src/view/console_mode/console_universe_widget.py b/src/view/console_mode/console_universe_widget.py index 8f8c6c20..8483f220 100644 --- a/src/view/console_mode/console_universe_widget.py +++ b/src/view/console_mode/console_universe_widget.py @@ -7,6 +7,7 @@ from model.broadcaster import Broadcaster from model.control_desk import BankSet from model.ofl.fixture import UsedFixture +from model.scene import Scene from model.universe import Universe from view.console_mode.console_channel_widget import ChannelWidget @@ -112,6 +113,11 @@ def automap(self) -> None: index += 1 fixtures_per_bank = 0 + def add_settings_to_scenes_default_values(self, scene: Scene) -> None: + """Add the current universes configuration to the scenes default values.""" + for channel in self._universe.channels: + scene.insert_dmx_default_value(self._universe, channel.address, channel.value, supress_emission=True) + def _add_fixture(self, fixture: UsedFixture) -> None: if fixture.universe_id != self._universe.id: return diff --git a/src/view/dialogs/asset_mgmt_dialog.py b/src/view/dialogs/asset_mgmt_dialog.py index 5c288d02..68aba5ae 100644 --- a/src/view/dialogs/asset_mgmt_dialog.py +++ b/src/view/dialogs/asset_mgmt_dialog.py @@ -36,10 +36,16 @@ def __init__(self, parent: QWidget | None = None, show_file_path: str | None = N self._load_asset_file.setText("Load asset from file") self._load_asset_file.triggered.connect(self._open_file) self._action_button_group.addAction(self._load_asset_file) + self._delete_selected_asset_action = QAction() + self._delete_selected_asset_action.setIcon(QIcon.fromTheme("edit-delete")) + self._delete_selected_asset_action.setText("Delete selected asset") + self._delete_selected_asset_action.triggered.connect(self._delete_selected_asset) + self._delete_selected_asset_action.setEnabled(False) + self._action_button_group.addAction(self._delete_selected_asset_action) layout.addWidget(self._action_button_group) - self._asset_display = AssetSelectionWidget(self) + self._asset_display = AssetSelectionWidget(self, multiselection_allowed=True) layout.addWidget(self._asset_display) self._close_button_group = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) @@ -51,6 +57,7 @@ def __init__(self, parent: QWidget | None = None, show_file_path: str | None = N self._dialog: QFileDialog | None = None self._show_file_path = show_file_path if show_file_path is not None else "" self.setMinimumWidth(800) + self._asset_display.asset_selection_changed.connect(self._selected_asset_changed) def _open_file(self) -> None: self._dialog = QFileDialog(self, "Open file") @@ -90,3 +97,11 @@ def _process_loading_files(self, list_of_files: list[str]) -> None: detailedText=accumulated_errors) msg_box.show() self._dialog = msg_box + + def _selected_asset_changed(self) -> None: + self._delete_selected_asset_action.setEnabled(len(self._asset_display.selected_asset) > 0) + + def _delete_selected_asset(self) -> None: + for asset in self._asset_display.selected_asset: + asset.unregister() + self._asset_display.reload_model() diff --git a/src/view/dialogs/asset_selection_dialog.py b/src/view/dialogs/asset_selection_dialog.py new file mode 100644 index 00000000..21669fd8 --- /dev/null +++ b/src/view/dialogs/asset_selection_dialog.py @@ -0,0 +1,52 @@ +"""Provide a dialog for selecting assets.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QWidget +from qasync import QApplication + +from model.media_assets.asset import MediaAsset +from view.utility_widgets.asset_selection_widget import AssetSelectionWidget + +if TYPE_CHECKING: + from model.media_assets.media_type import MediaType + + +class AssetSelectionDialog(QDialog): + """A dialog for selecting assets.""" + + asset_selected: Signal = Signal(MediaAsset) + + def __init__(self, parent: QWidget | None = None, + allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = False) -> None: + """Initialize the dialog.""" + super().__init__(parent) + layout = QVBoxLayout() + + self._asset_view = AssetSelectionWidget(self, allowed_types, multiselection_allowed) + layout.addWidget(self._asset_view) + + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, Qt.Orientation.Horizontal, self + ) + layout.addWidget(button_box) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + self.setMinimumWidth(800) + self.setMinimumHeight(600) + self.setLayout(layout) + + @override + def accept(self) -> None: + self.asset_selected.emit(self._asset_view.selected_asset) + QApplication.processEvents() + super().accept() + self.close() + + @override + def reject(self) -> None: + super().reject() + self.close() diff --git a/src/view/dialogs/colum_dialog.py b/src/view/dialogs/colum_dialog.py index 5e14c6b3..c0aa282e 100644 --- a/src/view/dialogs/colum_dialog.py +++ b/src/view/dialogs/colum_dialog.py @@ -1,17 +1,18 @@ -"""modify a colum of XTouch""" +"""modify a colum of XTouch.""" from PySide6 import QtWidgets from PySide6.QtCore import Qt -from model import ColorHSI from model.broadcaster import Broadcaster +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn from view.dialogs.temperature_dialog import TemperatureDialog class ColumnDialog(QtWidgets.QDialog): - """select how to modify a colum of XTouch""" + """Select how to modify a colum of XTouch.""" def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: + """Initialize dialog for provided column.""" super().__init__(parent) self._broadcaster = Broadcaster() self.setWindowTitle(f"Change Column {column.id}") @@ -24,12 +25,12 @@ def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: # self.colorD.finished.connect(self._select_color) # color_label = QtWidgets.QLabel(column.color.format_for_filter()) color_picker = QtWidgets.QPushButton("pick Color") - color_picker.clicked.connect(lambda: self._broadcaster.view_to_color.emit()) - self.color_d.finished.connect(lambda: self._broadcaster.view_leave_color.emit()) + color_picker.clicked.connect(self._broadcaster.view_to_color.emit) + self.color_d.finished.connect(self._broadcaster.view_leave_color.emit) temperature_picker = QtWidgets.QPushButton("pick Temperature") - temperature_picker.clicked.connect(lambda: self._broadcaster.view_to_temperature.emit()) - self.temperature_d.finished.connect(lambda: self._broadcaster.view_leave_temperature.emit()) + temperature_picker.clicked.connect(self._broadcaster.view_to_temperature.emit) + self.temperature_d.finished.connect(self._broadcaster.view_leave_temperature.emit) color_layout = QtWidgets.QVBoxLayout() color_layout.addWidget(color_picker) @@ -39,7 +40,7 @@ def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: color_widget.setLayout(color_layout) button = QtWidgets.QPushButton("close") - button.clicked.connect(lambda: self._broadcaster.view_leave_colum_select.emit()) + button.clicked.connect(self._broadcaster.view_leave_colum_select.emit) layout = QtWidgets.QVBoxLayout() layout.addWidget(color_widget) layout.addWidget(button) diff --git a/src/view/dialogs/selection_dialog.py b/src/view/dialogs/selection_dialog.py index bd928a7b..f52ad948 100644 --- a/src/view/dialogs/selection_dialog.py +++ b/src/view/dialogs/selection_dialog.py @@ -1,15 +1,17 @@ """Contains a selection dialog.""" +from collections.abc import Callable from typing import override from PySide6.QtGui import QStandardItem, QStandardItemModel, Qt -from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget +from PySide6.QtWidgets import QAbstractItemView, QDialog, QDialogButtonBox, QFormLayout, QLabel, QListView, QWidget class SelectionDialog(QDialog): """A dialog allowing the user to select items in a list.""" - def __init__(self, title: str, message: str, items: list[str], parent: QWidget | None = None) -> None: + def __init__(self, title: str, message: str, items: list[str], parent: QWidget | None = None, + multi_selection_allowed: bool = True, selected_callback: Callable | None = None) -> None: """Initialize the dialog. Args: @@ -17,6 +19,8 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | message: The displayed message or help text of the dialog. items: The list of items to present in the dialog. parent: The parent Qt widget of the dialog. + multi_selection_allowed: Whether the dialog should allow selection of multiple items. + selected_callback: Optional callback function that will be called when selection is completed. """ super().__init__(parent) @@ -28,6 +32,7 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | self.setWindowTitle(title) for item_name in items: item = QStandardItem(item_name) + # TODO use radio buttons if multi_selection_allowed == False item.setCheckable(True) model.appendRow(item) self.list_view.setModel(model) @@ -35,9 +40,12 @@ def __init__(self, title: str, message: str, items: list[str], parent: QWidget | QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, Qt.Orientation.Horizontal, self ) form.addRow(button_box) + if not multi_selection_allowed: + self.list_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) self.setModal(True) + self._selection_callable = selected_callback @property def selected_items(self) -> list[str]: @@ -54,6 +62,8 @@ def selected_items(self) -> list[str]: @override def accept(self) -> None: super().accept() + if self._selection_callable is not None: + self._selection_callable(self) self.close() @override diff --git a/src/view/dialogs/temperature_dialog.py b/src/view/dialogs/temperature_dialog.py index 9761d7e4..cd869c0d 100644 --- a/src/view/dialogs/temperature_dialog.py +++ b/src/view/dialogs/temperature_dialog.py @@ -1,15 +1,16 @@ -"""dialog for change of Temperature""" +"""dialog for change of Temperature.""" import numpy as np from PySide6 import QtCore, QtGui, QtWidgets -from model import ColorHSI +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn class TemperatureDialog(QtWidgets.QDialog): - """dialog for change of Temperature""" + """dialog for change of Temperature.""" def __init__(self, column: ColorDeskColumn, parent: object = None) -> None: + """Initialize dialog for provided column.""" super().__init__(parent) self._column = column self.temperature = QtWidgets.QSpinBox(self) diff --git a/src/view/main_window.py b/src/view/main_window.py index 62c32acd..888c1f21 100644 --- a/src/view/main_window.py +++ b/src/view/main_window.py @@ -28,6 +28,7 @@ from view.misc.console_dock_widget import ConsoleDockWidget from view.misc.settings.settings_dialog import SettingsDialog from view.patch_view.patch_mode import PatchMode +from view.show_mode.editor.node_editor_widgets.cue_editor.yes_no_dialog import YesNoDialog from view.show_mode.editor.showmanager import ShowEditorWidget from view.show_mode.player.showplayer import ShowPlayerWidget from view.utility_widgets.wizzards.patch_plan_export import PatchPlanExportWizard @@ -136,6 +137,7 @@ def __init__(self, parent: QWidget = None) -> None: self._terminal_widget: ConsoleDockWidget | None = None self.setWindowIcon(QPixmap(resource_path(os.path.join("resources", "logo.png")))) + self._close_now = False @property def fish_connector(self) -> NetworkManager: @@ -216,14 +218,6 @@ def _setup_menubar(self) -> None: self._add_entries_to_menu(menu, entries) self.menuBar().addAction(menu.menuAction()) - @override - def closeEvent(self, event: QCloseEvent, /) -> None: - # TODO use event.ignore() here is there's still stuff to do - super().closeEvent(event) - QApplication.processEvents() - self._broadcaster.application_closing.emit() - QApplication.processEvents() - def _start_connection(self) -> None: # TODO rework to signals self._fish_connector.start(True) @@ -405,3 +399,25 @@ def _toggle_terminal(self) -> None: def _open_asset_mgmt_dialog(self) -> None: self._settings_dialog = AssetManagementDialog(self, self._board_configuration.file_path) self._settings_dialog.show() + + @override + def closeEvent(self, event: QCloseEvent) -> None: + if self._close_now: + super().closeEvent(event) + QApplication.processEvents() + self._broadcaster.application_closing.emit() + QApplication.processEvents() + else: + event.ignore() + self._settings_dialog = YesNoDialog( + self, + "Close Editor", + "Do you really want to close this window? Any unsaved changes will be lost.", + self._close_callback + ) + self._settings_dialog.setModal(True) + self._settings_dialog.show() + + def _close_callback(self) -> None: + self._close_now = True + self.close() diff --git a/src/view/misc/termqt/_terminal_buffer.py b/src/view/misc/termqt/_terminal_buffer.py index 8aca1cbd..c7373d98 100644 --- a/src/view/misc/termqt/_terminal_buffer.py +++ b/src/view/misc/termqt/_terminal_buffer.py @@ -840,8 +840,8 @@ def set_fg(self, color: QColor) -> None: def set_style(self, color: QColor | None, bg_color: QColor | None, bold: bool, underline: bool, reverse: bool) -> None: - self._fg_color = color if color else self._fg_color - self._bg_color = bg_color if bg_color else self._bg_color + self._fg_color = color or self._fg_color + self._bg_color = bg_color or self._bg_color self._bold = bool(bold) if bold != -1 else self._bold self._underline = bool(underline) if underline != -1 else \ self._underline diff --git a/src/view/misc/termqt/terminal_widget.py b/src/view/misc/termqt/terminal_widget.py index b75d382c..617133c1 100644 --- a/src/view/misc/termqt/terminal_widget.py +++ b/src/view/misc/termqt/terminal_widget.py @@ -104,7 +104,7 @@ def __init__(self, self.scroll_bar: QScrollBar = None - self.logger = logger if logger else logging.getLogger() + self.logger = logger or logging.getLogger() self.logger.info("Initializing Terminal...") TerminalBuffer.__init__(self, max(int(height / 10), 1), max(int(width / 10), 1), logger=self.logger, **kwargs) diff --git a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py index de2d3f20..74735cb8 100644 --- a/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py +++ b/src/view/show_mode/editor/editor_tab_widgets/bankset_tab.py @@ -1,3 +1,5 @@ +"""Contains BankSet editor tab widget.""" + from PySide6.QtCore import Qt from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( @@ -16,12 +18,15 @@ QWidget, ) -from model import ColorHSI +from model.color_hsi import ColorHSI from model.control_desk import BankSet, ColorDeskColumn, FaderBank, RawDeskColumn class BankSetTabWidget(QWidget): + """Editor Tab to edit BankSets.""" + def __init__(self, parent: QWidget, bankset: BankSet) -> None: + """Initialize tab widget for given BankSet.""" super().__init__(parent) self._bankset: BankSet | None = None @@ -29,8 +34,8 @@ def __init__(self, parent: QWidget, bankset: BankSet) -> None: self._tool_bar = QToolBar() self._tool_bar.addAction(QIcon.fromTheme("system-software-update"), "Refresh bankset on fish", lambda: self._bankset.update() if self._bankset is not None else False) - self._tool_bar.addAction(QIcon.fromTheme("document-new"), "Add Bank", lambda: self._add_bank()) - self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Column to current Bank", lambda: self._add_column()) + self._tool_bar.addAction(QIcon.fromTheme("document-new"), "Add Bank", self._add_bank) + self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Column to current Bank", self._add_column) self._new_column_type_cbox = QComboBox() self._new_column_type_cbox.insertItems(0, ["Numbers", "Color"]) self._new_column_type_cbox.setCurrentIndex(1) @@ -53,6 +58,7 @@ def __init__(self, parent: QWidget, bankset: BankSet) -> None: @property def bankset(self) -> BankSet: + """Get or set the used BankSet.""" return self._bankset @bankset.setter diff --git a/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py new file mode 100644 index 00000000..66839a5c --- /dev/null +++ b/src/view/show_mode/editor/editor_tab_widgets/dmx_default_value_editor.py @@ -0,0 +1,150 @@ +"""Contains DMX default value editor tab widget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem + +if TYPE_CHECKING: + from model.scene import Scene + + +class _ValueQueryDialog(QDialog): + def __init__(self, parent: DMXDefaultValueEditorWidget) -> None: + super().__init__(parent) + self._editor: DMXDefaultValueEditorWidget = parent + + self._universe_id_tb = QComboBox() + for univ in parent.scene.board_configuration.universes: + self._universe_id_tb.addItem(str(univ.name), univ.id) + self._channel_tb = QSpinBox() + self._channel_tb.setRange(0, 511) + self._value_tb = QSpinBox() + self._value_tb.setRange(0, 255) + + layout = QFormLayout() + layout.addRow("Universe ID", self._universe_id_tb) + layout.addRow("Channel", self._channel_tb) + layout.addRow("Value", self._value_tb) + + layout_exit = QHBoxLayout() + self._ok = QPushButton() + self._ok.setText("Enter") + _cancel = QPushButton() + _cancel.setText("Cancel") + layout_exit.addWidget(_cancel) + layout_exit.addWidget(self._ok) + _cancel.setAutoDefault(False) + self._ok.setAutoDefault(True) + self._ok.clicked.connect(self._accept) + _cancel.clicked.connect(self.close) + layout.addRow(layout_exit) + + self.setLayout(layout) + self.setWindowTitle("Add Entry") + self.setModal(True) + + def _accept(self) -> None: + univ_id = self._universe_id_tb.currentData() + channel = self._channel_tb.value() + value = self._value_tb.value() + refresh_required = self._editor.scene.insert_dmx_default_value(univ_id, channel, value, supress_emission=True) + if refresh_required: + self._editor.refresh() + else: + self._editor.add_value_entry(univ_id, channel, value) + self.close() + + +class DMXDefaultValueEditorWidget(QWidget): + """Widget to edit default DMX value mappings.""" + + def __init__(self, scene: Scene, parent: QWidget | None = None) -> None: + """Initializes the widget. + + Args: + scene: The scene which default DMX values should be edited. + parent: The parent widget. + + """ + super().__init__(parent) + self._scene: Scene = scene + self._scene.default_values_changed.connect(self.refresh) + + layout = QVBoxLayout() + + self._value_list_widget = QListWidget() + self._value_list_widget.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)) + self._value_list_widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection) + + self._remove_value_button = QPushButton("Remove Default Value") + self._remove_value_button.clicked.connect(self._remove_entry) + + self.refresh() + + self._add_value_button = QPushButton("Add Default Value") + self._add_value_button.clicked.connect(self._add_value_clicked) + + layout.addWidget(QLabel(f"DMX Default Values of Scene '{scene.human_readable_name}' [{scene.scene_id}]")) + layout.addWidget(self._value_list_widget) + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + buttons_layout.addWidget(self._remove_value_button) + buttons_layout.addSpacing(25) + buttons_layout.addWidget(self._add_value_button) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + def refresh(self) -> None: + """Refreshes the list view.""" + self._value_list_widget.clear() + empty = True + for univ_id, channel, value in self._scene.dmx_default_values: + self.add_value_entry(univ_id, channel, value) + empty = False + self._remove_value_button.setEnabled(not empty) + + @property + def scene(self) -> Scene: + """Get the scene in use.""" + return self._scene + + def _add_value_clicked(self) -> None: + self._dialog = _ValueQueryDialog(self) + self._dialog.show() + + def add_value_entry(self, universe_id: int, channel: int, value: int) -> None: + """Add an entry to the list view.""" + item = AnnotatedListWidgetItem(self._value_list_widget) + item.annotated_data = (universe_id, channel, value) + capability = "" + associated_fixture = self._scene.board_configuration.get_fixture_by_address(universe_id, channel) + if associated_fixture is not None: + capability = associated_fixture.fixture_channels[channel - associated_fixture.start_index].name + item.setText(f"{universe_id}:{channel} ({associated_fixture.name if + associated_fixture is not None else ""}.{capability}) -> {value}") + self._remove_value_button.setEnabled(True) + + def _remove_entry(self) -> None: + for entry_item in self._value_list_widget.selectedItems(): + if not isinstance(entry_item, AnnotatedListWidgetItem): + continue + univ, channel, _ = entry_item.annotated_data + self.scene.remove_dmx_default_value(univ, channel, supress_emission=True) + self.refresh() diff --git a/src/view/show_mode/editor/filter_settings_item.py b/src/view/show_mode/editor/filter_settings_item.py index 916bb305..f09c80ef 100644 --- a/src/view/show_mode/editor/filter_settings_item.py +++ b/src/view/show_mode/editor/filter_settings_item.py @@ -28,7 +28,9 @@ from .node_editor_widgets.autotracker_settings import AutotrackerSettingsWidget from .node_editor_widgets.color_mixing_setup_widget import ColorMixingSetupWidget +from .node_editor_widgets.color_to_colorwheel_adapter_settings_widget import ColorToColorwheelAdapterSetupWidget from .node_editor_widgets.column_select import ColumnSelect +from .node_editor_widgets.dimmer_brightness_mixin_config_widget import DimmerBrightnessMixinConfigWidget from .node_editor_widgets.import_vfilter_settings_widget import ImportVFilterSettingsWidget from .node_editor_widgets.lua_widget import LuaScriptConfigWidget from .node_editor_widgets.sequencer_editor.widget import SequencerEditor @@ -59,11 +61,17 @@ def __init__(self, filter_node: FilterNode, parent: QGraphicsItem, filter_: Filt self._dialog = None self._filter_node = filter_node self._on_update = lambda: None + self._parent = parent self.setScale(0.2) - self.moveBy(parent.boundingRect().width() / 2 - 6, parent.boundingRect().height() - 20) + self.update_position() self._filter = filter_ self._mb_updated: bool = False + def update_position(self) -> None: + """Updates the position of the button after the filter node size changed.""" + self.setPos(0, 0) + self.moveBy(self._parent.boundingRect().width() / 2 - 6, self._parent.boundingRect().height() - 20) + @override def focusOutEvent(self, ev: QFocusEvent) -> None: super().focusOutEvent(ev) @@ -131,9 +139,12 @@ def check_if_filter_has_special_widget(filter_: Filter) -> NodeEditorFilterConfi return ImportVFilterSettingsWidget(filter_) if filter_.filter_type == int(FilterTypeEnumeration.VFILTER_COLOR_MIXER): return ColorMixingSetupWidget() + if filter_.filter_type == int(FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL): + return ColorToColorwheelAdapterSetupWidget(filter_) if filter_.filter_type == FilterTypeEnumeration.VFILTER_SEQUENCER: return SequencerEditor(f=filter_) - + if filter_.filter_type == FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: + return DimmerBrightnessMixinConfigWidget() return None @@ -234,6 +245,8 @@ def closeEvent(self, arg__1: QCloseEvent) -> None: self._special_widget.parent_closed(self._filter_node) else: self._filter_node.update_node_after_settings_changed() + if self._filter_node.fsi is not None: + self._filter_node.fsi.update_position() super().closeEvent(arg__1) @override diff --git a/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py new file mode 100644 index 00000000..0158e6fb --- /dev/null +++ b/src/view/show_mode/editor/node_editor_widgets/color_to_colorwheel_adapter_settings_widget.py @@ -0,0 +1,284 @@ +"""Provides configuration widget for Color2Colorwheel adapter.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QSizePolicy, + QSpacerItem, + QSpinBox, + QWidget, +) + +from model.color_hsi import ColorHSI +from model.virtual_filters.color_to_colorwheel import extract_colorwheel_mappings_from_fixture +from view.dialogs.selection_dialog import SelectionDialog +from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget +from view.show_mode.show_ui_widgets.debug_viz_widgets import ColorLabel + +if TYPE_CHECKING: + from model.ofl.fixture import UsedFixture + from model.virtual_filters.color_to_colorwheel import ColorToColorWheel + + +class _ColorMappingListWidgetItem(QListWidgetItem): + """Purpose of this widget is to display a single mapping.""" + + def __init__(self, parent: QListWidget, color: ColorHSI, slot_value: int) -> None: + """Initializes and adds the widget based on the provided color and slot value.""" + super().__init__() + self.color = color + self.slot_value = slot_value + + widget = QWidget() + widget.setLayout(QHBoxLayout()) + self._color_label = ColorLabel() + self._color_label.setFixedWidth(16) + self._color_label.set_color(self.color) + widget.layout().addWidget(self._color_label) + self._slot_label = QLabel(str(slot_value)) + widget.layout().addWidget(self._slot_label) + + parent.addItem(self) + parent.setItemWidget(self, widget) + self.setSizeHint(widget.sizeHint()) + + +class _ColorSlotInputDialog(QDialog): + """Query a color and a slot.""" + + def __init__(self, parent: QWidget, list_widget: QListWidget) -> None: + """Initializes the dialog.""" + super().__init__(parent) + + self._list_widget = list_widget + + layout = QFormLayout() + self._hue_tb = QDoubleSpinBox() + self._hue_tb.setRange(0, 360) + self._hue_tb.setDecimals(2) + layout.addRow("Hue", self._hue_tb) + self._saturation_tb = QDoubleSpinBox() + self._saturation_tb.setDecimals(5) + self._saturation_tb.setValue(1.0) + self._saturation_tb.setRange(0, 1) + layout.addRow("Saturation", self._saturation_tb) + self._slot_tb = QSpinBox() + self._slot_tb.setRange(0, 65565) + layout.addRow("Slot", self._slot_tb) + layout.addItem(QSpacerItem(0, 25)) + + self._button_box = QDialogButtonBox() + self._button_box.addButton(QDialogButtonBox.StandardButton.Cancel).clicked.connect(self.close) + self._button_box.addButton(QDialogButtonBox.StandardButton.Ok).clicked.connect(self.accept) + layout.addWidget(self._button_box) + self.setLayout(layout) + + @override + def accept(self) -> None: + _ColorMappingListWidgetItem(self._list_widget, + ColorHSI(self._hue_tb.value(), self._saturation_tb.value(), 0.5), + self._slot_tb.value()) + super().accept() + + +class ColorToColorwheelAdapterSetupWidget(NodeEditorFilterConfigWidget): + """Configuration widget for color to colorwheel vfilter.""" + + def __init__(self, _filter: ColorToColorWheel) -> None: + """Initialize the configuration widget.""" + super().__init__() + self._input_dialog: _ColorSlotInputDialog | SelectionDialog | None = None + self._widget = QWidget() + layout = QFormLayout() + + self._selected_fixture: UsedFixture | None = None + self._filter = _filter + + fixture_selection_container = QWidget(parent=self._widget) + fixture_selection_container.setLayout(QHBoxLayout()) + self._selected_fixture_label = QLabel("No Fixture Selected.") + fixture_selection_container.layout().addWidget(self._selected_fixture_label) + self._fixture_selection_or_clear_button = QPushButton("Select Fixture") + self._fixture_selection_or_clear_button.clicked.connect(self._load_from_fixture_clicked) + fixture_selection_container.layout().addWidget(self._fixture_selection_or_clear_button) + layout.addRow("Selected Fixture: ", fixture_selection_container) + self._colorwheel_index_spinbox = QSpinBox() + self._colorwheel_index_spinbox.setMinimum(0) + self._colorwheel_index_spinbox.setMaximum(255) + self._colorwheel_index_spinbox.setValue(0) + self._colorwheel_index_spinbox.setEnabled(False) + layout.addRow("Color Wheel Index: ", self._colorwheel_index_spinbox) + + layout.addItem(QSpacerItem(0, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) + + self._enable_dimmer_input_cb = QCheckBox("Enable Dimmer Input") + layout.addRow("Enable Dimmer Input", self._enable_dimmer_input_cb) + self._dimmer_input_datatype_combobox = QComboBox() + self._dimmer_input_datatype_combobox.setEditable(False) + self._dimmer_input_datatype_combobox.addItems(["8bit", "16bit", "float", ""]) + layout.addRow("Dimmer Input Type", self._dimmer_input_datatype_combobox) + self._dimmer_output_datatype_combobox = QComboBox() + self._dimmer_output_datatype_combobox.setEditable(False) + self._dimmer_output_datatype_combobox.addItems(["8bit", "16bit", "float", ""]) + layout.addRow("Dimmer Output Type", self._dimmer_output_datatype_combobox) + self._colorwheel_datatype_combobox = QComboBox() + self._colorwheel_datatype_combobox.setEditable(False) + self._colorwheel_datatype_combobox.addItems(["8bit", "16bit"]) + layout.addRow("Color Wheel Data Type", self._colorwheel_datatype_combobox) + + self._color_mapping_list = QListWidget() + layout.addWidget(self._color_mapping_list) + self._mapping_manipulation_buttongroup = QWidget() + self._mapping_manipulation_buttongroup.setLayout(QHBoxLayout()) + self._mapping_manipulation_buttongroup.layout().addItem(QSpacerItem( + 10, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + )) + self._add_mapping_button = QPushButton("Add Mapping") + self._add_mapping_button.clicked.connect(self._add_mapping_clicked) + self._mapping_manipulation_buttongroup.layout().addWidget(self._add_mapping_button) + self._remove_mapping_button = QPushButton("Remove Mapping") + self._remove_mapping_button.clicked.connect(self._remove_mapping_clicked) + self._mapping_manipulation_buttongroup.layout().addWidget(self._remove_mapping_button) + layout.addWidget(self._mapping_manipulation_buttongroup) + + self._wheel_speed_spinbox = QSpinBox() + self._wheel_speed_spinbox.setMinimum(0) + self._wheel_speed_spinbox.setMaximum(65565) + self._wheel_speed_spinbox.setValue(300) + self._wheel_speed_spinbox.setToolTip( + "How many ms does it take to switch between two adjacent color wheel slots?" + ) + layout.addRow("Color Wheel Speed [ms]", self._wheel_speed_spinbox) + self._dimm_when_wheel_is_moving_cb = QCheckBox("Dim when Wheel Moving") + self._dimm_when_wheel_is_moving_cb.setChecked(True) + layout.addWidget(self._dimm_when_wheel_is_moving_cb) + + self._widget.setLayout(layout) + + def _add_mapping_clicked(self) -> None: + self._input_dialog = _ColorSlotInputDialog(self._widget, self._color_mapping_list) + self._input_dialog.setModal(True) + self._input_dialog.show() + + def _remove_mapping_clicked(self) -> None: + items_to_remove = [index.row() for index in self._color_mapping_list.selectedIndexes()] + items_to_remove.sort(reverse=True) + for item in items_to_remove: + self._color_mapping_list.takeItem(item) + + def _load_from_fixture_clicked(self) -> None: + if self._selected_fixture is not None: + self._selected_fixture = None + self._update_selected_fixture() + return + fixture_names = [ + f"[{f.universe_id}:{f.start_index}] {f.name}" for f in self._filter.scene.board_configuration.fixtures + ] + self._input_dialog = SelectionDialog("Select Fixture", "Please select the target fixture", + fixture_names, parent=self._widget, multi_selection_allowed=False, + selected_callback=self._fixture_selected_callback) + self._input_dialog.setModal(True) + self._input_dialog.show() + + def _fixture_selected_callback(self, sd: SelectionDialog) -> None: + fixture_univ, fixture_chan = sd.selected_items[0].split("] ", 1)[0].replace("[", "").split(":") + fixture_chan = int(fixture_chan) + fixture_univ = int(fixture_univ) + self._selected_fixture = self._filter.scene.board_configuration.\ + get_fixture_by_address(fixture_univ, fixture_chan) + self._update_selected_fixture() + + def _parse_color_mapping(self, mapping: str) -> None: + self._color_mapping_list.clear() + for entry_str in mapping.split(";"): + if len(entry_str) == 0: + continue + hue, saturation, slot = entry_str.split(":") + hue = float(hue) + saturation = float(saturation) + slot = int(slot) + _ColorMappingListWidgetItem(self._color_mapping_list, ColorHSI(hue, saturation, 0.5), slot) + + def _update_selected_fixture(self) -> None: + if self._selected_fixture is None: + self._fixture_selection_or_clear_button.setText("Select Fixture") + self._colorwheel_index_spinbox.setEnabled(False) + self._color_mapping_list.setEnabled(True) + self._selected_fixture_label.setText("No Fixture Selected.") + self._remove_mapping_button.setEnabled(True) + self._add_mapping_button.setEnabled(True) + else: + self._fixture_selection_or_clear_button.setText("Clear Selected Fixture") + self._colorwheel_index_spinbox.setEnabled(len(self._selected_fixture.colorwheel_mappings) > 0) + self._color_mapping_list.setEnabled(False) + self._selected_fixture_label.setText(self._selected_fixture.name) + self._remove_mapping_button.setEnabled(False) + self._add_mapping_button.setEnabled(False) + self._parse_color_mapping(extract_colorwheel_mappings_from_fixture( + self._selected_fixture, + selected_slot_index=self._colorwheel_index_spinbox.value() + )) + + def _compile_color_mapping_string(self) -> str: + parts: list[_ColorMappingListWidgetItem] = [] + for i in range(self._color_mapping_list.count()): + item = self._color_mapping_list.item(i) + if not isinstance(item, _ColorMappingListWidgetItem): + continue + parts.append(item) + return ";".join(f"{item.color.hue}:{item.color.saturation}:{item.slot_value}" for item in parts) + + @override + def get_widget(self) -> QWidget: + return self._widget + + @override + def _get_configuration(self) -> dict[str, str]: + return { + "mode": "automatic" if self._selected_fixture is not None else "manual", + "fixture-uuid": self._selected_fixture.uuid if self._selected_fixture is not None else "", + "color-mappings": self._compile_color_mapping_string(), + "dimmer-input": self._dimmer_input_datatype_combobox.currentText(), + "dimmer-output": self._dimmer_output_datatype_combobox.currentText(), + "colorwheel-datatype": self._colorwheel_datatype_combobox.currentText(), + "wheel_speed": str(self._wheel_speed_spinbox.value()), + "dim_when_off": str(self._dimm_when_wheel_is_moving_cb.isChecked()), + "colorwheel-id": str(self._colorwheel_index_spinbox.value()), + } + + @override + def _load_configuration(self, conf: dict[str, str]) -> None: + self._selected_fixture = self._filter.scene.board_configuration.get_fixture(conf["fixture-uuid"]) + self._parse_color_mapping(conf["color-mappings"]) + self._update_selected_fixture() + self._dimmer_input_datatype_combobox.setCurrentText(conf["dimmer-input"]) + self._dimmer_output_datatype_combobox.setCurrentText(conf["dimmer-output"]) + self._colorwheel_datatype_combobox.setCurrentText(conf["colorwheel-datatype"]) + self._colorwheel_index_spinbox.setValue(int(conf["colorwheel-id"])) + self._wheel_speed_spinbox.setValue(int(conf["wheel_speed"])) + self._dimm_when_wheel_is_moving_cb.setChecked(conf["dim_when_off"] == "true") + + @override + def _load_parameters(self, parameters: dict[str, str]) -> dict: + return parameters # Nothing to do here + + @override + def _get_parameters(self) -> dict[str, str]: + return {} # Nothing to do here + + @override + def parent_opened(self) -> None: + pass # Nothing to do here diff --git a/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py b/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py index c6ce27b1..8424bb3b 100644 --- a/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py +++ b/src/view/show_mode/editor/node_editor_widgets/cue_editor/keyframe_state_edit_dialog.py @@ -14,7 +14,7 @@ QWidget, ) -from model import ColorHSI +from model.color_hsi import ColorHSI from model.filter_data.cues.cue import KeyFrame, State, StateColor, StateDouble, StateEightBit, StateSixteenBit diff --git a/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py b/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py index a4000663..f6292761 100644 --- a/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/cue_editor/timeline_content_widget.py @@ -105,13 +105,12 @@ def paintEvent(self, ev: QPaintEvent) -> None: kf_line_brush.setStyle(Qt.HorPattern) for kf in self._frames: if kf: - i = 0 x = int(kf.timestamp / self._time_zoom) painter.setBrush(kf_line_brush) kf_states = kf._states painter.drawLine(x, 20, x, len(kf_states) * CHANNEL_DISPLAY_HEIGHT + 20) painter.setBrush(light_gray_brush) - for s in kf_states: + for i, s in enumerate(kf_states): if kf.only_on_channel is None: y = 40 + i * CHANNEL_DISPLAY_HEIGHT else: @@ -140,7 +139,6 @@ def paintEvent(self, ev: QPaintEvent) -> None: painter.drawText(x + 15, y + 21, str(s._value)) painter.fillPath(marker_path, selected_brush) painter.drawText(x + 15, y + 9, s.transition) - i += 1 # render cursor, timescale and bars painter.setBrush(light_gray_brush) diff --git a/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py new file mode 100644 index 00000000..8ef61aaf --- /dev/null +++ b/src/view/show_mode/editor/node_editor_widgets/dimmer_brightness_mixin_config_widget.py @@ -0,0 +1,90 @@ +"""Module contains dimmer brightness mixin node config widget.""" + +from typing import override + +from PySide6.QtWidgets import QButtonGroup, QCheckBox, QFormLayout, QHBoxLayout, QLabel, QRadioButton, QWidget + +from view.show_mode.editor.node_editor_widgets import NodeEditorFilterConfigWidget + + +class DimmerBrightnessMixinConfigWidget(NodeEditorFilterConfigWidget): + """Configuration widget for dimmer brightness mixin node.""" + + def __init__(self, parent: QWidget | None = None) -> None: + """Load the filter and prepare a widget.""" + super().__init__() + self._widget = QWidget(parent=parent) + layout = QFormLayout() + self._cb_has_16bit = QCheckBox(self._widget) + self._cb_has_16bit.setText("Enable 16bit output") + self._cb_has_16bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_16bit) + self._cb_has_8bit = QCheckBox(self._widget) + self._cb_has_8bit.setText("Enable 8bit output") + self._cb_has_8bit.checkStateChanged.connect(self._update_warning_visibility) + layout.addWidget(self._cb_has_8bit) + self._output_warning_label = QLabel("At least one output should be configured.", self._widget) + self._output_warning_label.setVisible(False) + self._output_warning_label.setStyleSheet("color: red;") + layout.addWidget(self._output_warning_label) + + self._input_method_rb_group = QButtonGroup(self._widget) + self._input_8bit = QRadioButton("8bit", self._widget) + self._input_method_rb_group.addButton(self._input_8bit) + self._input_16bit = QRadioButton("16bit", self._widget) + self._input_method_rb_group.addButton(self._input_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._input_8bit) + button_layout.addWidget(self._input_16bit) + layout.addRow("Input Port Data Type:", button_layout) + + self._mixin_method_rb_group = QButtonGroup(self._widget) + self._mixin_8bit = QRadioButton("8bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_8bit) + self._mixin_16bit = QRadioButton("16bit", self._widget) + self._mixin_method_rb_group.addButton(self._mixin_16bit) + button_layout = QHBoxLayout() + button_layout.addWidget(self._mixin_8bit) + button_layout.addWidget(self._mixin_16bit) + layout.addRow("Mixin Port Data Type:", button_layout) + + self._widget.setLayout(layout) + + def _update_warning_visibility(self) -> None: + self._output_warning_label.setVisible(not self._cb_has_8bit.isChecked() and not self._cb_has_16bit.isChecked()) + + @override + def _get_configuration(self) -> dict[str, str]: + return { + "has_16bit_output": "true" if self._cb_has_16bit.isChecked() else "false", + "has_8bit_output": "true" if self._cb_has_8bit.isChecked() else "false", + "input_method": "8bit" if self._input_8bit.isChecked() else "16bit", + "input_method_mixin": "8bit" if self._mixin_8bit.isChecked() else "16bit", + } + + @override + def _load_configuration(self, conf: dict[str, str]) -> None: + self._cb_has_16bit.setChecked(conf.get("has_16bit_output", "false") == "true") + self._cb_has_8bit.setChecked(conf.get("has_8bit_output", "false") == "true") + self._input_8bit.setChecked(conf.get("input_method", "8bit") == "8bit") + self._input_16bit.setChecked(conf.get("input_method", "8bit") == "16bit") + self._mixin_8bit.setChecked(conf.get("input_method_mixin", "8bit") == "8bit") + self._mixin_16bit.setChecked(conf.get("input_method_mixin", "8bit") == "16bit") + + @override + def get_widget(self) -> QWidget: + return self._widget + + @override + def _load_parameters(self, parameters: dict[str, str]) -> dict: + # Nothing to do here + pass + + @override + def _get_parameters(self) -> dict[str, str]: + return {} + + @override + def parent_opened(self) -> None: + # Nothing to do here + pass diff --git a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py index 9ebaa57a..17dba4d4 100644 --- a/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py +++ b/src/view/show_mode/editor/node_editor_widgets/node_editor_widget.py @@ -1,3 +1,5 @@ +"""Module provides abstract filter node configuration widget.""" + from abc import ABC, abstractmethod from typing import TYPE_CHECKING @@ -8,6 +10,13 @@ class NodeEditorFilterConfigWidget(ABC): + """Base class for node editor filter configuration widgets. + + All abstract methods must be implemented. They need to return a non-null value at all times. Dictionaries however, + might be returned empty if no changes should be made. + + """ + @abstractmethod def _get_configuration(self) -> dict[str, str]: """Retrieve the filter configuration parameters that should be updated.""" @@ -18,17 +27,17 @@ def _load_configuration(self, conf: dict[str, str]) -> None: @property def configuration(self) -> dict[str, str]: - """Returns the configuration of the filter""" + """Returns the configuration of the filter.""" return self._get_configuration() @configuration.setter def configuration(self, conf: dict[str, str]) -> None: - """Loads the configuration already present in the filter configuration""" + """Loads the configuration already present in the filter configuration.""" self._load_configuration(conf) @abstractmethod def get_widget(self) -> QWidget: - """Returns the widget that should be displayed""" + """Returns the widget that should be displayed.""" @abstractmethod def _load_parameters(self, parameters: dict[str, str]) -> dict: @@ -37,25 +46,33 @@ def _load_parameters(self, parameters: dict[str, str]) -> dict: @abstractmethod def _get_parameters(self) -> dict[str, str]: """Return the (initial) filter parameters deduced by the widget. - Only parameters that changed need to be updated here.""" + + Only parameters that changed need to be updated here. + + """ raise NotImplementedError @property def parameters(self) -> dict[str, str]: - """Returns the current filter parameters""" + """Returns the current filter parameters.""" return self._get_parameters() @parameters.setter def parameters(self, parameters: dict[str, str]) -> None: - """Sets the filter parameters on the widget""" + """Sets the filter parameters on the widget.""" self._load_parameters(parameters) def parent_closed(self, filter_node: "FilterNode") -> None: - """This method might be overridden to listen for parent close events. - Arguments: - filter_node -- might be used to alter the filter being presented.""" + """Method might be overridden to listen for parent close events. + + Args: + filter_node: might be used to alter the filter being presented. + + """ filter_node.update_node_after_settings_changed() + if filter_node.fsi is not None: + filter_node.fsi.update_position() @abstractmethod def parent_opened(self) -> None: - """This method might be overridden to listen for parent open events.""" + """Method might be overridden to listen for parent open events.""" diff --git a/src/view/show_mode/editor/nodes/filter_node_library.py b/src/view/show_mode/editor/nodes/filter_node_library.py index a9f97cec..4d9ea9a0 100644 --- a/src/view/show_mode/editor/nodes/filter_node_library.py +++ b/src/view/show_mode/editor/nodes/filter_node_library.py @@ -18,7 +18,9 @@ AdapterFloatToColorNode, AdapterFloatToRange, ColorBrightnessMixinNode, + ColorToColorwheelAdapterNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -159,6 +161,8 @@ def _register_adapters_nodes(self) -> None: self.addNodeType(CombineTwo8BitToSingle16Bit, [("Adapters",)]) self.addNodeType(Map8BitTo16Bit, [("Adapters",)]) self.addNodeType(ColorBrightnessMixinNode, [("Adapters",)]) + self.addNodeType(DimmerBrightnessMixinNode, [("Adapters",)]) + self.addNodeType(ColorToColorwheelAdapterNode, [("Adapters",)]) def _register_arithmetic_nodes(self) -> None: """Register all the arithmetics nodes.""" diff --git a/src/view/show_mode/editor/nodes/impl/adapters.py b/src/view/show_mode/editor/nodes/impl/adapters.py index 4d693530..ff2eeda0 100644 --- a/src/view/show_mode/editor/nodes/impl/adapters.py +++ b/src/view/show_mode/editor/nodes/impl/adapters.py @@ -1,14 +1,19 @@ -"""Adapters and converters filter nodes""" +"""Adapters and converters filter nodes.""" +from typing import override + from model import DataType, Scene -from model.filter import Filter, FilterTypeEnumeration +from model.filter import Filter, FilterTypeEnumeration, VirtualFilter +from view.show_mode.editor.filter_settings_item import FilterSettingsItem from view.show_mode.editor.nodes.base.filternode import FilterNode class Adapter16BitTo8BitNode(FilterNode): """Filter to convert a 16 bit value to two 8 bit values.""" + nodeName = "16 bit to 8 bit converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to 8bit splitter adapter node.""" super().__init__(model=model, filter_type=int(FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT), name=name, terminals={ "value": {"io": "in"}, @@ -23,11 +28,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToBoolNode(FilterNode): """Filter to convert a 16 bit value to a boolean. + If input is 0, output is 0, else 1. """ + nodeName = "16 bit to bool converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to boolean adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_BOOL, name=name, terminals={ "value_in": {"io": "in"}, @@ -39,9 +47,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16bitToFloat(FilterNode): + """Filter node to convert a 16 bit value to a float.""" + nodeName = "16bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_16BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -54,9 +65,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8bitToFloat(FilterNode): + """Filter node to convert a 8bit value to a float.""" + nodeName = "8bit to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_TYPE_ADAPTER_8BIT_TO_FLOAT, name=name, terminals={ "value_in": {"io": "in"}, @@ -70,9 +84,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBNode(FilterNode): """Filter to convert a color value to a rgb value.""" + nodeName = "Color to rgb converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGB, name=name, terminals={ "value": {"io": "in"}, @@ -89,9 +105,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWNode(FilterNode): """Filter to convert a color value to a rgbw value.""" + nodeName = "Color to rgb-w converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-w adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBW, name=name, terminals={ "value": {"io": "in"}, @@ -110,9 +128,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToRGBWANode(FilterNode): """Filter to convert a color value to a RGBWA value.""" + nodeName = "Color to rgb-wa converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to rgb-wa adapter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_RGBWA, name=name, terminals={ "value": {"io": "in"}, @@ -133,9 +153,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToColorNode(FilterNode): """Filter to convert a float/double value to a color value.""" + nodeName = "Float to color converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Float to color combining converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_COLOR, name=name, terminals={ "h": {"io": "in"}, @@ -153,9 +175,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterColorToFloatsNode(FilterNode): """Filter that splits the HSI values into three individual float channels.""" + nodeName = "Color to Float converter" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color to Float converter node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_COLOR_TO_FLOAT, name=name, terminals={ "input": {"io": "in"}, @@ -171,13 +195,14 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatToRange(FilterNode): - """Filter maps a range of floats to another range of specific type (template)""" + """Filter maps a range of floats to another range of specific type (template).""" nodeName = "float range to float range" # noqa: N815 def __init__(self, model: Filter | Scene, name: str, filter_type: FilterTypeEnumeration = FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_FLOAT_RANGE) -> None: + """Initialize float range to float range template node.""" super().__init__(model, int(filter_type), name, terminals={ "value_in": {"io": "in"}, "value": {"io": "out"}, @@ -213,10 +238,12 @@ def __init__(self, model: Filter | Scene, name: str, class AdapterFloatTo8BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 8bit""" + """Filter maps a range of float to a range of 8bit.""" + nodeName = "Float range to 8bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 8bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_8BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -226,10 +253,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class AdapterFloatTo16BitRange(AdapterFloatToRange): - """Filter maps a range of float to a range of 16bit""" + """Filter maps a range of float to a range of 16bit.""" + nodeName = "Float range to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize float range to 16bit range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_FLOAT_TO_16BIT_RANGE, name=name) try: self.filter.initial_parameters["upper_bound_out"] = model.initial_parameters["upper_bound_out"] @@ -239,10 +268,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter16BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 16bit to a range of float""" + """Filter maps a range of 16bit to a range of float.""" + nodeName = "16bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 16bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_16BIT_TO_FLOAT_RANGE, name=name) try: @@ -254,10 +285,12 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Adapter8BitToRangeFloat(AdapterFloatToRange): - """Filter maps a range of 8bit to a range of floats""" + """Filter maps a range of 8bit to a range of floats.""" + nodeName = "8bit range to Float" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize 8bit to float range node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_FILTER_ADAPTER_8BIT_TO_FLOAT_RANGE, name=name) try: @@ -270,9 +303,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class CombineTwo8BitToSingle16Bit(FilterNode): """Filter that combines two 8bit values to a 16bit one.""" + nodeName = "Dual 8bit to single 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dual 8bit to single 16bit combiner node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_DUAL_BYTE_TO_16BIT, name=name, terminals={ "lower": {"io": "in"}, @@ -287,9 +322,11 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class Map8BitTo16Bit(FilterNode): """Filter that maps an 8-Bit value to a 16-Bit one.""" + nodeName = "Map 8bit to 16bit" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Map 8bit to 16bit map node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_ADAPTER_8BIT_TO_16BIT, name=name, terminals={ "value_in": {"io": "in"}, @@ -301,9 +338,16 @@ def __init__(self, model: Filter | Scene, name: str) -> None: class ColorBrightnessMixinNode(FilterNode): + """Filter node to mix brightness values conveniently. + + It supports the brightness mixin v-filter. + + """ + nodeName = "Color Brightness Mixin" # noqa: N815 def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Color Brightness Mixin node.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN, name=name, terminals={ "out": {"io": "out"}, @@ -316,3 +360,89 @@ def __init__(self, model: Filter | Scene, name: str) -> None: self.filter.in_data_types["brightness"] = DataType.DT_8_BIT self.channel_hints["brightness"] = "[0-255, optional]" self.filter._configuration_supported = False + +class ColorToColorwheelAdapterNode(FilterNode): + """Filter node to convert a color channel to color wheel control signals.""" + + nodeName = "Color to Color Wheel Adapter" # noqa: N815 + + def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize.""" + super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL, name=name, + terminals={ + "input": {"io": "in"}, + "colorwheel": {"io": "out"} + }) + self.update_node_after_settings_changed() + + @override + def update_node_after_settings_changed(self) -> None: + self.filter.in_data_types["input"] = DataType.DT_COLOR + + dimmer_input_dt_str = self.filter.filter_configurations.get("dimmer-input", "") + if len(dimmer_input_dt_str) > 0: + if "in_dimmer" not in self.inputs(): + self.addInput("in_dimmer") + match dimmer_input_dt_str: + case "16bit": + self.filter.in_data_types["in_dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.in_data_types["in_dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.in_data_types["in_dimmer"] = DataType.DT_8_BIT + else: + if "in_dimmer" in self.inputs(): + self.removeTerminal("in_dimmer") + dimmer_output_dt_str = self.filter.filter_configurations.get("dimmer-output", "") + if len(dimmer_output_dt_str) > 0: + if "dimmer" not in self.outputs(): + self.addOutput("dimmer") + match dimmer_output_dt_str: + case "16bit": + self.filter.out_data_types["dimmer"] = DataType.DT_16_BIT + case "float": + self.filter.out_data_types["dimmer"] = DataType.DT_DOUBLE + case _: + self.filter.out_data_types["dimmer"] = DataType.DT_8_BIT + else: + if "dimmer" in self.outputs(): + self.removeTerminal("dimmer") + match self.filter.filter_configurations.get("colorwheel-datatype"): + case "16bit": + self.filter.out_data_types["colorwheel"] = DataType.DT_16_BIT + case "float": + self.filter.out_data_types["colorwheel"] = DataType.DT_DOUBLE + case _: + self.filter.out_data_types["colorwheel"] = DataType.DT_8_BIT + + +class DimmerBrightnessMixinNode(FilterNode): + """Node for dimmer brightness mixin v-filter.""" + + nodeName = "Dimmer Brightness Mixin" # noqa: N815 + + def __init__(self, model: Filter | Scene, name: str) -> None: + """Initialize Dimmer Brightness Mixin node.""" + super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN, name=name, + terminals={"input": {"io": "in"}, "mixin": {"io": "in"}, "offset": {"io": "in"}}) + self.channel_hints["offset"] = "[(-1, 1), optional]" + self.channel_hints["input"] = "[default: global brightness]" + self.channel_hints["mixin"] = "[optional]" + self._update_output_terminals() + + def _update_output_terminals(self) -> None: + for setting, term_name in [("has_8bit_output", "dimmer_out8b"), ("has_16bit_output", "dimmer_out16b")]: + if self.filter.filter_configurations.get(setting) == "true": + if self.outputs().get(term_name) is None: + self.addOutput(term_name) + else: + if self.outputs().get(term_name) is not None: + self.removeTerminal(term_name) + + @override + def update_node_after_settings_changed(self) -> None: + if isinstance(self.filter, VirtualFilter): + self.filter.deserialize() + else: + raise ValueError("Expected filter instance to be a DimmerGlobalBrightnessMixinVFilter, implying a vFilter.") + self._update_output_terminals() diff --git a/src/view/show_mode/editor/nodes/impl/constants.py b/src/view/show_mode/editor/nodes/impl/constants.py index 629030b2..4ffad852 100644 --- a/src/view/show_mode/editor/nodes/impl/constants.py +++ b/src/view/show_mode/editor/nodes/impl/constants.py @@ -1,9 +1,11 @@ -"""Constants filter nodes""" +"""Constants filter nodes.""" from logging import getLogger +from typing import override from PySide6.QtGui import QBrush, QColor, QFontMetrics, QPainter -from model import ColorHSI, DataType, Scene +from model import DataType, Scene +from model.color_hsi import ColorHSI from model.filter import Filter, FilterTypeEnumeration from view.show_mode.editor.nodes.base.filternode import FilterNode @@ -14,6 +16,7 @@ class TextPreviewRendererMixin(FilterNode): + """Mixin to render text based previews in filter nodes.""" def __init__(self, model: Filter | Scene, filter_type: int, @@ -21,6 +24,7 @@ def __init__(self, model: Filter | Scene, terminals: dict[str, dict[str, str]] | None = None, allow_add_input: bool = False, allow_add_output: bool = False) -> None: + """Initialize.""" super().__init__(model, filter_type, name, terminals, allow_add_input, allow_add_output) self.graphicsItem().additional_rendering_method = self._draw_preview @@ -41,9 +45,11 @@ def _draw_preview(self, p: QPainter) -> None: class Constants8BitNode(TextPreviewRendererMixin): """Filter to represent an 8 bit value.""" + nodeName = "8_bit_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_8BIT, name=name, terminals={ "value": {"io": "out"}, }) @@ -55,6 +61,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.out_data_types["value"] = DataType.DT_8_BIT self.filter.gui_update_keys["value"] = DataType.DT_8_BIT + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -66,9 +73,11 @@ def update_node_after_settings_changed(self) -> None: class Constants16BitNode(TextPreviewRendererMixin): """Filter to represent a 16 bit value.""" + nodeName = "16_bit_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, name=name, terminals={ "value": {"io": "out"}, }) @@ -80,6 +89,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.out_data_types["value"] = DataType.DT_16_BIT self.filter.gui_update_keys["value"] = DataType.DT_16_BIT + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -91,9 +101,11 @@ def update_node_after_settings_changed(self) -> None: class ConstantsFloatNode(TextPreviewRendererMixin): """Filter to represent a float/double value.""" + nodeName = "Float_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, name=name, terminals={ "value": {"io": "out"}, }) @@ -105,6 +117,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.gui_update_keys["value"] = DataType.DT_DOUBLE self.graphicsItem().additional_rendering_method = self._draw_preview + @override def update_node_after_settings_changed(self) -> None: try: self.filter.initial_parameters["value"] = str( @@ -116,11 +129,15 @@ def update_node_after_settings_changed(self) -> None: class ConstantsColorNode(FilterNode): """Filter to represent a color value. - TODO specify color format + + Colors are stored as "h,s,i" where h is a float in [0,360], s and i are float in [0,1]. + """ + nodeName = "Color_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.FILTER_CONSTANT_COLOR, name=name, terminals={ "value": {"io": "out"}, }) @@ -143,6 +160,7 @@ def _draw_preview(self, p: QPainter) -> None: p.setBrush(self._color_brush) p.drawRect(x + 3, y + 3, 20, 20) + @override def update_node_after_settings_changed(self) -> None: try: self._color_brush = QBrush(ColorHSI.from_filter_str(self.filter.initial_parameters["value"]).to_qt_color()) @@ -153,9 +171,11 @@ def update_node_after_settings_changed(self) -> None: class PanTiltConstant(FilterNode): """Filter to represent a pan/tilt position.""" + nodeName = "PanTilt_filter" # noqa: N815 def __init__(self, model: Filter, name: str) -> None: + """Initialize.""" super().__init__(model=model, filter_type=FilterTypeEnumeration.VFILTER_POSITION_CONSTANT, name=name, terminals={}, allow_add_output=True) try: @@ -175,7 +195,7 @@ def __init__(self, model: Filter, name: str) -> None: self.filter.filter_configurations["outputs"] = outputs_from_file except: self.filter.filter_configurations["outputs"] = "16bit" - self.setup_output_terminals() + self._setup_output_terminals() self.filter.gui_update_keys["pan"] = DataType.DT_DOUBLE self.graphicsItem().additional_rendering_method = self._draw_preview @@ -195,7 +215,7 @@ def _draw_preview(self, p: QPainter) -> None: p.drawText(x + 3, y + fm.height() + 3, value_pan_str) p.drawText(x + 3, y + sheight, value_tilt_str) - def setup_output_terminals(self) -> None: + def _setup_output_terminals(self) -> None: existing_output_keys = list(self.outputs().keys()) outputs = self.filter.filter_configurations["outputs"] match outputs: @@ -227,7 +247,7 @@ def setup_output_terminals(self) -> None: if "tilt16bit" not in existing_output_keys: self.addOutput("tilt16bit") - def outputs_changed(self, eight_bit: bool, sixteen_bit: bool) -> None: + def _outputs_changed(self, eight_bit: bool, sixteen_bit: bool) -> None: self.filter.filter_configurations["outputs"] = \ "both" if eight_bit and sixteen_bit else "8bit" if eight_bit else "16bit" - self.setup_output_terminals() + self._setup_output_terminals() diff --git a/src/view/show_mode/editor/nodes/type_to_node_dict.py b/src/view/show_mode/editor/nodes/type_to_node_dict.py index 5114ac21..92238e83 100644 --- a/src/view/show_mode/editor/nodes/type_to_node_dict.py +++ b/src/view/show_mode/editor/nodes/type_to_node_dict.py @@ -17,7 +17,9 @@ AdapterFloatToColorNode, AdapterFloatToRange, ColorBrightnessMixinNode, + ColorToColorwheelAdapterNode, CombineTwo8BitToSingle16Bit, + DimmerBrightnessMixinNode, Map8BitTo16Bit, ) from view.show_mode.editor.nodes.impl.arithmetics import ( @@ -98,9 +100,11 @@ from view.show_mode.editor.nodes.import_node import ImportNode type_to_node: dict[int, str] = { + FilterTypeEnumeration.VFILTER_COLOR_TO_COLORWHEEL: ColorToColorwheelAdapterNode.nodeName, FilterTypeEnumeration.VFILTER_SEQUENCER: SequencerNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_MIXER: ColorMixerVFilterNode.nodeName, FilterTypeEnumeration.VFILTER_IMPORT: ImportNode.nodeName, + FilterTypeEnumeration.VFILTER_DIMMER_BRIGHTNESS_MIXIN: DimmerBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_COLOR_GLOBAL_BRIGHTNESS_MIXIN: ColorBrightnessMixinNode.nodeName, FilterTypeEnumeration.VFILTER_POSITION_CONSTANT: PanTiltConstant.nodeName, FilterTypeEnumeration.VFILTER_CUES: CueListNode.nodeName, diff --git a/src/view/show_mode/editor/show_browser/fixture_to_filter.py b/src/view/show_mode/editor/show_browser/fixture_to_filter.py index e2564b97..eb5c042e 100644 --- a/src/view/show_mode/editor/show_browser/fixture_to_filter.py +++ b/src/view/show_mode/editor/show_browser/fixture_to_filter.py @@ -1,4 +1,4 @@ -"""write fixture to file""" +"""write fixture to file.""" from logging import getLogger from typing import Union @@ -6,6 +6,7 @@ from model.filter import FilterTypeEnumeration from model.ofl.fixture import ColorSupport, UsedFixture from model.scene import FilterPage +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter from model.virtual_filters.vfilter_factory import construct_virtual_filter_instance logger = getLogger(__name__) @@ -26,6 +27,21 @@ def _sanitize_name(input_: str | dict) -> str: def place_fixture_filters_in_scene(fixture: UsedFixture | tuple[UsedFixture, ColorSupport], filter_page: FilterPage, output_map: dict[Union[ ColorSupport, str], str] | None = None) -> bool: + """Generate fixture control filters from a given fixture. + + Purpose of the output map: A fixture has certain features (such as color segments). These features receive special + filters to drive them. Their input ports are placed in this map in order to enable higher level automated tools + (such as the effects stack system) to find them and connect to them. + + Args: + fixture: The fixture to generate filters from. + filter_page: The page to place the new filters in. + output_map: A map that should receive the mapped fixture features. + + Returns: + True if the operation was successful. + + """ # TODO output_map do nothing if isinstance(fixture, tuple): fixture = fixture[0] @@ -116,7 +132,7 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co _sanitize_name(channel.name)] = adapter_name + ":value_upper" fp.filters.append(split_filter) # if output_map is not None: - # output_map[c[c_i]] = split_filter.filter_id + ":value" #TODO + # output_map[c[c_i]] = split_filter.filter_id + ":value" #FIXME already_added_filters.append(split_filter) i += 1 elif channel.name.startswith("Red"): @@ -162,15 +178,24 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co fp.filters.append(rgb_filter) already_added_filters.append(rgb_filter) # if output_map is not None: - # output_map[c[c_i]] = adapter_name + ":value" #TODO + # output_map[c[c_i]] = adapter_name + ":value" # FIXME i += 1 - elif channel.name == "Dimmer": + elif channel.name.lower() == "dimmer" or channel.name.lower() == "intensity": dimmer_name = _sanitize_name(f"dimmer_{i}_{name}") - global_dimmer_filter = Filter(scene=fp.parent_scene, + double_channel_dimmer_required = any( + ("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) and "fine" in fc.name.lower() + for fc in fixture.fixture_channels) + global_dimmer_filter = DimmerGlobalBrightnessMixinVFilter(scene=fp.parent_scene, filter_id=dimmer_name, - filter_type=49, - pos=(x - 2 * _additional_filter_depth, - compute_filter_height(channel_count, i))) + pos=(int(x - 2 * _additional_filter_depth), + int(compute_filter_height(channel_count, i)))) + if double_channel_dimmer_required: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "true" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "false" + else: + global_dimmer_filter.filter_configurations["has_16bit_output"] = "false" + global_dimmer_filter.filter_configurations["has_8bit_output"] = "true" + global_dimmer_filter.deserialize() added_depth = max(added_depth, 2 * _additional_filter_depth) global_dimmer_found = True fp.filters.append(global_dimmer_filter) @@ -178,18 +203,29 @@ def compute_filter_height(channel_count_: int, filter_index: int, filter_chan_co already_added_filters.append(global_dimmer_filter) dimmer_name = global_dimmer_filter.filter_id x += 10 - adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") - dimmer_to_byte_filter = Filter(scene=fp.parent_scene, - filter_id=adapter_name, - filter_type=8, - pos=(x - _additional_filter_depth, - compute_filter_height(channel_count, i))) - fp.parent_scene.append_filter(dimmer_to_byte_filter) - already_added_filters.append(dimmer_to_byte_filter) - adapter_name = dimmer_to_byte_filter.filter_id - dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":brightness" - universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" - fp.filters.append(dimmer_to_byte_filter) + + if double_channel_dimmer_required: + adapter_name = _sanitize_name(f"dimmer2byte_{i}_{name}") + dimmer_to_byte_filter = Filter(scene=fp.parent_scene, + filter_id=adapter_name, + filter_type=FilterTypeEnumeration.FILTER_ADAPTER_16BIT_TO_DUAL_8BIT, + pos=(x - _additional_filter_depth, + compute_filter_height(channel_count, i))) + fp.parent_scene.append_filter(dimmer_to_byte_filter) + already_added_filters.append(dimmer_to_byte_filter) + adapter_name = dimmer_to_byte_filter.filter_id + dimmer_to_byte_filter.channel_links["value"] = dimmer_name + ":dimmer_out16b" + fp.filters.append(dimmer_to_byte_filter) + + if double_channel_dimmer_required: + universe_filter.channel_links[_sanitize_name(channel.name)] = adapter_name + ":value_upper" + for fc in fixture.fixture_channels: + if (("dimmer" in fc.name.lower() or "intensity" in fc.name.lower()) + and "fine" in fc.name.lower()): + universe_filter.channel_links[_sanitize_name(fc.name)] = adapter_name + ":value_lower" + else: + universe_filter.channel_links[_sanitize_name(channel.name)] = dimmer_name + ":dimmer_out8b" + i += 1 except IndexError: continue diff --git a/src/view/show_mode/editor/show_browser/show_browser.py b/src/view/show_mode/editor/show_browser/show_browser.py index 3de3ddf0..e0d473ca 100644 --- a/src/view/show_mode/editor/show_browser/show_browser.py +++ b/src/view/show_mode/editor/show_browser/show_browser.py @@ -41,6 +41,7 @@ class ShowBrowser: _filter_browser_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "showbrowser-filterpages.svg"))) _fader_icon = QIcon(resource_path(os.path.join("resources", "icons", "faders.svg"))) _uipage_icon = QIcon(resource_path(os.path.join("resources", "icons", "uipage.svg"))) + _dmx_default_value_tab_icon = QIcon(resource_path(os.path.join("resources", "icons", "dmx-values-default.svg"))) def __init__(self, parent: QWidget, show: BoardConfiguration, editor_tab_browser: QTabWidget) -> None: """Initialize a ShowBrowser. @@ -73,11 +74,11 @@ def __init__(self, parent: QWidget, show: BoardConfiguration, editor_tab_browser self._filter_browsing_tree.setColumnCount(1) self._tool_bar = QToolBar() - self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Scene", lambda: self._add_element_pressed()) - self._tool_bar.addAction(QIcon.fromTheme("document-properties"), "Edit", lambda: self._edit_element_pressed()) - self._tool_bar.addAction(QIcon.fromTheme("view-refresh"), "Refresh", lambda: self._refresh_all()) + self._tool_bar.addAction(QIcon.fromTheme("list-add"), "Add Scene", self._add_element_pressed) + self._tool_bar.addAction(QIcon.fromTheme("document-properties"), "Edit", self._edit_element_pressed) + self._tool_bar.addAction(QIcon.fromTheme("view-refresh"), "Refresh", self._refresh_all) self._tool_bar.addAction( - QIcon.fromTheme("document-send"), "Send showfile to fish", lambda: self._upload_showfile() + QIcon.fromTheme("document-send"), "Send showfile to fish", self._upload_showfile ) self._toolbar_edit_action = self._tool_bar.actions()[1] @@ -178,6 +179,12 @@ def add_filter_page(parent_item: AnnotatedTreeWidgetItem, fp: FilterPage) -> Non bankset_item.setIcon(0, ShowBrowser._fader_icon) bankset_item.setText(1, s.linked_bankset.description) bankset_item.annotated_data = s.linked_bankset + default_value_item = AnnotatedTreeWidgetItem(item) + default_value_item.setText(0, "Default DMX values") + default_value_item.setIcon(0, ShowBrowser._dmx_default_value_tab_icon) + default_value_item.setData(1, Qt.ItemDataRole.WhatsThisRole, "DMXDEFAULTDATA") + default_value_item.annotated_data = s + if len(s.ui_pages) < 1: s.ui_pages.append(UIPage(s)) @@ -282,7 +289,8 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: for si in items: if isinstance(si, AnnotatedTreeWidgetItem): - if isinstance(si.annotated_data, Scene): + if (isinstance(si.annotated_data, Scene) and + si.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA"): scene_to_rename = si.annotated_data self._input_dialog = QInputDialog(self.widget) self._input_dialog.setInputMode(QInputDialog.TextInput) @@ -316,11 +324,13 @@ def rename(c: ShowBrowser, scene: Scene | FilterPage, text: str) -> None: def _scene_item_double_clicked(self, item: AnnotatedTreeWidgetItem) -> None: if isinstance(item, AnnotatedTreeWidgetItem): data = item.annotated_data - if isinstance(data, Scene): + if isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) != "DMXDEFAULTDATA": self._show.broadcaster.scene_open_in_editor_requested.emit(data.pages[0]) if self._selected_scene != data: self._selected_scene = data self._refresh_filter_browser() + elif isinstance(data, Scene) and item.data(1, Qt.ItemDataRole.WhatsThisRole) == "DMXDEFAULTDATA": + self._show.broadcaster.default_dmx_value_editor_opening_requested.emit(data) elif isinstance(data, FilterPage): # TODO exchange for correct loading of page self._show.broadcaster.scene_open_in_editor_requested.emit(data) @@ -343,7 +353,14 @@ def _universe_item_double_clicked(self, item: QTreeWidgetItem) -> None: current_widget.refresh() def _upload_showfile(self) -> None: - transmit_to_fish(self._show, False) + try: + transmit_to_fish(self._show, False) + except ValueError as e: + self._input_dialog = QMessageBox(QMessageBox.Icon.Critical, "Uploading Showfile Failed", + "An error occurred while uploading the show file to fish:", + parent=self.widget, detailedText=str(e)) + self._input_dialog.setModal(True) + self._input_dialog.show() def _duplicate_scene(self, selected_items: list[QTreeWidgetItem]) -> None: i: int = 1 diff --git a/src/view/show_mode/editor/showmanager.py b/src/view/show_mode/editor/showmanager.py index 30732810..4fc6c13f 100644 --- a/src/view/show_mode/editor/showmanager.py +++ b/src/view/show_mode/editor/showmanager.py @@ -3,6 +3,7 @@ Usage (where self is a QWidget and board_configuration is a BoardConfiguration): node_editor = NodeEditor(self, board_configuration) self.addWidget(node_editor) + """ from PySide6.QtGui import QAction from PySide6.QtWidgets import QInputDialog, QSplitter, QTabBar, QTabWidget, QWidget @@ -17,6 +18,7 @@ from .editing_utils import add_scene_to_show from .editor_tab_widgets.bankset_tab import BankSetTabWidget +from .editor_tab_widgets.dmx_default_value_editor import DMXDefaultValueEditorWidget from .show_browser.show_browser import ShowBrowser @@ -24,12 +26,14 @@ class ShowEditorWidget(QSplitter): """Node Editor to create and manage filters.""" def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster, parent: QWidget) -> None: + """Initialize the widget.""" super().__init__(parent) self._broadcaster = bcaster self._board_configuration = board_configuration self._opened_pages = set() self._opened_banksets = set() self._opened_uieditors = set() + self._open_dmx_value_editors = set() # Buttons to add or remove scenes from show self._open_page_tab_widget = QTabWidget(self) @@ -67,6 +71,9 @@ def __init__(self, board_configuration: BoardConfiguration, bcaster: Broadcaster board_configuration.broadcaster.bankset_open_in_editor_requested.connect(self._add_bankset_tab) board_configuration.broadcaster.uipage_opened_in_editor_requested.connect(self._add_uipage_tab) board_configuration.broadcaster.delete_scene.connect(self._remove_tab) + board_configuration.broadcaster.default_dmx_value_editor_opening_requested.connect( + self._open_dmx_default_value_editor + ) def _select_scene_to_be_removed(self) -> None: scene_index, ok_button_pressed = QInputDialog.getInt(self, "Remove a scene", "Scene index (0-index)") @@ -75,13 +82,15 @@ def _select_scene_to_be_removed(self) -> None: @property def toolbar(self) -> list[QAction]: - """toolbar for node_mode""" + """Toolbar for node_mode.""" return self._toolbar def _tab_bar_clicked(self, index: int) -> None: """Handles adding/deleting button action. + Args: index: Index of the clicked tab + """ # Left to right, first "+" button, second "-" button if index == self._open_page_tab_widget.tabBar().count() - 1: @@ -91,9 +100,11 @@ def _add_button_clicked(self) -> None: add_scene_to_show(self, self._board_configuration) def _add_scene_tab(self, page: Scene | FilterPage) -> SceneTabWidget | None: - """Creates a tab for a scene + """Creates a tab for a scene. + Args: page: The scene to be added + """ if isinstance(page, Scene): page = page.pages[0] @@ -163,6 +174,7 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: Args: scene_or_index: The that is being removed. + """ if isinstance(scene_or_index, Scene): for index in range(self._open_page_tab_widget.count() - 1): @@ -184,8 +196,26 @@ def _remove_tab(self, scene_or_index: Scene | int) -> None: self._opened_banksets.remove(widget.bankset) elif isinstance(widget, SceneUIPageEditorWidget): self._opened_uieditors.remove(widget.ui_page) + elif isinstance(widget, DMXDefaultValueEditorWidget): + self._open_dmx_value_editors.remove(widget.scene) self._open_page_tab_widget.removeTab(scene_or_index) def _send_show_file(self) -> None: - """Send the current board configuration as a xml file to fish""" + """Send the current board configuration as a xml file to fish.""" transmit_to_fish(self._board_configuration) + + def _open_dmx_default_value_editor(self, s: Scene) -> None: + if s in self._open_dmx_value_editors: + for tab_index in range(self._open_page_tab_widget.count()): + tab = self._open_page_tab_widget.widget(tab_index) + if isinstance(tab, DMXDefaultValueEditorWidget) and tab.scene == s: + self._open_page_tab_widget.setCurrentIndex(tab_index) + return + self._open_dmx_value_editors.add(s) + tab = DMXDefaultValueEditorWidget(s, self._open_page_tab_widget) + self._open_page_tab_widget.insertTab( + self._open_page_tab_widget.tabBar().count() - 1, + tab, + s.human_readable_name + "/Defaults", + ) + self._open_page_tab_widget.setCurrentWidget(tab) diff --git a/src/view/show_mode/effect_stacks/effects_stack_editor.py b/src/view/show_mode/effect_stacks/effects_stack_editor.py index f6d67336..a391dc1a 100644 --- a/src/view/show_mode/effect_stacks/effects_stack_editor.py +++ b/src/view/show_mode/effect_stacks/effects_stack_editor.py @@ -1,4 +1,4 @@ -"""This file provides the main control widget for the filter stacking v-filter.""" +"""Provides the main control widget for the filter stacking v-filter.""" from typing import override @@ -26,9 +26,10 @@ class EffectsStackEditor(QWidget): - """This configuration widget provides an editor enabling the user to compose effect onto sockets.""" + """Configuration widget provides an editor enabling the user to compose effect onto sockets.""" def __init__(self, f: Filter, parent: QWidget | None) -> None: + """Initializes the widget.""" super().__init__(parent=parent) if not isinstance(f, EffectsStack): raise ValueError("This filter is supposed to be an instance of the EffectsStack virtual filter.") @@ -98,10 +99,10 @@ def _effect_add_button_clicked(self, e: Effect) -> None: def eventFilter(self, widget: QWidget, event: QEvent) -> bool: if event.type() == QEvent.KeyPress and widget is self._effect_placement_bar: key = event.key() - if key in [Qt.Key_Return, Qt.Key_Enter]: + if key in [Qt.Key.Key_Return, Qt.Key.Key_Enter]: self._compilation_widget.add_effect_to_slot(self._effect_placement_bar.value()) return True - if key in [Qt.Key_Escape]: + if key == Qt.Key.Key_Escape: self._compilation_widget.load_effect_to_add(None) self._effect_placement_bar.setEnabled(False) self._effect_placement_bar.setVisible(False) diff --git a/src/view/show_mode/show_ui_widgets/__init__.py b/src/view/show_mode/show_ui_widgets/__init__.py index f8e02d26..32ae3182 100644 --- a/src/view/show_mode/show_ui_widgets/__init__.py +++ b/src/view/show_mode/show_ui_widgets/__init__.py @@ -117,7 +117,7 @@ def filter_to_ui_widget( We used to construct widgets this way, but the WIDGET_LIBRARY method should be used instead. """ - selected_configuration = configuration if configuration else {} + selected_configuration = configuration or {} match filter_.filter_type: case 0 | 1 | 2: # number constants diff --git a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py index e0b99638..e707dd26 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/auto_track_dialog_widget.py @@ -1,3 +1,5 @@ +"""Module contains auto tracker control widget.""" + from typing import TYPE_CHECKING from PySide6.QtCore import QTimer @@ -18,8 +20,7 @@ class AutoTrackDialogWidget(QTabWidget): - """ - The `MainWindow` class represents the main application window. + """The `MainWindow` class represents the main application window. Attributes: instance (InstanceManager): An instance manager for managing application instances and settings. @@ -30,12 +31,11 @@ class AutoTrackDialogWidget(QTabWidget): - `video_update_all()`: Update video content for all active tabs. - `tab_changed(index)`: Handle tab change events. - `register_tabs(tab_widget, tabs)`: Register tabs in the main window. + """ def __init__(self, f: "AutoTrackerFilter", provided_instance: InstanceManager | None) -> None: - """ - Initialize the main application window. - """ + """Initialize the main application window.""" super().__init__() if not provided_instance: # We're constructing the player widget @@ -64,9 +64,7 @@ def __init__(self, f: "AutoTrackerFilter", provided_instance: InstanceManager | self.video_timer.start(1) def video_update_all(self) -> None: - """ - Update video content for all active tabs. - """ + """Update video content for all active tabs.""" for i in range(self.count()): tab = self.widget(i) if isinstance(tab, GuiTab): @@ -74,11 +72,11 @@ def video_update_all(self) -> None: # TODO call generate_update_content from ui widget def tab_changed(self, index: int) -> None: - """ - Handle tab change events. + """Handle tab change events. Args: - index (int): The index of the selected tab. + index: The index of the selected tab. + """ for i in range(self.count()): tab = self.widget(i) @@ -86,16 +84,16 @@ def tab_changed(self, index: int) -> None: tab.tab_changed(index) def register_tabs(self, tabs: list[GuiTab]) -> None: - """ - Register tabs in the main window. + """Register tabs in the main window. Args: - tabs : A list of tab objects to register. + tabs: A list of tab objects to register. + """ first = True for tab in tabs: self.addTab(tab, tab.name) - tab.id = self.count() - 1 + tab.tab_id = self.count() - 1 if first: tab.tab_activated() first = False diff --git a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py index bcfc7ffb..8f70db0e 100644 --- a/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py +++ b/src/view/show_mode/show_ui_widgets/autotracker/gui_tab.py @@ -1,11 +1,12 @@ +"""Module contains auto tracker GUI tab implementation.""" + from PySide6.QtWidgets import QWidget from controller.autotrack.Helpers.InstanceManager import InstanceManager class GuiTab(QWidget): - """ - The `GuiTab` class represents a tab within a graphical user interface. + """The `GuiTab` class represents a tab within a graphical user interface. Attributes: _id (int): An internal identifier for the tab. @@ -20,15 +21,16 @@ class GuiTab(QWidget): - `tab_deactivated()`: Called when the tab is deactivated. - `id`: Property for getting or setting the tab's internal identifier. - `video_update()`: Abstract method for updating video content within the tab. + """ def __init__(self, name: str, instance: InstanceManager) -> None: - """ - Initialize a GuiTab object. + """Initialize a GuiTab object. Args: - name (str): The name of the tab. + name: The name of the tab. instance: An instance associated with the tab. + """ super().__init__() self._id: int = -1 @@ -37,44 +39,38 @@ def __init__(self, name: str, instance: InstanceManager) -> None: self.active = False def tab_changed(self, index: int) -> None: - """ - Callback when the active tab is changed. + """Callback when the active tab is changed. Args: index (int): The new index of the tab. + """ - if index == self.id: + if index == self.tab_id: self.tab_activated() else: self.tab_deactivated() def tab_activated(self) -> None: - """ - Called when the tab is activated. - """ + """Called when the tab is activated.""" self.active = True def tab_deactivated(self) -> None: - """ - Called when the tab is deactivated. - """ + """Called when the tab is deactivated.""" self.active = False @property - def id(self) -> int: - """ - Get or set the tab's internal identifier. + def tab_id(self) -> int: + """Get or set the tab's internal identifier. Returns: int: The internal identifier of the tab. + """ return self._id - @id.setter - def id(self, value: int) -> None: + @tab_id.setter + def tab_id(self, value: int) -> None: self._id = value def video_update(self) -> None: - """ - Abstract method for updating video content within the tab. - """ + """Abstract method for updating video content within the tab.""" diff --git a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py index 599926b6..d88b21d6 100644 --- a/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py +++ b/src/view/show_mode/show_ui_widgets/color_selection_uiwidget.py @@ -1,3 +1,5 @@ +"""Module contains color selection widget.""" + from typing import override from PySide6.QtGui import QAction, QColor @@ -12,12 +14,20 @@ QWidget, ) -from model import ColorHSI, Filter, UIPage, UIWidget +from model import Filter, UIPage, UIWidget +from model.color_hsi import ColorHSI class ColorSelectionUIWidget(UIWidget): + """UI widget allowing the user to select a color.""" def __init__(self, parent: UIPage, configuration: dict[str, str]) -> None: + """Initialize the widget. + + Configuration Parameters: number_of_presets (integer) -- How many presets should be available in total? + stored_presets: ; separated list of filter-encoded colors. + + """ super().__init__(parent, configuration) self._value: ColorHSI = ColorHSI.from_filter_str("") @@ -30,6 +40,7 @@ def __init__(self, parent: UIPage, configuration: dict[str, str]) -> None: self._config_widget: QWidget | None = None self._filter = None + @override def set_filter(self, f: Filter, i: int) -> None: if not f: return @@ -42,7 +53,7 @@ def set_filter(self, f: Filter, i: int) -> None: def generate_update_content(self) -> list[tuple[str, str]]: return [("value", self._value.format_for_filter())] - def push_value(self, new_value: ColorHSI) -> None: + def _push_value(self, new_value: ColorHSI) -> None: self._value = new_value self.push_update() @@ -79,7 +90,7 @@ def _build_base_widget(self, parent: QWidget, for_player: bool) -> QWidget: column_layout.addWidget(color_label) transmit_button = QPushButton("Set", w) transmit_button.setEnabled(for_player) - transmit_button.clicked.connect(lambda _i=i: self.push_value(self._presets[_i])) + transmit_button.clicked.connect(lambda _i=i: self._push_value(self._presets[_i])) column_layout.addWidget(transmit_button) layout.addLayout(column_layout) self._presets: list[ColorHSI] = color_presets diff --git a/src/view/show_mode/show_ui_widgets/cue_control.py b/src/view/show_mode/show_ui_widgets/cue_control.py index 7a3c5981..d67f5a07 100644 --- a/src/view/show_mode/show_ui_widgets/cue_control.py +++ b/src/view/show_mode/show_ui_widgets/cue_control.py @@ -152,7 +152,7 @@ def _repopulate_lists(self) -> None: cue_list.clear() for cue in self._cues: item = AnnotatedListWidgetItem(cue_list) - label = _CueLabel(cue_list, cue[0] if cue[0] else "No Name") + label = _CueLabel(cue_list, cue[0] or "No Name") item.annotated_data = cue item.setSizeHint(label.sizeHint()) cue_list.addItem(item) diff --git a/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py b/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py index 0e9a02d4..3fa30cbc 100644 --- a/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py +++ b/src/view/show_mode/show_ui_widgets/debug_viz_widgets.py @@ -15,7 +15,8 @@ from PySide6.QtGui import QColor, QPainter, QPaintEvent from PySide6.QtWidgets import QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QSpinBox, QWidget -from model import ColorHSI, UIWidget +from model import UIWidget +from model.color_hsi import ColorHSI if TYPE_CHECKING: from collections.abc import Callable diff --git a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py index cae5ab09..33bcbe79 100644 --- a/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py +++ b/src/view/show_mode/show_ui_widgets/macro_buttons_ui_widget.py @@ -28,6 +28,7 @@ from model.media_assets.registry import get_asset_by_uuid from utility import resource_path from view.action_setup_view._command_insertion_dialog import escape_argument +from view.dialogs.asset_selection_dialog import AssetSelectionDialog from view.show_mode.editor.editor_tab_widgets.ui_widget_editor._widget_holder import UIWidgetHolder from view.show_mode.editor.show_browser.annotated_item import AnnotatedListWidgetItem from view.utility_widgets.asset_selection_widget import AssetSelectionWidget @@ -35,6 +36,7 @@ if TYPE_CHECKING: from model import UIPage + from model.media_assets.asset import MediaAsset class _AddMacroActionDialog(QDialog): @@ -108,12 +110,9 @@ def __init__(self, parent: QListWidget, item_def: dict[str, str], index: int, up self._icon_bt.setIcon(self._NO_ICON) layout.addWidget(self._icon_bt) layout.addStretch() - icon_label = QLabel(self) - if self._item_def.get("icon", "") != "": - asset = get_asset_by_uuid(self._item_def["icon"]) - if asset is not None: - icon_label.setPixmap(asset.get_thumbnail()) - layout.addWidget(icon_label) + self.icon_label = QLabel(self) + self._update_displayed_icon() + layout.addWidget(self.icon_label) layout.addStretch() self._text_tb = QLineEdit(self) self._text_tb.setText(item_def["text"]) @@ -125,7 +124,18 @@ def __init__(self, parent: QListWidget, item_def: dict[str, str], index: int, up self._command_tb.textChanged.connect(self._command_changed) layout.addWidget(self._command_tb) self.setLayout(layout) - # TODO implement icon changing functionality + self._dialog: QDialog | None = None + self._icon_bt.pressed.connect(self._change_icon_of_button) + + def _update_displayed_icon(self) -> None: + found_icon = False + if self._item_def.get("icon", "") != "": + asset = get_asset_by_uuid(self._item_def["icon"]) + if asset is not None: + self.icon_label.setPixmap(asset.get_thumbnail()) + found_icon = True + if not found_icon: + self.icon_label.setPixmap(self._NO_ICON.pixmap(64, 64)) def _text_changed(self, text: str) -> None: self._item_def["text"] = text @@ -135,6 +145,24 @@ def _command_changed(self, text: str) -> None: self._item_def["command"] = text self._update_button.setEnabled(True) + def _change_icon_of_button(self) -> None: + self._dialog = AssetSelectionDialog(self, allowed_types=[MediaType.IMAGE], multiselection_allowed=False) + self._dialog.setModal(True) + self._dialog.asset_selected.connect(self._icon_changed) + self._dialog.open() + + def _icon_changed(self, asset: list[MediaAsset]) -> None: + self._dialog = None + if len(asset) == 0: + asset_id = "" + else: + asset = asset[-1] + asset_id = asset.id if asset is not None else "" + if asset_id != self._item_def.get("icon", ""): + self._update_button.setEnabled(True) + self._item_def["icon"] = asset_id + self._update_displayed_icon() + @property def item_def(self) -> dict[str, str]: return self._item_def diff --git a/src/view/utility_widgets/asset_selection_widget.py b/src/view/utility_widgets/asset_selection_widget.py index fd157ab7..8296d9b0 100644 --- a/src/view/utility_widgets/asset_selection_widget.py +++ b/src/view/utility_widgets/asset_selection_widget.py @@ -5,7 +5,7 @@ import os from typing import TYPE_CHECKING, Any, override -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal from PySide6.QtGui import QAction, QIcon from PySide6.QtWidgets import QAbstractItemView, QLineEdit, QTableView, QToolBar, QVBoxLayout, QWidget @@ -148,6 +148,8 @@ def get_row_indicies(self, assets: list[MediaAsset]) -> list[int]: class AssetSelectionWidget(QWidget): """Provide a sortable and searchable selection widget for assets.""" + asset_selection_changed: Signal = Signal() + def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] | None = None, multiselection_allowed: bool = True) -> None: """Initialize the asset selection widget. @@ -199,6 +201,7 @@ def __init__(self, parent: QWidget | None = None, allowed_types: list[MediaType] self.setLayout(layout) self._update_filter() + self._asset_view.selectionModel().selectionChanged.connect(self.asset_selection_changed.emit) def _update_filter(self, force: bool = False) -> None: selected_types: set[MediaType] = set() diff --git a/submodules/docs b/submodules/docs index 9726b4c9..9fe92bdf 160000 --- a/submodules/docs +++ b/submodules/docs @@ -1 +1 @@ -Subproject commit 9726b4c9196c1ebca838622dc8ec8eb4709674bd +Subproject commit 9fe92bdfcd76a45545b70d11c1aef9733d5e8e7a diff --git a/submodules/resources b/submodules/resources index 8567afa0..637a2861 160000 --- a/submodules/resources +++ b/submodules/resources @@ -1 +1 @@ -Subproject commit 8567afa061ae0a926c53b36081bf4908f91b9bb1 +Subproject commit 637a2861a89cf43d2677d62e7672054c742fba4b diff --git a/test/unittests/.gitignore b/test/unittests/.gitignore new file mode 100644 index 00000000..f85c6b18 --- /dev/null +++ b/test/unittests/.gitignore @@ -0,0 +1 @@ +config.py \ No newline at end of file diff --git a/test/unittests/__init__.py b/test/unittests/__init__.py new file mode 100644 index 00000000..dc2612aa --- /dev/null +++ b/test/unittests/__init__.py @@ -0,0 +1 @@ +"""Unit test collection.""" \ No newline at end of file diff --git a/test/unittests/test_dimmer_brightness_mixin_vfilter.py b/test/unittests/test_dimmer_brightness_mixin_vfilter.py new file mode 100644 index 00000000..42480a73 --- /dev/null +++ b/test/unittests/test_dimmer_brightness_mixin_vfilter.py @@ -0,0 +1,96 @@ +"""Unit test for dimmer brightness mixin.""" +import logging +import unittest +from logging import getLogger, basicConfig + +from model import BoardConfiguration, Scene, Filter +from model.filter import FilterTypeEnumeration +from model.virtual_filters.range_adapters import DimmerGlobalBrightnessMixinVFilter +from test.unittests.utilities import execute_board_configuration + + +logger = getLogger(__name__) + + +class DimmerBrightnessMixinTest(unittest.TestCase): + """Unit test for dimmer brightness mixin.""" + + def _prepare_show_config(self) -> tuple[BoardConfiguration, list[tuple[str, float]]]: + show = BoardConfiguration() + scene = Scene(0, "Test Scene for dimmer brightness mixin", show) + show._add_scene(scene) + output_list = [] + row: int = 0 + + def create_input(method: str, mf: DimmerGlobalBrightnessMixinVFilter, is_mixin: bool) -> str: + mf.filter_configurations["input_method_mixin" if is_mixin else "input_method"] = method.replace("-", "") + if "-" not in method: + input_filter = Filter( + scene, + f"input_filter_{row}_{method}_{"mixin" if is_mixin else "input"}", + FilterTypeEnumeration.FILTER_CONSTANT_8BIT if method == "8bit" else FilterTypeEnumeration.FILTER_CONSTANT_16_BIT, + pos=(-10, row * 15 + (5 if is_mixin else -5)) + ) + input_filter.initial_parameters["value"] = str(255 / 2) if method == "8bit" else str(65565 / 2) + scene.append_filter(input_filter) + mf.channel_links["mixin" if is_mixin else "input"] = f"{input_filter.filter_id}:value" + + def create_output(is_8b: bool): + output_filter = Filter( + scene, f"output_{"8b" if is_8b else "16b"}_{row}", FilterTypeEnumeration.FILTER_REMOTE_DEBUG_8BIT if is_8b else FilterTypeEnumeration.FILTER_REMOTE_DEBUG_16BIT, + pos=(5, row * 15 + (-5 if is_8b else 5)) + ) + scene.append_filter(output_filter) + output_filter.channel_links["value"] = mixin_filter.filter_id + (":dimmer_out8b" if is_8b else ":dimmer_out16b") + expected_output = (255 if is_8b else 65565) * calculate_dimmer_val(input_method, mixin_method, has_offset) + logger.info("Registering output %s => %s.", output_filter.filter_id, expected_output) + output_list.append( + (output_filter.filter_id, expected_output)) + + def calculate_dimmer_val(in_m: str, mixin_m: str, has_offset: bool) -> float: + in_stream = 1.0 if "-" in in_m else 0.5 + mixin_stream = 1.0 + offset_stream = 0.25 if has_offset else 0.0 + return max(0.0, min(1.0, in_stream * mixin_stream + offset_stream)) + + for input_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for mixin_method in ["-8bit", "-16bit", "8bit", "16bit"]: + for output_8b_enabled in [True, False]: + for output_16b_enabled in [True, False]: + for has_offset in [True, False]: + logger.info("Creating Config Row: %s, Input: %s, Mixin: %s, Out 8bit: %s, Out 16bit: %s, has Offset: %s", row, input_method, mixin_method, output_8b_enabled, output_16b_enabled, has_offset) + mixin_filter = DimmerGlobalBrightnessMixinVFilter(scene, f"mixin_filter_r{row}", (0, row * 15)) + create_input(input_method, mixin_filter, False) + create_input(mixin_method, mixin_filter, True) + + if has_offset: + offset_filter = Filter( + scene, f"offset_filter_{row}", FilterTypeEnumeration.FILTER_CONSTANT_FLOAT, + pos=(-5, row * 15) + ) + offset_filter.initial_parameters["value"] = "0.25" + scene.append_filter(offset_filter) + mixin_filter.channel_links["offset"] = offset_filter.filter_id + ":value" + + scene.append_filter(mixin_filter) + + if output_8b_enabled: + create_output(True) + if output_16b_enabled: + create_output(False) + row += 1 + return show, output_list + + def test_inst(self): + """Test instanciation and results of v filter.""" + basicConfig(level=logging.DEBUG) + show, expected_output_list = self._prepare_show_config() + recorded_output_list = [] + expected_output_dict = {} + for key, expected_val in expected_output_list: + expected_output_dict[key] = expected_val + self.assertTrue(execute_board_configuration(show, recorded_gui_updates=recorded_output_list)) + for scene_id, filter_id, key, value_str in recorded_output_list: + self.assertEqual(scene_id, 0, "Expected scene ID to be 0.") + self.assertTrue(expected_output_dict[filter_id] - 5 < int(value_str) < expected_output_dict[filter_id] + 5, + f"Expected configuration of {filter_id} to be {expected_output_dict[filter_id]} but got {value_str} instead.") diff --git a/test/unittests/utilities.py b/test/unittests/utilities.py new file mode 100644 index 00000000..545890cb --- /dev/null +++ b/test/unittests/utilities.py @@ -0,0 +1,118 @@ +"""Unit test utils. + +This module requires a config.py to be in the same package. This config file needs to contain a variable called +FISH_EXEC_PATH pointing to a local fish binary. +""" +import os.path +import subprocess +from logging import getLogger +from time import sleep + +from PySide6.QtWidgets import QApplication + +import proto.FilterMode_pb2 +from controller.file.serializing.general_serialization import create_xml +from controller.network import NetworkManager +from controller.utils.process_notifications import get_process_notifier +from model import BoardConfiguration, Broadcaster + +from .config import FISH_EXEC_PATH + +logger = getLogger(__name__) + +def _stall_until_stdout_reached_target(process: subprocess.Popen, target: str): + while True: + output = process.stdout.readline() + if not output and process.poll() is not None: + return False + if target in output: + return True + + +_last_error_message = "" +_GOOD_MESSAGES = ["No Error occured", "Showfile Applied."] + +def execute_board_configuration(bc: BoardConfiguration, cycles: int = 25, recorded_gui_updates: list[tuple[int, str, str, str]] | None = None, main_brightness: int = 65565) -> bool: + """Execute a board configuration. + + This method starts a fish instance, connects to it, uploads a board configuration and enables filter execution. + If after the specified amount of iterations, no error occurred, the method stops, returning true. Otherwise, false. + + Args: + bc: The board configuration to apply + cycles: The amount of cycles to wait + recorded_gui_updates: If A list is provided, any GUI updates received during execution are stored in there. + main_brightness: The Main brightness value used for the test. + + Returns: + True if no error occurred during execution. Otherwise, false. + + """ + global _last_error_message + _last_error_message = _GOOD_MESSAGES[0] + logger.debug("Starting fish...") + if os.path.exists("/tmp/fish.sock"): + logger.warning("Removing fish socket.") + os.remove("/tmp/fish.sock") + process = subprocess.Popen( + [FISH_EXEC_PATH], + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + _stall_until_stdout_reached_target(process, "[debug] Entering ev defloop") + logger.debug("Starting QApplication...") + application = QApplication([]) + logger.debug("Starting network manager.") + nm = NetworkManager() + def fish_error_received(msg: str): + global _last_error_message + if msg != _last_error_message: + if msg not in _GOOD_MESSAGES: + logger.error("Received error from fish: '%s'", msg) + _last_error_message = msg + nm.status_updated.connect(fish_error_received) + broadcaster = Broadcaster() + application.processEvents() + logger.debug("Connecting to fish...") + nm.start() + application.processEvents() + error_occurred: bool = False + + def receive_update_from_fish(msg: proto.FilterMode_pb2.update_parameter): + if recorded_gui_updates is not None: + recorded_gui_updates.append((msg.scene_id, msg.filter_id, msg.parameter_key, msg.parameter_value)) + + broadcaster.update_filter_parameter.connect(receive_update_from_fish) + for i in range(10): + application.processEvents() + sleep(0.01) + pn = get_process_notifier("test upload notifier", 300) + logger.info("Creating Show XML...") + xml = create_xml(bc, pn, assemble_for_fish_loading=True) + logger.info("Sending it to fish...") + nm.transmit_show_file(xml, True) + while _last_error_message == _GOOD_MESSAGES[0] and nm.connection_state(): + application.processEvents() + sleep(0.01) + if _last_error_message != _GOOD_MESSAGES[1]: + logger.error("Final error state from fish was: %s. Failing execution.", _last_error_message) + error_occurred = True + nm.set_main_brightness_fader_position(int((main_brightness / 65565) * 255)) + logger.info("Evaluating execution...") + if not error_occurred: + for i in range(cycles * 4): + application.processEvents() + sleep(0.01) + logger.info("Ending execution...") + nm.disconnect() + sleep(1) + del broadcaster + del nm + del application + process.stdin.write("k\n") + process.communicate() + if process.returncode != 0: + logger.error("Fish terminated with code %s", process.returncode) + return not error_occurred