From bb9e61aaf7c86d27ef24cfc1c3d4b7f0baadbf89 Mon Sep 17 00:00:00 2001 From: Ben Connors Date: Wed, 23 Jan 2019 15:29:51 -0500 Subject: Fixes; untested Chaser pre-rendering - General fixes, cleanup, and commenting - Add untested Chaser pre-rendering --- workspace.py | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 160 insertions(+), 24 deletions(-) diff --git a/workspace.py b/workspace.py index 2b0b9a8..ed40634 100755 --- a/workspace.py +++ b/workspace.py @@ -45,7 +45,7 @@ creation time and will not be updated. replicate the QLC+ behavior, hook up a random number generator to the fading and go nuts. 8. Show fading: QLC+ tends to cut fade-outs that overlap with other steps on the same track; - seeing as QLC+ lacks any fade-out logic, BLC holds fades as long as they say. + seeing as QLC+ lacks any fade-out logic, BLC holds fades as long as they specify. 9? Precedence: BLC adopts a highest-takes-precedence doctrine when determining what level lights should be held at. This may be different than QLC+. @@ -65,8 +65,12 @@ form: Where values is as returned by a single call to the Show's render method. The values given at a certain time index must be held until the next time index. -A Chaser may be pre-rendered provided it has no infinite-length fades. A Chaser's prerender -method will return an iterable of iterables of the form: +A Chaser may be pre-rendered provided it satisfies: + + - No infinite length fades + - All steps of infinite-length are Scenes + +Chaser's render_all method will return an iterable of iterables of the form: [[(time, values), ...], ...] @@ -74,7 +78,8 @@ Each block of (time, values) pairs represents an infinite segment in the chaser, value in each block should be held until some condition becomes true. This does restrict the chaser in that steps of finite length cannot be skipped, so take this into account. In all but the first step, values does not necessarily have a value for each channel in the show's scope; -it gives only the changed values at that time. +it gives only the changed values at that time. Note also that this only supports the rendering +of single-shot chasers presently. Additionally, time is reset to 0 at the start of each block. # General Notes for Implementation @@ -83,14 +88,22 @@ it gives only the changed values at that time. render not only the current time index but also all time indexes until the value actually changes. render_all fixes this by returning only changed values, but still renders every time index during fades. If rendering shows "live", i.e. without pre-rendering, I recommend taking - nx = max(nx, m) for some m > 10 (e.g. ~16 for 60 Hz DMX). + nx = max(nx, m) for some m > 10 (e.g. ~16 for 60 Hz DMX), as rendering faster than the + transmission rate of your connection is pointless. + +- This library is thread-safe except for the Function "data" objects: these objects may only be + used in one thread at a time. + +- The hash function on each class is likely to be slow: use it to prevent running an even slower + operation if a function hasn't changed; a Function's hash will be consistent as long as the + workspace on disk doesn't change """ from abc import ABC, abstractmethod import json from multiprocessing.pool import ThreadPool import subprocess as subp -import warnings +import logging from lxml import etree @@ -139,6 +152,10 @@ class Fixture: May be composed of multiple channels. """ + def __hash__(self): + return hash((self.name, self.address_start, self.channel_count, self.mode, self.id, + self.universe)) + def __repr__(self): return "Fixture(id=%d, name=%s, universe=%d, start=%d, channels=%d)" % (self.id, self.name, self.universe.id, self.address_start, self.channel_count) @@ -153,6 +170,9 @@ class Fixture: class Channel: """Class representing a single output channel.""" + def __hash__(self): + return hash((self.fixture, self.offset, self.address)) + def __repr__(self): return "Channel(address=%d)" % (self.address) @@ -166,6 +186,9 @@ class Channel: class ChannelGroup: """Class representing a group of output channels.""" + def __hash__(self): + return hash(self.id, self.name, self.channels) + def __repr__(self): return "ChannelGroup(id=%d, name=%s, channels=(%s))" % (self.id, self.name, ", ".join((repr(c) for c in self.channels))) @@ -177,6 +200,9 @@ class ChannelGroup: class Universe: """Class representing an output universe.""" + def __hash__(self): + return hash((self.id, self.name)) + def __repr__(self): return "Universe(id=%d, name=%s)" % (self.id, self.name) @@ -208,6 +234,9 @@ class Function(ABC): require the caller to store that state. """ repr_attr = ("id", "name",) + def __hash__(self): + return hash((self.id, self.type, self.name, self.scope, self.hidden, self.duration, + self.actual_duration)) @staticmethod def get_data(): @@ -271,6 +300,9 @@ class Function(ABC): class FadeFunction(Function): """QLC function that can fade in/out.""" + def __hash__(self): + return hash((super().__hash__(), self.fade_in, self.fade_out)) + def __init__(self, id_, type_, name, scope, hidden=False, duration=-1, actual_duration=-1, fade_in=0, fade_out=0): if fade_in >= QLC_INFTY or fade_out >= QLC_INFTY: raise ValueError("Fades cannot be infinite") @@ -285,6 +317,8 @@ class FadeFunction(Function): class Audio(FadeFunction): """Class for a QLC+ audio function.""" repr_attr = ("id", "fname", "fade_in", "fade_out",) + def __hash__(self): + return hash((super().__hash__(), self.fname, self.run_order)) def render(self, t, data=None): """Render the audio function. @@ -312,6 +346,9 @@ class Scene(Function): Scenes are mostly meaningless on their own in this context, they must be attached to a chaser/show to do anything. """ + def __hash__(self): + return hash((super().__hash__(), self.values)) + def render(self, t, data=None): """All arguments are unused.""" return self.values, (), QLC_INFTY, None @@ -330,6 +367,9 @@ class ChaserStep(FadeFunction): self.start_time = start_time self.end_time = end_time + def __hash__(self): + return hash((super().__hash__(), self.hold, self.function)) + def get_data(self, start_time=0): return self.ChaserStepData(fd=self.function.get_data(), start_time=start_time, end_time=self.duration) @@ -393,6 +433,9 @@ class Chaser(Function): self.step_data = step_data self.obey_loop = obey_loop + def __hash__(self): + return hash((super().__hash__(), self.steps, self.run_order, self.direction)) + @staticmethod def advance(t, data): """End the current chaser step. @@ -477,6 +520,56 @@ class Chaser(Function): return tuple(vals.items()), tuple(sorted(acues, key=lambda a: a[0])), nx, data + def render_all(self, minnx=1): + """Render the entire Chaser.""" + ## Verify that we can render this one + if self.run_order != SINGLESHOT: + raise ValueError("Can only render SingleShot Chasers") + for s in self.steps: + if s.hold == QLC_INFTY and s.function.actual_duration == QLC_INFTY: + raise ValueError("Cannot render Chaser with infinite hold of infinite function") + elif QLC_INFTY in (s.fade_in, s.fade_out): + raise ValueError("Cannot render Chaser with infinite fades") + + steps = [] + + t = 0 + start_time = 0 + data = None + current = [] + acurrent = [] + + values = {c: 0 for c in self.scope} + + ## We're gonna have to break encapsulation here + while True: + vals, acues, nx, data = self.render(t, data=data) + changes = [] + for c,v in vals: + if t == 0 or values[c] != v: + values[c] = v + changes.append((c, v)) + current.append((t-start_time, tuple(changes))) + + acurrent += [(t-start_time, *others) for t,*others in acues] + + if nx == -1 or nx >= QLC_INFTY: + ## Done the current step + steps.append((tuple(current), tuple(acurrent))) + if nx == -1: + ## Done + break + ## Reached an infinite segment, advance + current = [] + acurrent = [] + t += 1 + start_time = t + data = self.advance(t, data) + else: + t += max(minnx, nx) + + return tuple(steps) + def __init__(self, id_, name, steps, hidden=False, run_order=SINGLESHOT, direction=FORWARD): if run_order not in (LOOP, SINGLESHOT): raise NotImplementedError("Only Loop and SingleShot chasers are currently supported") @@ -500,10 +593,14 @@ class Chaser(Function): duration=dur, actual_duration=max_t) self.steps = tuple(steps) self.run_order = run_order + self.direction = direction class ShowFunction(Function): """Class for representing a function in a show.""" repr_attr = ("id", "name", "start_time", ("function", lambda f: f.id)) + def __hash__(self): + return hash((super().__hash__(), self.function, self.start_time)) + def render(self, t, data=None): if data is None: data = self.function.get_data() @@ -522,6 +619,9 @@ class ShowFunction(Function): class ShowTrack(Function): """Class for representing a track in a show.""" repr_attr = ("id", "name", ("functions", lambda fs: ','.join(("%d@%d" % (f.function.id, f.start_time) for f in fs)))) + def __hash__(self): + return hash((super().__hash__(), self.functions)) + def get_data(self): return tuple((f.function.get_data() for f in self.functions)) @@ -567,6 +667,9 @@ class ShowTrack(Function): class Show(Function): """Class representing a QLC+ show.""" + def __hash__(self): + return hash((super().__hash__(), self.tracks)) + def render(self, t, data=None): if t > self.actual_duration: return (), (), -1, data @@ -592,8 +695,43 @@ class Show(Function): return tuple(values.items()), tuple(sorted(acues, key=lambda a: a[0])), nx, data - def render_all(self): - """Render the entire show.""" + def render_all(self, minnx=1): + """Render the entire show. + + minnx is the minimum amount of time between render steps. Setting this to a few + milliseconds less than the transmission time of your connection should be fine, but + the default value of 1 ensures that every fade is rendered as perfectly as it can be + when using integer milliseconds. The time-complexity of this function is approximately + linear in minnx (e.g. minnx=10 will be around 10 times faster than minnx=1 for the same + show). + + This function returns: + + cues, audio_cues + + Where cues is of the form: + + [(time, ((channel, value), (channel, value), ...)), ...] + + Note that (channel, value) pairs are only present if that channel changed value at the + given t value, so values must be held at previous levels if they are ommitted. + + audio_cues is of the form: + + [(start time, filename, fade in, fade out, fade out start time), ...] + + Both cues and audio_cues are sorted by t/start time. A typical loop for rendering + lighting cues would be: + + cues, _ = show.render_all() + current_time = 0 + + while cues: + while cues[0][0] < current_time: + _, changes = cues.pop(0) + for c, v in changes: + ## Set address c to value v + """ if self.actual_duration == QLC_INFTY: raise ValueError("Cannot render infinite-length shows (please rethink your life if you created this show)") @@ -614,7 +752,7 @@ class Show(Function): acues.update(tacues) if nx < 0: break - t += nx + t += max(nx, minnx) return tuple(cues), tuple(sorted(acues, key=lambda a: a[1])) @@ -661,14 +799,14 @@ class Workspace: engine = ws.find(QXW+"Engine") ## Load universes - print("Loading universes...") + logging.info("Loading universes...") for u in engine.find(QXW+"InputOutputMap").findall(QXW+"Universe"): uid = int(u.attrib["ID"]) self.universes[uid] = Universe(uid, u.attrib["Name"]) - print("Loaded %d universe(s)" % len(self.universes)) + logging.info("Loaded %d universe(s)" % len(self.universes)) ## Load fixtures - print("Loading fixtures...") + logging.info("Loading fixtures...") total_channels = 0 for f in engine.iterfind(QXW+"Fixture"): fid = int(f.find(QXW+"ID").text) @@ -679,18 +817,18 @@ class Workspace: total_channels += channels mode = f.find(QXW+"Mode") self.fixtures[fid] = Fixture(fid, name, address, self.universes[uid], mode, channels=channels) - print("Loaded %d fixtures with %d channels" % (len(self.fixtures), total_channels)) + logging.info("Loaded %d fixtures with %d channels" % (len(self.fixtures), total_channels)) ## Load channel groups - print("Loading channel groups...") + logging.info("Loading channel groups...") for cg in engine.iterfind(QXW+"ChannelsGroup"): vals = [int(i) for i in cg.text.split(',')] cg = ChannelGroup(int(cg.attrib["ID"]), cg.attrib["Name"], [self.fixtures[fid].channels[offset] for fid, offset in zip(vals[::2], vals[1::2])]) self.channel_groups[cg.id] = cg - print("Loaded %d channel groups" % len(self.channel_groups)) + logging.info("Loaded %d channel groups" % len(self.channel_groups)) - print("Determining proper function load order...") + logging.info("Determining proper function load order...") load = [] audio_fnames = [] ids = set() @@ -721,26 +859,24 @@ class Workspace: ids.add(f.attrib["ID"]) load.append(f) work = todo - print("Found %d functions" % len(load)) + logging.info("Found %d functions" % len(load)) ## Calculate all audio lengths before load. This will reduce duplicate calls if the same ## file is present in multiple functions and lets us use a ThreadPool to speed it up - print("Scanning %d audio functions..." % len(audio_fnames)) + logging.info("Scanning %d audio functions..." % len(audio_fnames)) with ThreadPool() as pool: audio_fnames = tuple(set(audio_fnames)) audio_lengths = {f: l for f,l in zip(audio_fnames, pool.map(audio_length, audio_fnames))} if 0 in audio_lengths.values(): - print("The following files had zero-length:") for f,l in audio_lengths.items(): if l == 0: - warnings.warn("zero-length audio file \"%s\"" % fname, UserWarning) - print(" \"%s\"" % f) + logging.warning("zero-length audio file \"%s\"" % f) - print("Scanned %d audio functions" % len(load)) + logging.info("Scanned %d audio functions" % len(load)) ## Now have an appropriate load order, load them - print("Loading functions...") + logging.info("Loading functions...") for func in load: ftype = func.attrib["Type"] sid = int(func.attrib["ID"]) @@ -852,7 +988,7 @@ class Workspace: self.functions[sid] = func - print("Loaded %d top-level functions" % len(self.functions)) + logging.info("Loaded %d top-level functions" % len(self.functions)) return self -- cgit v1.2.3