"""Workspace module. Contains the main Workspace implementation. """ import datetime as dt import json import subprocess as subp import xml.etree.ElementTree as et from .constants import AUDIO, SCENE, BXW from .functions.function import Function from .exceptions import LoadError from .interfaces import XMLSerializable def ffprobe_audio_length(f: str, path: str = "ffprobe") -> int: """Use ffprobe to check audio length in milliseconds. Will always return the nearest whole millisecond greater than or equal to the duration. Parameters: f: the path to check path: the path of ffprobe """ try: a = subp.check_output([path, "-show_format", "-print_format", "json", f], stderr=subp.DEVNULL) except subp.CalledProcessError: return 0 return int(1000*float(json.loads(a)["format"]["duration"])+0.5) class Workspace(XMLSerializable): """Class representing a audiovisual workspace.""" def __init__(self, name: str, author: str, version: int, modified: dt.datetime): self.name = name self.author = author self.version = version self.modified = modified self.fixtures = {} self._last_fixture_id = -1 self.functions = {} self._last_function_id = -1 self._audio_lengths = {} self._change_callbacks = {} self._delete_callbacks = {} def get_audio_length(self, filename: str) -> int: """Determine the audio length of the given file. This value is returned from the cache, if available. """ if filename not in self._audio_lengths: self._audio_lengths[filename] = ffprobe_audio_length(filename) return self._audio_lengths[filename] def recheck_audio_length(self, filename: str) -> int: """Determine the audio length of the given file. This function re-probes the value, updating the cache result and the durations of Audio functions. """ self._audio_lengths[filename] = ffprobe_audio_length(filename) for f in self.functions.values(): if f.type == AUDIO and f.filename == filename: f.duration = self._audio_lengths[f.filename] return self._audio_lengths[filename] def recheck_audio_lengths(self): """Recheck and update all audio lengths.""" for filename in self._audio_lengths: self._audio_lengths[filename] = ffprobe_audio_length(filename) for f in self.functions.values(): if f.type == AUDIO and f.filename in self._audio_lengths: f.filename = f.filename def register_fixture(self, f: "Fixture"): """Register the fixture in the Workspace. Always called when the fixture is instantiated. """ if f.id in self.fixtures: raise ValueError("A fixture with that ID already exists") self.fixtures[f.id] = f def register_function(self, f: Function): """Register the function in the Workspace. Always called when the function is instantiated. """ if f.id in self.functions: raise ValueError("A function with that ID already exists") self.functions[f.id] = f @property def next_fixture_id(self): """Return the next fixture ID.""" return self._last_fixture_id + 1 @property def next_function_id(self): """Return the next function ID.""" return self._last_function_id + 1 def delete_channel(self, c: "Fixture.Channel"): """Notify that the given channel was deleted. This is used for removing deleted channels from functions. """ for f in self.functions.values(): if f.type == SCENE and c in f.scope: f.delete_channel(c) def delete_callbacks(self, owner, f: int = None): """Remove all callbacks registered by the owner. :param f: the function to remove from (all if None) """ if isinstance(f, Function): f = f.id for g in self._change_callbacks: if f is None or g == f: self._change_callbacks[g] = [i for i in self._change_callbacks[g] if i[0] != owner] for g in self._delete_callbacks: if f is None or g == f: self._delete_callbacks[g] = [i for i in self._delete_callbacks[g] if i[0] != owner] def register_function_change_callback(self, f: int, callback, owner = None): """Register a callback for a function change. :param f: the ID of the function to monitor :param callback: a function to call, accepting the Function :param owner: optional ID to use for removal of the callback later """ if isinstance(f, Function): f = f.id if f not in self._change_callbacks: self._change_callbacks[f] = [] self._change_callbacks[f].append((owner, callback)) def register_function_delete_callback(self, f: int, callback, owner = None): """Register a callback for a function deletion. :param f: the ID of the function to monitor :param callback: a function to call, accepting the Function :param owner: optional ID to use for removal of the callback later """ if isinstance(f, Function): f = f.id if f not in self._delete_callbacks: self._delete_callbacks[f] = [] self._delete_callbacks[f].append((owner, callback)) def function_changed(self, f: Function): """Called when a function is changed. :param f: the changed function """ for callback in self._change_callbacks[f.id]: callback(f) def function_deleted(self, f: Function): """Called when a function is deleted. This also handles removing the function from the Workspace. """ for callback in self._delete_callbacks[f.id]: callback(f) self.delete_callbacks(f) del self.functions[f.id] @classmethod def load(cls, filename): """Load the workspace from a file.""" tree = et.parse(filename) root = tree.getroot() return cls.deserialize(None, root) @classmethod def deserialize(cls, w, e): from .topology import Fixture from .functions.scene import Scene from .functions.audio import Audio if e.tag != BXW+"workspace": raise LoadError("Root tag must be workspace") try: name, author, version, modified, fixtures, functions = e except ValueError: raise LoadError("Invalid workspace layout") ## First load the metadata so we can create the workspace name = name.text author = author.text version = int(version.text) modified = dt.datetime.fromisoformat(modified.text) w = cls(name=name, author=author, version=version, modified=modified) ## Now load the fixtures for fixture in fixtures: Fixture.deserialize(w, fixture) ## Finally, load the functions for function in functions: type_ = function.get("type") if type_ == AUDIO: Audio.deserialize(w, function) elif type_ == SCENE: Scene.deserialize(w, function) else: raise LoadError("Unknown function type \"%s\"" % type_) return w def serialize(self) -> et.Element: root = et.Element(BXW+"workspace") et.SubElement(root, BXW+"name").text = self.name et.SubElement(root, BXW+"author").text = self.author et.SubElement(root, BXW+"version").text = str(self.version) et.SubElement(root, BXW+"modified").text = dt.datetime.now().isoformat() fixtures = et.SubElement(root, BXW+"fixtures") for n, fixture in enumerate(self.fixtures.values()): fe = fixture.serialize() fixtures.insert(n, fe) functions = et.SubElement(root, BXW+"functions") for n, function in enumerate(self.functions.values()): fe = function.serialize() functions.insert(n, fe) return root def save(self, filename): """Save the workspace to a file.""" et.register_namespace("", BXW.strip("{}")) root = self.serialize() XMLSerializable.indent(root) tree = et.ElementTree(element=root) tree.write(filename, encoding="unicode")