summaryrefslogtreecommitdiff
path: root/workspace.py
diff options
context:
space:
mode:
Diffstat (limited to 'workspace.py')
-rwxr-xr-xworkspace.py184
1 files 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