changeset 2376:4556eebe5d73

topdir reorgs; let pdm have its src/ dir; separate vite area from light9/
author drewp@bigasterisk.com
date Sun, 12 May 2024 19:02:10 -0700
parents 623836db99af
children 2088c500415e
files .pdm.toml bin/attic/bcf_puppet_demo bin/attic/bumppad bin/attic/captureDevice bin/attic/curvecalc bin/attic/curvecalc_all_subterms bin/attic/dmx_color_test.py bin/attic/dmxserver bin/attic/effectListing bin/attic/effectSequencer bin/attic/effecteval bin/attic/fade bin/attic/gobutton bin/attic/gtk_dnd_demo.py bin/attic/inputdemo bin/attic/inputquneo bin/attic/kcclient bin/attic/keyboardcomposer bin/attic/lightsim bin/attic/listsongs bin/attic/live bin/attic/load_test_rdfdb bin/attic/midifade bin/attic/movesinks bin/attic/mpd_timing_test bin/attic/musictime bin/attic/paintserver bin/attic/patchserver bin/attic/picamserve bin/attic/pytest bin/attic/python bin/attic/run_local.py bin/attic/staticclient bin/attic/subcomposer bin/attic/subserver bin/attic/timeline bin/attic/tkdnd_minimal_drop.py bin/attic/tracker bin/attic/vidref bin/attic/vidrefsetup bin/attic/wavecurve bin/attic/webcontrol bin/bcf_puppet_demo bin/bumppad bin/captureDevice bin/clientdemo bin/collector_loadtest.py bin/curvecalc bin/curvecalc_all_subterms bin/debug/clientdemo bin/debug/collector_loadtest.py bin/dmx_color_test.py bin/dmxserver bin/effectListing bin/effectSequencer bin/effecteval bin/fade bin/gobutton bin/gtk_dnd_demo.py bin/inputdemo bin/inputquneo bin/kcclient bin/keyboardcomposer bin/lightsim bin/listsongs bin/live bin/load_test_rdfdb bin/midifade bin/movesinks bin/mpd_timing_test bin/musictime bin/paintserver bin/patchserver bin/picamserve bin/pytest bin/python bin/run_local.py bin/staticclient bin/subcomposer bin/subserver bin/timeline bin/tkdnd_minimal_drop.py bin/tracker bin/vidref bin/vidrefsetup bin/wavecurve bin/webcontrol light9/Effects.py light9/Fadable.py light9/FlyingFader.py light9/Patch.py light9/Submaster.py light9/TLUtility.py light9/__init__.py light9/ascoltami/__init__.py light9/ascoltami/main.py light9/ascoltami/main_test.py light9/ascoltami/musictime_client.py light9/ascoltami/player.py light9/ascoltami/playlist.py light9/ascoltami/webapp.py light9/ascoltami/webapp_test.py light9/chase.py light9/clientsession.py light9/coffee.py light9/collector/__init__.py light9/collector/collector.py light9/collector/collector_client.py light9/collector/collector_client_asyncio.py light9/collector/collector_test.py light9/collector/device.py light9/collector/device_test.py light9/collector/dmx_controller_output.py light9/collector/output.py light9/collector/output_test.py light9/collector/service.py light9/collector/weblisteners.py light9/cursor1.xbm light9/curvecalc/__init__.py light9/curvecalc/client.py light9/curvecalc/cursors.py light9/curvecalc/curve.py light9/curvecalc/curvecalc.glade light9/curvecalc/curveedit.py light9/curvecalc/curveview.py light9/curvecalc/musicaccess.py light9/curvecalc/output.py light9/curvecalc/subterm.py light9/curvecalc/subtermview.py light9/curvecalc/zoomcontrol.py light9/dmxchanedit.py light9/dmxclient.py light9/editchoice.py light9/editchoicegtk.py light9/effect/__init__.py light9/effect/edit.py light9/effect/effect_function_library.py light9/effect/effect_function_library_test.py light9/effect/effect_functions.py light9/effect/effecteval.py light9/effect/effecteval2.py light9/effect/effecteval_test.py light9/effect/scale.py light9/effect/sequencer/__init__.py light9/effect/sequencer/eval_faders.py light9/effect/sequencer/eval_faders_test.py light9/effect/sequencer/note.py light9/effect/sequencer/note_test.py light9/effect/sequencer/sequencer.py light9/effect/sequencer/service.py light9/effect/sequencer/service_test.py light9/effect/sequencer/web/Light9SequencerUi.ts light9/effect/sequencer/web/index.html light9/effect/sequencer/web/vite.config.ts light9/effect/settings.py light9/effect/settings_test.py light9/effecteval/__init__.py light9/effecteval/effect-components.html light9/effecteval/effect.coffee light9/effecteval/effect.html light9/effecteval/effect.py light9/effecteval/effectloop.py light9/effecteval/index.html light9/effecteval/test_effect.py light9/gtkpyconsole.py light9/homepage/write_config.py light9/homepage/write_config_test.py light9/io/Makefile light9/io/__init__.py light9/io/motordrive light9/io/parport.c light9/io/parport.i light9/io/serport.i light9/io/udmx.py light9/localsyncedgraph.py light9/metrics.py light9/midifade/midifade.py light9/midifade/midifade_test.py light9/mock_syncedgraph.py light9/namespaces.py light9/networking.py light9/newtypes.py light9/observable.py light9/paint/__init__.py light9/paint/capture.py light9/paint/solve.py light9/paint/solve_test.py light9/prof.py light9/rdfdb/service.py light9/rdfdb/service_test.py light9/recentfps.py light9/run_local.py light9/showconfig.py light9/subclient.py light9/subcomposer/__init__.py light9/subcomposer/index.html light9/subcomposer/subcomposerweb.py light9/tkdnd.py light9/typedgraph.py light9/typedgraph_test.py light9/uihelpers.py light9/updatefreq.py light9/vidref/__init__.py light9/vidref/gui.js light9/vidref/index.html light9/vidref/main.py light9/vidref/moviestore.py light9/vidref/remotepivideo.py light9/vidref/setup.html light9/vidref/videorecorder.py light9/vidref/vidref.glade light9/vidref/vidref.html light9/vidref/vidref.ui light9/wavelength.py light9/wavepoints.py light9/web/AutoDependencies.ts light9/web/EditChoice.ts light9/web/Light9CursorCanvas.ts light9/web/RdfDbChannel.ts light9/web/RdfdbSyncedGraph.ts light9/web/ResourceDisplay.ts light9/web/SyncedGraph.ts light9/web/TiledHome.ts light9/web/ascoltami/Light9AscoltamiUi.ts light9/web/ascoltami/index.html light9/web/ascoltami/main.ts light9/web/collector/Light9CollectorDevice.ts light9/web/collector/Light9CollectorUi.ts light9/web/collector/index.html light9/web/colorpick_crosshair_large.svg light9/web/colorpick_crosshair_small.svg light9/web/colorpick_rainbow_large.png light9/web/colorpick_rainbow_small.png light9/web/drawing.ts light9/web/edit-choice-demo.html light9/web/edit-choice.coffee light9/web/edit-choice_test.html light9/web/effects/Light9EffectListing.ts light9/web/effects/index.html light9/web/fade/Light9EffectFader.ts light9/web/fade/Light9FadeUi.ts light9/web/fade/Light9Fader.ts light9/web/fade/index.html light9/web/floating_color_picker.ts light9/web/graph_test.coffee light9/web/index.html light9/web/lib/.bowerrc light9/web/lib/bower.json light9/web/lib/onecolor.d.ts light9/web/lib/parse-prometheus-text-format.d.ts light9/web/lib/sylvester.d.ts light9/web/lib/tapmodo-Jcrop-1902fbc/MIT-LICENSE.txt light9/web/lib/tapmodo-Jcrop-1902fbc/README.md light9/web/lib/tapmodo-Jcrop-1902fbc/css/Jcrop.gif light9/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.css light9/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css light9/web/lib/tapmodo-Jcrop-1902fbc/index.html light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.min.js light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.color.js light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.min.js light9/web/light9-collector-client.html light9/web/light9-color-picker.ts light9/web/light9-color-picker_test.html light9/web/light9-music.coffee light9/web/light9-music.html light9/web/light9-timeline-audio.ts light9/web/light9-vidref-live.js light9/web/light9-vidref-replay-stack.js light9/web/light9-vidref-replay.js light9/web/live/Effect.ts light9/web/live/Light9AttrControl.ts light9/web/live/Light9DeviceControl.ts light9/web/live/Light9DeviceSettings.ts light9/web/live/Light9Listbox.ts light9/web/live/README.md light9/web/live/index.html light9/web/metrics/ServiceButtonRow.ts light9/web/metrics/StatsLine.ts light9/web/metrics/StatsProcess.ts light9/web/metrics/index.html light9/web/mime.types light9/web/paint/bg1.jpg light9/web/paint/bg2.jpg light9/web/paint/bg3.jpg light9/web/paint/index.html light9/web/paint/paint-elements.coffee light9/web/paint/paint-elements.html light9/web/paint/paint-report-elements.html light9/web/patch.test.ts light9/web/patch.ts light9/web/rdfdb-synced-graph_test.html light9/web/rdfdbclient.ts light9/web/resource-display_test.html light9/web/show_specific.ts light9/web/style.css light9/web/timeline/Note.coffee light9/web/timeline/Project.coffee light9/web/timeline/TimeAxis.coffee light9/web/timeline/TimeZoomed.coffee light9/web/timeline/TimelineEditor.coffee light9/web/timeline/adjustable.ts light9/web/timeline/adjusters.ts light9/web/timeline/brick_layout.ts light9/web/timeline/index.html light9/web/timeline/inline-attrs.coffee light9/web/timeline/inline-attrs.html light9/web/timeline/timeline-elements.ts light9/web/timeline/viewstate.ts light9/web/timeline/vite.config.ts light9/web/timeline2/index.html light9/web/timeline2/package.json light9/web/vite.config.ts light9/web/websocket.js light9/webcontrol.html light9/zmqtransport.py src/light9/Effects.py src/light9/Fadable.py src/light9/FlyingFader.py src/light9/Patch.py src/light9/Submaster.py src/light9/TLUtility.py src/light9/__init__.py src/light9/ascoltami/__init__.py src/light9/ascoltami/main.py src/light9/ascoltami/main_test.py src/light9/ascoltami/musictime_client.py src/light9/ascoltami/player.py src/light9/ascoltami/playlist.py src/light9/ascoltami/webapp.py src/light9/ascoltami/webapp_test.py src/light9/chase.py src/light9/clientsession.py src/light9/coffee.py src/light9/collector/__init__.py src/light9/collector/collector.py src/light9/collector/collector_client.py src/light9/collector/collector_client_asyncio.py src/light9/collector/collector_test.py src/light9/collector/device.py src/light9/collector/device_test.py src/light9/collector/dmx_controller_output.py src/light9/collector/output.py src/light9/collector/output_test.py src/light9/collector/service.py src/light9/collector/weblisteners.py src/light9/cursor1.xbm src/light9/curvecalc/__init__.py src/light9/curvecalc/client.py src/light9/curvecalc/cursors.py src/light9/curvecalc/curve.py src/light9/curvecalc/curvecalc.glade src/light9/curvecalc/curveedit.py src/light9/curvecalc/curveview.py src/light9/curvecalc/musicaccess.py src/light9/curvecalc/output.py src/light9/curvecalc/subterm.py src/light9/curvecalc/subtermview.py src/light9/curvecalc/zoomcontrol.py src/light9/dmxchanedit.py src/light9/dmxclient.py src/light9/editchoice.py src/light9/editchoicegtk.py src/light9/effect/__init__.py src/light9/effect/edit.py src/light9/effect/effect_function_library.py src/light9/effect/effect_function_library_test.py src/light9/effect/effect_functions.py src/light9/effect/effecteval.py src/light9/effect/effecteval2.py src/light9/effect/effecteval_test.py src/light9/effect/scale.py src/light9/effect/sequencer/__init__.py src/light9/effect/sequencer/eval_faders.py src/light9/effect/sequencer/eval_faders_test.py src/light9/effect/sequencer/note.py src/light9/effect/sequencer/note_test.py src/light9/effect/sequencer/sequencer.py src/light9/effect/sequencer/service.py src/light9/effect/sequencer/service_test.py src/light9/effect/sequencer/web/Light9SequencerUi.ts src/light9/effect/sequencer/web/index.html src/light9/effect/sequencer/web/vite.config.ts src/light9/effect/settings.py src/light9/effect/settings_test.py src/light9/effecteval/__init__.py src/light9/effecteval/effect-components.html src/light9/effecteval/effect.coffee src/light9/effecteval/effect.html src/light9/effecteval/effect.py src/light9/effecteval/effectloop.py src/light9/effecteval/index.html src/light9/effecteval/test_effect.py src/light9/gtkpyconsole.py src/light9/homepage/write_config.py src/light9/homepage/write_config_test.py src/light9/io/Makefile src/light9/io/__init__.py src/light9/io/motordrive src/light9/io/parport.c src/light9/io/parport.i src/light9/io/serport.i src/light9/io/udmx.py src/light9/localsyncedgraph.py src/light9/metrics.py src/light9/midifade/midifade.py src/light9/midifade/midifade_test.py src/light9/mock_syncedgraph.py src/light9/namespaces.py src/light9/networking.py src/light9/newtypes.py src/light9/observable.py src/light9/paint/__init__.py src/light9/paint/capture.py src/light9/paint/solve.py src/light9/paint/solve_test.py src/light9/prof.py src/light9/rdfdb/service.py src/light9/rdfdb/service_test.py src/light9/recentfps.py src/light9/run_local.py src/light9/showconfig.py src/light9/subclient.py src/light9/subcomposer/__init__.py src/light9/subcomposer/index.html src/light9/subcomposer/subcomposerweb.py src/light9/tkdnd.py src/light9/typedgraph.py src/light9/typedgraph_test.py src/light9/uihelpers.py src/light9/updatefreq.py src/light9/vidref/__init__.py src/light9/vidref/gui.js src/light9/vidref/index.html src/light9/vidref/main.py src/light9/vidref/moviestore.py src/light9/vidref/remotepivideo.py src/light9/vidref/setup.html src/light9/vidref/videorecorder.py src/light9/vidref/vidref.glade src/light9/vidref/vidref.html src/light9/vidref/vidref.ui src/light9/wavelength.py src/light9/wavepoints.py src/light9/webcontrol.html src/light9/zmqtransport.py web/AutoDependencies.ts web/EditChoice.ts web/Light9CursorCanvas.ts web/RdfDbChannel.ts web/RdfdbSyncedGraph.ts web/ResourceDisplay.ts web/SyncedGraph.ts web/TiledHome.ts web/ascoltami/Light9AscoltamiUi.ts web/ascoltami/index.html web/ascoltami/main.ts web/collector/Light9CollectorDevice.ts web/collector/Light9CollectorUi.ts web/collector/index.html web/colorpick_crosshair_large.svg web/colorpick_crosshair_small.svg web/colorpick_rainbow_large.png web/colorpick_rainbow_small.png web/drawing.ts web/edit-choice-demo.html web/edit-choice.coffee web/edit-choice_test.html web/effects/Light9EffectListing.ts web/effects/index.html web/fade/Light9EffectFader.ts web/fade/Light9FadeUi.ts web/fade/Light9Fader.ts web/fade/index.html web/floating_color_picker.ts web/graph_test.coffee web/index.html web/lib/.bowerrc web/lib/bower.json web/lib/onecolor.d.ts web/lib/parse-prometheus-text-format.d.ts web/lib/sylvester.d.ts web/lib/tapmodo-Jcrop-1902fbc/MIT-LICENSE.txt web/lib/tapmodo-Jcrop-1902fbc/README.md web/lib/tapmodo-Jcrop-1902fbc/css/Jcrop.gif web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.css web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css web/lib/tapmodo-Jcrop-1902fbc/index.html web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.min.js web/lib/tapmodo-Jcrop-1902fbc/js/jquery.color.js web/lib/tapmodo-Jcrop-1902fbc/js/jquery.min.js web/light9-collector-client.html web/light9-color-picker.ts web/light9-color-picker_test.html web/light9-music.coffee web/light9-music.html web/light9-timeline-audio.ts web/light9-vidref-live.js web/light9-vidref-replay-stack.js web/light9-vidref-replay.js web/live/Effect.ts web/live/Light9AttrControl.ts web/live/Light9DeviceControl.ts web/live/Light9DeviceSettings.ts web/live/Light9Listbox.ts web/live/README.md web/live/index.html web/metrics/ServiceButtonRow.ts web/metrics/StatsLine.ts web/metrics/StatsProcess.ts web/metrics/index.html web/mime.types web/paint/bg1.jpg web/paint/bg2.jpg web/paint/bg3.jpg web/paint/index.html web/paint/paint-elements.coffee web/paint/paint-elements.html web/paint/paint-report-elements.html web/patch.test.ts web/patch.ts web/rdfdb-synced-graph_test.html web/rdfdbclient.ts web/resource-display_test.html web/show_specific.ts web/style.css web/timeline/Note.coffee web/timeline/Project.coffee web/timeline/TimeAxis.coffee web/timeline/TimeZoomed.coffee web/timeline/TimelineEditor.coffee web/timeline/adjustable.ts web/timeline/adjusters.ts web/timeline/brick_layout.ts web/timeline/index.html web/timeline/inline-attrs.coffee web/timeline/inline-attrs.html web/timeline/timeline-elements.ts web/timeline/viewstate.ts web/timeline/vite.config.ts web/timeline2/index.html web/timeline2/package.json web/vite.config.ts web/websocket.js
diffstat 527 files changed, 33179 insertions(+), 33181 deletions(-) [+]
line wrap: on
line diff
--- a/.pdm.toml	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-[python]
-path = "/usr/bin/python3.10"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/bcf_puppet_demo	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,17 @@
+#!/usr/bin/python
+"""
+tiny bcf2000 controller demo
+"""
+from bcf2000 import BCF2000
+from twisted.internet import reactor
+
+
+class PuppetSliders(BCF2000):
+
+    def valueIn(self, name, value):
+        if name == 'slider1':
+            self.valueOut('slider5', value)
+
+
+b = PuppetSliders()
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/bumppad	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,89 @@
+#!bin/python
+
+import sys, time, math
+import tkinter as tk
+
+import run_local
+import light9.dmxclient as dmxclient
+from light9.TLUtility import make_attributes_from_args
+
+from light9.Submaster import Submaster, sub_maxes
+
+
+class pad(tk.Frame):
+    levs = None  # Submaster : level
+
+    def __init__(self, master, root, mag):
+        make_attributes_from_args('master', 'mag')
+        tk.Frame.__init__(self, master)
+        self.levs = {}
+        for xy, key, subname in [
+            ((1, 1), 'KP_Up', 'centered'),
+            ((1, 3), "KP_Down", 'third-c'),
+            ((0, 2), 'KP_Left', 'scoop-l'),
+            ((2, 2), 'KP_Right', 'scoop-r'),
+            ((1, 0), 'KP_Divide', 'cyc'),
+            ((0, 3), "KP_End", 'hottest'),
+            ((2, 3), 'KP_Next', 'deepblues'),
+            ((0, 4), 'KP_Insert', "zip_red"),
+            ((2, 4), 'KP_Delete', "zip_orange"),
+            ((3, 1), 'KP_Add', 'strobedim'),
+            ((3, 3), 'KP_Enter', 'zip_blue'),
+            ((1, 2), 'KP_Begin', 'scoop-c'),
+        ]:
+
+            sub = Submaster(subname)
+            self.levs[sub] = 0
+
+            l = tk.Label(self,
+                         font="arial 12 bold",
+                         anchor='w',
+                         height=2,
+                         relief='groove',
+                         bd=5,
+                         text="%s\n%s" % (key.replace('KP_', ''), sub.name))
+            l.grid(column=xy[0], row=xy[1], sticky='news')
+
+            root.bind(
+                "<KeyPress-%s>" % key, lambda ev, sub=sub: self.bumpto(sub, 1))
+            root.bind("<KeyRelease-%s>" % key,
+                      lambda ev, sub=sub: self.bumpto(sub, 0))
+
+    def bumpto(self, sub, lev):
+        now = time.time()
+        self.levs[sub] = lev * self.mag.get()
+        self.master.after_idle(self.output)
+
+    def output(self):
+        dmx = sub_maxes(*[s * l
+                          for s, l in list(self.levs.items())]).get_dmx_list()
+        dmxclient.outputlevels(dmx, clientid="bumppad")
+
+
+root = tk.Tk()
+root.tk_setPalette("maroon4")
+root.wm_title("bumppad")
+mag = tk.DoubleVar()
+
+tk.Label(root,
+         text="Keypad press/release activate sub; 1..5 set mag",
+         font="Helvetica -12 italic",
+         anchor='w').pack(side='bottom', fill='x')
+
+pad(root, root, mag).pack(side='left', fill='both', exp=1)
+
+magscl = tk.Scale(root,
+                  orient='vertical',
+                  from_=1,
+                  to=0,
+                  res=.01,
+                  showval=1,
+                  variable=mag,
+                  label='mag',
+                  relief='raised',
+                  bd=1)
+for i in range(1, 6):
+    root.bind("<Key-%s>" % i, lambda ev, i=i: mag.set(math.sqrt((i) / 5)))
+magscl.pack(side='left', fill='y')
+
+root.mainloop()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/captureDevice	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,199 @@
+#!bin/python
+"""
+Operate a motorized light and take pictures of it in every position.
+"""
+from rdflib import URIRef
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, Deferred
+
+import logging
+import optparse
+import os
+import time
+import treq
+import cyclone.web, cyclone.websocket, cyclone.httpclient
+from light9.metrics import metrics, metricsRoute
+from run_local import log
+from cycloneerr import PrettyErrorHandler
+
+from light9.namespaces import L9, RDF
+from light9 import networking, showconfig
+from rdfdb.syncedgraph import SyncedGraph
+from light9.paint.capture import writeCaptureDescription
+from light9.effect.settings import DeviceSettings
+from light9.collector.collector_client import sendToCollector
+from rdfdb.patch import Patch
+from light9.zmqtransport import parseJsonMessage
+
+
+
+class Camera(object):
+
+    def __init__(self, imageUrl):
+        self.imageUrl = imageUrl
+
+    def takePic(self, uri, writePath):
+        log.info('takePic %s', uri)
+        return treq.get(
+            self.imageUrl).addCallbacks(lambda r: self._done(writePath, r),
+                                        log.error)
+
+    @inlineCallbacks
+    def _done(self, writePath, response):
+        jpg = yield response.content()
+        try:
+            os.makedirs(os.path.dirname(writePath))
+        except OSError:
+            pass
+        with open(writePath, 'w') as out:
+            out.write(jpg)
+        log.info('wrote %s', writePath)
+
+
+def deferSleep(sec):
+    d = Deferred()
+    reactor.callLater(sec, d.callback, None)
+    return d
+
+
+class Capture(object):
+    firstMoveTime = 3
+    settleTime = .5
+
+    def __init__(self, graph, dev):
+        self.graph = graph
+        self.dev = dev
+
+        def steps(a, b, n):
+            return [round(a + (b - a) * i / n, 5) for i in range(n)]
+
+        startTime = time.time()
+        self.captureId = 'cap%s' % (int(startTime) - 1495170000)
+        self.toGather = []
+
+        #quantum
+        rxSteps = steps(.06, .952, 10)
+        rySteps = steps(0.1, .77, 5)
+        zoomSteps = steps(.12, .85, 3)
+        # aura
+        rxSteps = steps(0.15, .95, 10)
+        rySteps = steps(0, .9, 5)
+        zoomSteps = steps(.6, .9, 3)
+
+        row = 0
+        for ry in rySteps:
+            xSteps = rxSteps[:]
+            if row % 2:
+                xSteps.reverse()
+            row += 1
+            for rx in xSteps:
+                for zoom in zoomSteps:
+                    self.toGather.append(
+                        DeviceSettings(
+                            graph,
+                            [
+                                (dev, L9['rx'], rx),
+                                (dev, L9['ry'], ry),
+                                (dev, L9['color'], '#ffffff'),
+                                (dev, L9['zoom'], zoom),
+                                #(dev, L9['focus'], 0.13),
+                            ]))
+
+        self.devTail = dev.rsplit('/')[-1]
+        self.session = URIRef('/'.join(
+            [showconfig.showUri(), 'capture', self.devTail, self.captureId]))
+        self.ctx = URIRef(self.session + '/index')
+
+        self.graph.patch(
+            Patch(addQuads=[
+                (self.session, RDF.type, L9['CaptureSession'], self.ctx),
+            ]))
+
+        self.numPics = 0
+        self.settingsCache = set()
+        self.step().addErrback(log.error)
+
+    def off(self):
+        return sendToCollector(client='captureDevice',
+                               session='main',
+                               settings=DeviceSettings(self.graph, []))
+
+    @inlineCallbacks
+    def step(self):
+        if not self.toGather:
+            yield self.off()
+            yield deferSleep(1)
+            reactor.stop()
+            return
+        settings = self.toGather.pop()
+
+        log.info('[%s left] move to %r', len(self.toGather), settings)
+        yield sendToCollector(client='captureDevice',
+                              session='main',
+                              settings=settings)
+
+        yield deferSleep(self.firstMoveTime if self.numPics ==
+                         0 else self.settleTime)
+
+        picId = 'pic%s' % self.numPics
+        path = '/'.join(['capture', self.devTail, self.captureId, picId
+                        ]) + '.jpg'
+        uri = URIRef(self.session + '/' + picId)
+
+        yield camera.takePic(uri, os.path.join(showconfig.root(), path))
+        self.numPics += 1
+
+        writeCaptureDescription(self.graph, self.ctx, self.session, uri,
+                                self.dev, path, self.settingsCache, settings)
+
+        reactor.callLater(0, self.step)
+
+
+camera = Camera(
+    'http://plus:8200/picamserve/pic?res=1080&resize=800&iso=800&redgain=1.6&bluegain=1.6&shutter=60000&x=0&w=1&y=0&h=.952'
+)
+
+
+class Attrs(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    @metrics('set_attr').time()
+    def put(self):
+        client, clientSession, settings, sendTime = parseJsonMessage(
+            self.request.body)
+        self.set_status(202)
+
+
+def launch(graph):
+
+    cap = Capture(graph, dev=L9['device/aura5'])
+    reactor.listenTCP(networking.captureDevice.port,
+                      cyclone.web.Application(handlers=[
+                          (r'/()', cyclone.web.StaticFileHandler, {
+                              "path": "light9/web",
+                              "default_filename": "captureDevice.html"
+                          }),
+                          metricsRoute(),
+                      ]),
+                      interface='::',
+                      cap=cap)
+    log.info('serving http on %s', networking.captureDevice.port)
+
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    (options, args) = parser.parse_args()
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+    graph = SyncedGraph(networking.rdfdb.url, "captureDevice")
+
+    graph.initiallySynced.addCallback(lambda _: launch(graph)).addErrback(
+        log.error)
+    reactor.run()
+
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/curvecalc	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,574 @@
+#!bin/python
+"""
+now launches like this:
+% bin/curvecalc http://light9.bigasterisk.com/show/dance2007/song1
+
+
+
+todo: curveview should preserve more objects, for speed maybe
+
+"""
+
+import sys
+import imp
+sys.path.append('/usr/lib/python2.7/dist-packages')  # For gtk
+from twisted.internet import gtk3reactor
+gtk3reactor.install()
+from twisted.internet import reactor
+
+import time, textwrap, os, optparse, linecache, signal, traceback, json
+import gi
+from gi.repository import Gtk
+from gi.repository import GObject
+from gi.repository import Gdk
+
+from urllib.parse import parse_qsl
+import louie as dispatcher
+from rdflib import URIRef, Literal, RDF, RDFS
+import logging
+
+from run_local import log
+from light9 import showconfig, networking
+from light9.curvecalc import curveview
+from light9.curvecalc.curve import Curveset
+from light9.curvecalc.curveedit import serveCurveEdit
+from light9.curvecalc.musicaccess import Music
+from light9.curvecalc.output import Output
+from light9.curvecalc.subterm import Subterm
+from light9.curvecalc.subtermview import add_one_subterm
+from light9.editchoicegtk import EditChoice, Local
+from light9.gtkpyconsole import togglePyConsole
+from light9.namespaces import L9
+from light9.observable import Observable
+from light9 import clientsession
+from rdfdb.patch import Patch
+from rdfdb.syncedgraph import SyncedGraph
+from light9.wavelength import wavelength
+
+
+class SubtermExists(ValueError):
+    pass
+
+
+class Main(object):
+
+    def __init__(self, graph, opts, session, curveset, music):
+        self.graph, self.opts, self.session = graph, opts, session
+        self.curveset, self.music = curveset, music
+        self.lastSeenInputTime = 0
+        self.currentSubterms = [
+        ]  # Subterm objects that are synced to the graph
+
+        self.setTheme()
+        wtree = self.wtree = Gtk.Builder()
+        wtree.add_from_file("light9/curvecalc/curvecalc.glade")
+        mainwin = wtree.get_object("MainWindow")
+
+        mainwin.connect("destroy", self.onQuit)
+        wtree.connect_signals(self)
+
+        mainwin.show_all()
+
+        mainwin.connect("delete-event", lambda *args: reactor.crash())
+
+        def updateTitle():
+            mainwin.set_title(
+                "curvecalc - %s" %
+                graph.label(graph.value(session, L9['currentSong'])))
+
+        graph.addHandler(updateTitle)
+
+        songChoice = Observable(None)  # to be connected with the session song
+
+        self.registerGraphToSongChoice(wtree, session, graph, songChoice)
+        self.registerSongChoiceToGraph(session, graph, songChoice)
+        self.registerCurrentPlayerSongToUi(wtree, graph, songChoice)
+
+        ec = EditChoice(graph, songChoice, label="Editing song:")
+        wtree.get_object("currentSongEditChoice").add(ec)
+        ec.show()
+
+        wtree.get_object("subterms").connect("add", self.onSubtermChildAdded)
+
+        self.refreshCurveView()
+
+        self.makeStatusLines(wtree.get_object("status"))
+        self.setupNewSubZone()
+        self.acceptDragsOnCurveViews()
+
+        # may not work
+        wtree.get_object("paned1").set_position(600)
+
+    def registerGraphToSongChoice(self, wtree, session, graph, songChoice):
+
+        def setSong():
+            current = graph.value(session, L9['currentSong'])
+            if not wtree.get_object("followPlayerSongChoice").get_active():
+                songChoice(current)
+                dispatcher.send("song_has_changed")
+
+        graph.addHandler(setSong)
+
+    def registerSongChoiceToGraph(self, session, graph, songChoice):
+        self.muteSongChoiceUntil = 0
+
+        def songChoiceToGraph(newSong):
+            if newSong is Local:
+                raise NotImplementedError('what do i patch')
+            log.debug('songChoiceToGraph is going to set to %r', newSong)
+
+            # I get bogus newSong values in here sometimes. This
+            # workaround may not even be helping.
+            now = time.time()
+            if now < self.muteSongChoiceUntil:
+                log.debug('muted')
+                return
+            self.muteSongChoiceUntil = now + 1
+
+            graph.patchObject(context=session,
+                              subject=session,
+                              predicate=L9['currentSong'],
+                              newObject=newSong)
+
+        songChoice.subscribe(songChoiceToGraph)
+
+    def registerCurrentPlayerSongToUi(self, wtree, graph, songChoice):
+        """current_player_song 'song' param -> playerSong ui
+        and
+        current_player_song 'song' param -> songChoice, if you're in autofollow
+        """
+
+        def current_player_song(song):
+            # (this is run on every frame)
+            ps = wtree.get_object("playerSong")
+            if URIRef(ps.get_uri()) != song:
+                log.debug("update playerSong to %s", ps.get_uri())
+
+                def setLabel():
+                    ps.set_label(graph.label(song))
+
+                graph.addHandler(setLabel)
+                ps.set_uri(song)
+            if song != songChoice():
+                if wtree.get_object("followPlayerSongChoice").get_active():
+                    log.debug('followPlayerSongChoice is on')
+                    songChoice(song)
+
+        dispatcher.connect(current_player_song, "current_player_song")
+        self.current_player_song = current_player_song
+
+    def setupNewSubZone(self):
+        self.wtree.get_object("newSubZone").drag_dest_set(
+            flags=Gtk.DestDefaults.ALL,
+            targets=[Gtk.TargetEntry('text/uri-list', 0, 0)],
+            actions=Gdk.DragAction.COPY)
+
+    def acceptDragsOnCurveViews(self):
+        w = self.wtree.get_object("curves")
+        w.drag_dest_set(flags=Gtk.DestDefaults.ALL,
+                        targets=[Gtk.TargetEntry('text/uri-list', 0, 0)],
+                        actions=Gdk.DragAction.COPY)
+
+        def recv(widget, context, x, y, selection, targetType, time):
+            subUri = URIRef(selection.data.strip())
+            print("into curves", subUri)
+            with self.graph.currentState(tripleFilter=(subUri, RDFS.label,
+                                                       None)) as current:
+                subName = current.label(subUri)
+
+            if '?' in subUri:
+                subName = self.handleSubtermDrop(subUri)
+            else:
+                try:
+                    self.makeSubterm(subName,
+                                     withCurve=True,
+                                     sub=subUri,
+                                     expr="%s(t)" % subName)
+                except SubtermExists:
+                    # we're not making sure the expression/etc are
+                    # correct-- user mihgt need to fix things
+                    pass
+            curveView = self.curvesetView.row(subName).curveView
+            t = self.lastSeenInputTime  # curveView.current_time() # new curve hasn't heard the time yet. this has gotten too messy- everyone just needs to be able to reach the time source
+            print("time", t)
+            curveView.add_points([(t - .5, 0), (t, 1)])
+
+        w.connect("drag-data-received", recv)
+
+    def onDragDataInNewSubZone(self, widget, context, x, y, selection,
+                               targetType, time):
+        data = URIRef(selection.data.strip())
+        if '?' in data:
+            self.handleSubtermDrop(data)
+            return
+        with self.graph.currentState(tripleFilter=(data, None,
+                                                   None)) as current:
+            subName = current.label(data)
+        self.makeSubterm(newname=subName,
+                         withCurve=True,
+                         sub=data,
+                         expr="%s(t)" % subName)
+
+    def handleSubtermDrop(self, data):
+        params = parse_qsl(data.split('?')[1])
+        flattened = dict(params)
+        self.makeSubterm(Literal(flattened['subtermName']),
+                         expr=flattened['subtermExpr'])
+
+        for cmd, name in params:
+            if cmd == 'curve':
+                self.curveset.new_curve(name)
+        return name
+
+    def onNewCurve(self, *args):
+        dialog = self.wtree.get_object("newCurve")
+        entry = self.wtree.get_object("newCurveName")
+        # if you don't have songx, that should be the suggested name
+        entry.set_text("")
+        if dialog.run() == 1:
+            self.curveset.new_curve(entry.get_text())
+        dialog.hide()
+
+    def onRedrawCurves(self, *args):
+        dispatcher.send("all curves rebuild")
+
+    def onSubtermsMap(self, *args):
+        # if this was called too soon, like in __init__, the gtktable
+        # would get its children but it wouldn't lay anything out that
+        # I can see, and I'm not sure why. Waiting for map event is
+        # just a wild guess.
+        self.graph.addHandler(self.set_subterms_from_graph)
+
+    def onNewSubterm(self, *args):
+        self.makeSubterm(Literal(""), withCurve=False)
+        return
+
+        # pretty sure i don't want this back, but not completely sure
+        # what the UX should be to get the new curve.
+
+        dialog = self.wtree.get_object("newSubterm")
+        # the plan is to autocomplete this on existing subterm names
+        # (but let you make one up, too)
+        entry = self.wtree.get_object("newSubtermName").get_children()[0]
+        entry.set_text("")
+        entry.grab_focus()
+        if dialog.run() == 1:
+            newname = entry.get_text()
+            wc = self.wtree.get_object("newSubtermMakeCurve").get_active()
+            self.makeSubterm(newname, withCurve=wc)
+        dialog.hide()
+
+    def currentSong(self):
+
+        with self.graph.currentState(tripleFilter=(self.session,
+                                                   L9['currentSong'],
+                                                   None)) as current:
+            return current.value(self.session, L9['currentSong'])
+
+    def songSubtermsContext(self):
+        return self.currentSong()
+
+    def makeSubterm(self, newname, withCurve=False, expr=None, sub=None):
+        """
+        raises SubtermExists if we had a subterm with a sub with the given
+        name. what about a no-sub term with the same label? who knows
+        """
+        assert isinstance(newname, Literal), repr(newname)
+        if withCurve:
+            self.curveset.new_curve(newname)
+        if newname in self.all_subterm_labels():
+            raise SubtermExists("have a subterm who sub is named %r" % newname)
+        with self.graph.currentState() as current:
+            song = self.currentSong()
+            for i in range(1000):
+                uri = song + "/subterm/%d" % i
+                if (uri, None, None) not in current:
+                    break
+            else:
+                raise ValueError("can't pick a name for the new subterm")
+
+        ctx = self.songSubtermsContext()
+        quads = [
+            (uri, RDF.type, L9.Subterm, ctx),
+            (uri, RDFS.label, Literal(newname), ctx),
+            (self.currentSong(), L9['subterm'], uri, ctx),
+        ]
+        if sub is not None:
+            quads.append((uri, L9['sub'], sub, ctx))
+        if expr is not None:
+            quads.append((uri, L9['expression'], Literal(expr), ctx))
+        self.graph.patch(Patch(addQuads=quads))
+
+        return uri
+
+    def all_subterm_labels(self):
+        """
+        Literal labels of subs in subterms. doesn't currently include labels of the
+        subterm resources. I'm not sure what I'm going to do with
+        those.
+        """
+        labels = []
+        with self.graph.currentState() as current:
+            for st in current.objects(
+                    current.value(self.session, L9['currentSong']),
+                    L9['subterm']):
+                sub = current.value(st, L9['sub'])
+                if sub is not None:
+                    labels.append(current.label(sub))
+        return labels
+
+    def set_subterms_from_graph(self):
+        """rebuild all the gtktable 'subterms' widgets and the
+        self.currentSubterms list"""
+        song = self.graph.value(self.session, L9['currentSong'])
+
+        newList = []
+        for st in set(self.graph.objects(song, L9['subterm'])):
+            log.debug("song %s has subterm %s", song, st)
+            term = Subterm(self.graph, st, self.songSubtermsContext(),
+                           self.curveset)
+            newList.append(term)
+        self.currentSubterms[:] = newList
+
+        master = self.wtree.get_object("subterms")
+        log.debug("removing subterm widgets")
+        [master.remove(c) for c in master.get_children()]
+        for term in self.currentSubterms:
+            add_one_subterm(term, self.curveset, master)
+        master.show_all()
+        log.debug("%s table children showing" % len(master.get_children()))
+
+    def setTheme(self):
+        settings = Gtk.Settings.get_default()
+        settings.set_property("gtk-application-prefer-dark-theme", True)
+
+        providers = []
+        providers.append(Gtk.CssProvider())
+        providers[-1].load_from_path("theme/Just-Dark/gtk-3.0/gtk.css")
+        providers.append(Gtk.CssProvider())
+        providers[-1].load_from_data('''
+          * { font-size: 92%; }
+          .button:link { font-size: 7px }
+        ''')
+
+        screen = Gdk.Display.get_default_screen(Gdk.Display.get_default())
+        for p in providers:
+            Gtk.StyleContext.add_provider_for_screen(
+                screen, p, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
+
+    def onSubtermChildAdded(self, subtermsTable, *args):
+        # this would probably work, but isn't getting called
+        log.info("onSubtermChildAdded")
+        v = subtermsTable.get_parent().props.vadjustment
+        v.props.value = v.props.upper
+
+    def onQuit(self, *args):
+        reactor.crash()
+        # there's a hang after this, maybe in sem_wait in two
+        # threads. I don't know whose they are.
+        # This fix affects profilers who want to write output at the end.
+        os.kill(os.getpid(), signal.SIGKILL)
+
+    def onCollapseAll(self, *args):
+        self.curvesetView.collapseAll()
+
+    def onCollapseNone(self, *args):
+        self.curvesetView.collapseNone()
+
+    def onDelete(self, *args):
+        self.curvesetView.onDelete()
+
+    def onPythonConsole(self, item):
+        ns = dict()
+        ns.update(globals())
+        ns.update(self.__dict__)
+        togglePyConsole(self, item, ns)
+
+    def onSeeCurrentTime(self, item):
+        dispatcher.send("see time")
+
+    def onSeeTimeUntilEnd(self, item):
+        dispatcher.send("see time until end")
+
+    def onZoomAll(self, item):
+        dispatcher.send("show all")
+
+    def onPlayPause(self, item):
+        # since the X coord in a curveview affects the handling, one
+        # of them may be able to pick this up
+        results = dispatcher.send("onPlayPause")
+        times = [t for listener, t in results if t is not None]
+        self.music.playOrPause(t=times[0] if times else None)
+
+    def onSave(self, *args):
+        # only doing curves still. I hope to eliminate all this.
+        log.info("saving curves")
+        self.curveset.save()
+        log.info("saved")
+
+    def makeStatusLines(self, master):
+        """various labels that listen for dispatcher signals"""
+        for row, (signame, textfilter) in enumerate([
+            ('input time', lambda t: "%.2fs" % t),
+            ('output levels', lambda levels: textwrap.fill(
+                "; ".join([
+                    "%s:%.2f" % (n, v) for n, v in list(levels.items())[:2] if v
+                    > 0
+                ]), 70)),
+            ('update period', lambda t: "%.1fms" % (t * 1000)),
+            ('update status', lambda x: str(x)),
+        ]):
+            key = Gtk.Label("%s:" % signame)
+            value = Gtk.Label("")
+            master.resize(row + 1, 2)
+            master.attach(key, 0, 1, row, row + 1)
+            master.attach(value, 1, 2, row, row + 1)
+            key.set_alignment(1, 0)
+            value.set_alignment(0, 0)
+
+            dispatcher.connect(lambda val, value=value, tf=textfilter: value.
+                               set_text(tf(val)),
+                               signame,
+                               weak=False)
+        dispatcher.connect(lambda val: setattr(self, 'lastSeenInputTime', val),
+                           'input time',
+                           weak=False)
+        master.show_all()
+
+    def refreshCurveView(self):
+        wtree = self.wtree
+        mtimes = [
+            os.path.getmtime(f) for f in [
+                'light9/curvecalc/curveview.py',
+                'light9/curvecalc/zoomcontrol.py',
+            ]
+        ]
+
+        if (not hasattr(self, 'curvesetView') or
+                self.curvesetView._mtimes != mtimes):
+            print("reload curveview.py")
+            curvesVBox = wtree.get_object("curves")
+            zoomControlBox = wtree.get_object("zoomControlBox")
+            [curvesVBox.remove(c) for c in curvesVBox.get_children()]
+            [zoomControlBox.remove(c) for c in zoomControlBox.get_children()]
+            try:
+                linecache.clearcache()
+                imp.reload(curveview)
+
+                # old ones are not getting deleted right
+                if hasattr(self, 'curvesetView'):
+                    self.curvesetView.live = False
+
+                # mem problem somewhere; need to hold a ref to this
+                self.curvesetView = curveview.Curvesetview(
+                    self.graph, curvesVBox, zoomControlBox, self.curveset)
+                self.curvesetView._mtimes = mtimes
+
+                # this is scheduled after some tk shuffling, to
+                # try to minimize the number of times we redraw
+                # the curve at startup. If tk is very slow, it's
+                # ok. You'll just get some wasted redraws.
+                self.curvesetView.goLive()
+            except Exception:
+                print("reload failed:")
+                traceback.print_exc()
+        if self.opts.reload:
+            reactor.callLater(1, self.refreshCurveView)
+
+
+class MaxTime(object):
+    """
+    looks up the time in seconds for the session's current song
+    """
+
+    def __init__(self, graph, session):
+        self.graph, self.session = graph, session
+        graph.addHandler(self.update)
+
+    def update(self):
+        song = self.graph.value(self.session, L9['currentSong'])
+        if song is None:
+            self.maxtime = 0
+            return
+        musicfilename = showconfig.songOnDisk(song)
+        self.maxtime = wavelength(musicfilename)
+        log.info("new max time %r", self.maxtime)
+        dispatcher.send("max time", maxtime=self.maxtime)
+
+    def get(self):
+        return self.maxtime
+
+
+def launch(args, graph, session, opts, startTime, music):
+
+    try:
+        song = URIRef(args[0])
+        graph.patchObject(context=session,
+                          subject=session,
+                          predicate=L9['currentSong'],
+                          newObject=song)
+    except IndexError:
+        pass
+
+    curveset = Curveset(graph=graph, session=session)
+
+    log.debug("startup: output %s", time.time() - startTime)
+
+    mt = MaxTime(graph, session)
+    dispatcher.connect(lambda: mt.get(), "get max time", weak=False)
+
+    start = Main(graph, opts, session, curveset, music)
+    out = Output(graph, session, music, curveset, start.currentSubterms)
+
+    dispatcher.send("show all")
+
+    if opts.startup_only:
+        log.debug("quitting now because of --startup-only")
+        return
+
+    def hoverTimeResponse(requestHandler):
+        results = dispatcher.send("onPlayPause")
+        times = [t for listener, t in results if t is not None]
+        if not times:
+            requestHandler.set_status(404)
+            requestHandler.write("not hovering over any time")
+            return
+        with graph.currentState(tripleFilter=(session, L9['currentSong'],
+                                              None)) as g:
+            song = g.value(session, L9['currentSong'])
+            json.dump({"song": song, "hoverTime": times[0]}, requestHandler)
+
+    serveCurveEdit(networking.curveCalc.port, hoverTimeResponse, start.curveset)
+
+
+def main():
+    startTime = time.time()
+    parser = optparse.OptionParser()
+    parser.set_usage("%prog [opts] [songURI]")
+    parser.add_option("--debug", action="store_true", help="log at DEBUG")
+    parser.add_option("--reload",
+                      action="store_true",
+                      help="live reload of themes and code")
+    parser.add_option("--startup-only",
+                      action='store_true',
+                      help="quit after loading everything (for timing tests)")
+    parser.add_option("--profile", help='"hotshot" or "stat"')
+    clientsession.add_option(parser)
+    opts, args = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if opts.debug else logging.INFO)
+
+    log.debug("startup: music %s", time.time() - startTime)
+
+    session = clientsession.getUri('curvecalc', opts)
+
+    music = Music()
+    graph = SyncedGraph(networking.rdfdb.url, "curvecalc")
+
+    graph.initiallySynced.addCallback(lambda _: launch(args, graph, session,
+                                                       opts, startTime, music))
+    from light9 import prof
+    prof.run(reactor.run, profile=opts.profile)
+
+
+main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/curvecalc_all_subterms	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,6 @@
+#!/bin/zsh
+echo broken: use a plain shell loop
+exit 1
+
+
+for x (`ls $LIGHT9_SHOW/subterms`) { bin/curvecalc $x }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/dmx_color_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+#!bin/python
+from run_local import log
+import colorsys, time, logging
+from light9 import dmxclient
+from twisted.internet import reactor, task
+
+log.setLevel(logging.INFO)
+firstDmxChannel = 10
+
+
+def step():
+    hue = (time.time() * .2) % 1.0
+    r, g, b = colorsys.hsv_to_rgb(hue, 1, 1)
+    chans = [r, g, b]
+    log.info(chans)
+    dmxclient.outputlevels([0] * (firstDmxChannel - 1) + chans, twisted=True)
+
+
+task.LoopingCall(step).start(.05)
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/dmxserver	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,280 @@
+#!bin/python
+"""
+Replaced by bin/collector
+
+
+this is the only process to talk to the dmx hardware. other clients
+can connect to this server and present dmx output, and this server
+will max ('pile-on') all the client requests.
+
+this server has a level display which is the final set of values that
+goes to the hardware.
+
+clients shall connect to the xmlrpc server and send:
+
+  their PID (or some other cookie)
+
+  a length-n list of 0..1 levels which will represent the channel
+    values for the n first dmx channels.
+
+server is port 8030; xmlrpc method is called outputlevels(pid,levellist).
+
+todo:
+  save dmx on quit and restore on restart
+  if parport fails, run in dummy mode (and make an option for that too)
+"""
+
+from twisted.internet import reactor
+from twisted.web import xmlrpc, server
+import sys, time, os
+from optparse import OptionParser
+import run_local
+import txosc.dispatch, txosc. async
+from light9.io import ParportDMX, UsbDMX
+
+from light9.updatefreq import Updatefreq
+from light9 import networking
+
+from txzmq import ZmqEndpoint, ZmqFactory, ZmqPullConnection, ZmqRequestTimeoutError
+import json
+
+
+def startZmq(port, outputlevels):
+    zf = ZmqFactory()
+    e = ZmqEndpoint('bind', 'tcp://*:%s' % port)
+    s = ZmqPullConnection(zf, e)
+
+    def onPull(message):
+        msg = json.loads(message[0])
+        outputlevels(msg['clientid'], msg['levellist'])
+
+    s.onPull = onPull
+
+
+class ReceiverApplication(object):
+    """
+    receive UDP OSC messages. address is /dmx/1 for dmx channel 1,
+    arguments are 0-1 floats for that channel and any number of
+    following channels.
+    """
+
+    def __init__(self, port, lightServer):
+        self.port = port
+        self.lightServer = lightServer
+        self.receiver = txosc.dispatch.Receiver()
+        self.receiver.addCallback("/dmx/*", self.pixel_handler)
+        self._server_port = reactor.listenUDP(
+            self.port,
+            txosc. async .DatagramServerProtocol(self.receiver),
+            interface='0.0.0.0')
+        print("Listening OSC on udp port %s" % (self.port))
+
+    def pixel_handler(self, message, address):
+        # this is already 1-based though I don't know why
+        startChannel = int(message.address.split('/')[2])
+        levels = [a.value for a in message.arguments]
+        allLevels = [0] * (startChannel - 1) + levels
+        self.lightServer.xmlrpc_outputlevels("osc@%s" % startChannel, allLevels)
+
+
+class XMLRPCServe(xmlrpc.XMLRPC):
+
+    def __init__(self, options):
+
+        xmlrpc.XMLRPC.__init__(self)
+
+        self.clientlevels = {}  # clientID : list of levels
+        self.lastseen = {}  # clientID : time last seen
+        self.clientfreq = {}  # clientID : updatefreq
+
+        self.combinedlevels = []  # list of levels, after max'ing the clients
+        self.clientschanged = 1  # have clients sent anything since the last send?
+        self.options = options
+        self.lastupdate = 0  # time of last dmx send
+        self.laststatsprint = 0  # time
+
+        # desired seconds between sendlevels() calls
+        self.calldelay = 1 / options.updates_per_sec
+
+        print("starting parport connection")
+        self.parportdmx = UsbDMX(dimmers=90, port=options.dmx_device)
+        if os.environ.get('DMXDUMMY', 0):
+            self.parportdmx.godummy()
+        else:
+            self.parportdmx.golive()
+
+        self.updatefreq = Updatefreq()  # freq of actual dmx sends
+        self.num_unshown_updates = None
+        self.lastshownlevels = None
+        # start the loop
+        self.sendlevels()
+
+        # the other loop
+        self.purgeclients()
+
+    def purgeclients(self):
+        """forget about any clients who haven't sent levels in a while.
+        this runs in a loop"""
+
+        purge_age = 10  # seconds
+
+        reactor.callLater(1, self.purgeclients)
+
+        now = time.time()
+        cids = list(self.lastseen.keys())
+        for cid in cids:
+            lastseen = self.lastseen[cid]
+            if lastseen < now - purge_age:
+                print(("forgetting client %s (no activity for %s sec)" %
+                       (cid, purge_age)))
+                try:
+                    del self.clientlevels[cid]
+                except KeyError:
+                    pass
+                del self.clientfreq[cid]
+                del self.lastseen[cid]
+
+    def sendlevels(self):
+        """sends to dmx if levels have changed, or if we havent sent
+        in a while"""
+
+        reactor.callLater(self.calldelay, self.sendlevels)
+
+        if self.clientschanged:
+            # recalc levels
+
+            self.calclevels()
+
+            if (self.num_unshown_updates is None or  # first time
+                    self.options.fast_updates or  # show always
+                (
+                    self.combinedlevels != self.lastshownlevels and  # changed
+                    self.num_unshown_updates > 5)):  # not too frequent
+                self.num_unshown_updates = 0
+                self.printlevels()
+                self.lastshownlevels = self.combinedlevels[:]
+            else:
+                self.num_unshown_updates += 1
+
+        if time.time() > self.laststatsprint + 2:
+            self.laststatsprint = time.time()
+            self.printstats()
+
+        # used to be a fixed 1 in here, for the max delay between
+        # calls, instead of calldelay
+        if self.clientschanged or time.time(
+        ) > self.lastupdate + self.calldelay:
+            self.lastupdate = time.time()
+            self.sendlevels_dmx()
+
+        self.clientschanged = 0  # clear the flag
+
+    def calclevels(self):
+        """combine all the known client levels into self.combinedlevels"""
+        self.combinedlevels = []
+        for chan in range(0, self.parportdmx.dimmers):
+            x = 0
+            for clientlist in list(self.clientlevels.values()):
+                if len(clientlist) > chan:
+                    # clamp client levels to 0..1
+                    cl = max(0, min(1, clientlist[chan]))
+                    x = max(x, cl)
+            self.combinedlevels.append(x)
+
+    def printlevels(self):
+        """write all the levels to stdout"""
+        print("Levels:",
+              "".join(["% 2d " % (x * 100) for x in self.combinedlevels]))
+
+    def printstats(self):
+        """print the clock, freq, etc, with a \r at the end"""
+
+        sys.stdout.write("dmxserver up at %s, [polls %s] " % (
+            time.strftime("%H:%M:%S"),
+            str(self.updatefreq),
+        ))
+        for cid, freq in list(self.clientfreq.items()):
+            sys.stdout.write("[%s %s] " % (cid, str(freq)))
+        sys.stdout.write("\r")
+        sys.stdout.flush()
+
+    def sendlevels_dmx(self):
+        """output self.combinedlevels to dmx, and keep the updates/sec stats"""
+        # they'll get divided by 100
+        if self.parportdmx:
+            self.parportdmx.sendlevels([l * 100 for l in self.combinedlevels])
+        self.updatefreq.update()
+
+    def xmlrpc_echo(self, x):
+        return x
+
+    def xmlrpc_outputlevels(self, cid, levellist):
+        """send a unique id for your client (name+pid maybe), then
+        the variable-length dmx levellist (scaled 0..1)"""
+        if levellist != self.clientlevels.get(cid, None):
+            self.clientlevels[cid] = levellist
+            self.clientschanged = 1
+        self.trackClientFreq(cid)
+        return "ok"
+
+    def xmlrpc_currentlevels(self, cid):
+        """get a list of levels we're currently sending out. All
+        channels beyond the list you get back, they're at zero."""
+        # if this is still too slow, it might help to return a single
+        # pickled string
+        self.trackClientFreq(cid)
+        trunc = self.combinedlevels[:]
+        i = len(trunc) - 1
+        if i < 0:
+            return []
+        while trunc[i] == 0 and i >= 0:
+            i -= 1
+        if i < 0:
+            return []
+        trunc = trunc[:i + 1]
+        return trunc
+
+    def trackClientFreq(self, cid):
+        if cid not in self.lastseen:
+            print("hello new client %s" % cid)
+            self.clientfreq[cid] = Updatefreq()
+        self.lastseen[cid] = time.time()
+        self.clientfreq[cid].update()
+
+
+parser = OptionParser()
+parser.add_option("-f",
+                  "--fast-updates",
+                  action='store_true',
+                  help=('display all dmx output to stdout instead '
+                        'of the usual reduced output'))
+parser.add_option("-r",
+                  "--updates-per-sec",
+                  type='float',
+                  default=20,
+                  help=('dmx output frequency'))
+parser.add_option("-d",
+                  "--dmx-device",
+                  default='/dev/dmx0',
+                  help='dmx device name')
+parser.add_option("-n",
+                  "--dummy",
+                  action="store_true",
+                  help="dummy mode, same as DMXDUMMY=1 env variable")
+(options, songfiles) = parser.parse_args()
+
+print(options)
+
+if options.dummy:
+    os.environ['DMXDUMMY'] = "1"
+
+port = networking.dmxServer.port
+print("starting xmlrpc server on port %s" % port)
+xmlrpcServe = XMLRPCServe(options)
+reactor.listenTCP(port, server.Site(xmlrpcServe))
+
+startZmq(networking.dmxServerZmq.port, xmlrpcServe.xmlrpc_outputlevels)
+
+oscApp = ReceiverApplication(9051, xmlrpcServe)
+
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/effectListing	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+#!/bin/sh
+pnpm exec vite -c light9/effect/listing/web/vite.config.ts &
+wait
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/effectSequencer	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,5 @@
+#!/bin/zsh
+pnpm exec vite -c light9/effect/sequencer/web/vite.config.ts &
+pdm run uvicorn light9.effect.sequencer.service:app --host 0.0.0.0 --port 8213 --no-access-log 
+wait
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/effecteval	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,283 @@
+#!bin/python
+
+from run_local import log
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, returnValue
+import cyclone.web, cyclone.websocket, cyclone.httpclient
+import sys, optparse, logging, json, itertools
+from rdflib import URIRef, Literal
+
+from light9 import networking, showconfig
+from light9.effecteval.effect import EffectNode
+from light9.effect.edit import getMusicStatus, songNotePatch
+from light9.effecteval.effectloop import makeEffectLoop
+from light9.metrics import metrics, metricsRoute
+from light9.namespaces import L9
+from rdfdb.patch import Patch
+from rdfdb.syncedgraph import SyncedGraph
+
+from cycloneerr import PrettyErrorHandler
+from light9.coffee import StaticCoffee
+
+
+
+class EffectEdit(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        self.set_header('Content-Type', 'text/html')
+        self.write(open("light9/effecteval/effect.html").read())
+
+    def delete(self):
+        graph = self.settings.graph
+        uri = URIRef(self.get_argument('uri'))
+        with graph.currentState(tripleFilter=(None, L9['effect'], uri)) as g:
+            song = ctx = list(g.subjects(L9['effect'], uri))[0]
+        self.settings.graph.patch(
+            Patch(delQuads=[
+                (song, L9['effect'], uri, ctx),
+            ]))
+
+
+@inlineCallbacks
+def currentSong():
+    s = (yield getMusicStatus())['song']
+    if s is None:
+        raise ValueError("no current song")
+    returnValue(URIRef(s))
+
+
+class SongEffects(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def wideOpenCors(self):
+        self.set_header('Access-Control-Allow-Origin', '*')
+        self.set_header('Access-Control-Allow-Methods',
+                        'GET, PUT, POST, DELETE, OPTIONS')
+        self.set_header('Access-Control-Max-Age', '1000')
+        self.set_header('Access-Control-Allow-Headers',
+                        'Content-Type, Authorization, X-Requested-With')
+
+    def options(self):
+        self.wideOpenCors()
+        self.write('')
+
+    @inlineCallbacks
+    def post(self):
+        self.wideOpenCors()
+        dropped = URIRef(self.get_argument('drop'))
+
+        try:
+            song = URIRef(self.get_argument('uri'))
+        except Exception:  # which?
+            song = yield currentSong()
+
+        event = self.get_argument('event', default='default')
+
+        note = self.get_argument('note', default=None)
+        if note is not None:
+            note = URIRef(note)
+
+        log.info("adding to %s", song)
+        note, p = yield songNotePatch(self.settings.graph,
+                                      dropped,
+                                      song,
+                                      event,
+                                      ctx=song,
+                                      note=note)
+
+        self.settings.graph.patch(p)
+        self.settings.graph.suggestPrefixes(song, {'song': URIRef(song + '/')})
+        self.write(json.dumps({'note': note}))
+
+
+class SongEffectsUpdates(cyclone.websocket.WebSocketHandler):
+
+    def connectionMade(self, *args, **kwargs):
+        self.graph = self.settings.graph
+        self.graph.addHandler(self.updateClient)
+
+    def updateClient(self):
+        # todo: abort if client is gone
+        playlist = self.graph.value(showconfig.showUri(), L9['playList'])
+        songs = list(self.graph.items(playlist))
+        out = []
+        for s in songs:
+            out.append({'uri': s, 'label': self.graph.label(s), 'effects': []})
+            seen = set()
+            for n in self.graph.objects(s, L9['note']):
+                for uri in self.graph.objects(n, L9['effectClass']):
+                    if uri in seen:
+                        continue
+                    seen.add(uri)
+                    out[-1]['effects'].append({
+                        'uri': uri,
+                        'label': self.graph.label(uri)
+                    })
+            out[-1]['effects'].sort(key=lambda e: e['uri'])
+
+        self.sendMessage({'songs': out})
+
+
+class EffectUpdates(cyclone.websocket.WebSocketHandler):
+    """
+    stays alive for the life of the effect page
+    """
+
+    def connectionMade(self, *args, **kwargs):
+        log.info("websocket opened")
+        self.uri = URIRef(self.get_argument('uri'))
+        self.sendMessage({'hello': repr(self)})
+
+        self.graph = self.settings.graph
+        self.graph.addHandler(self.updateClient)
+
+    def updateClient(self):
+        # todo: if client has dropped, abort and don't get any more
+        # graph updates
+
+        # EffectNode knows how to put them in order. Somehow this is
+        # not triggering an update when the order changes.
+        en = EffectNode(self.graph, self.uri)
+        codeLines = [c.code for c in en.codes]
+        self.sendMessage({'codeLines': codeLines})
+
+    def connectionLost(self, reason):
+        log.info("websocket closed")
+
+    def messageReceived(self, message):
+        log.info("got message %s" % message)
+        # write a patch back to the graph
+
+
+def replaceObjects(graph, c, s, p, newObjs):
+    patch = graph.getObjectPatch(context=c,
+                                 subject=s,
+                                 predicate=p,
+                                 newObject=newObjs[0])
+
+    moreAdds = []
+    for line in newObjs[1:]:
+        moreAdds.append((s, p, line, c))
+    fullPatch = Patch(delQuads=patch.delQuads,
+                      addQuads=patch.addQuads + moreAdds)
+    graph.patch(fullPatch)
+
+
+class Code(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def put(self):
+        effect = URIRef(self.get_argument('uri'))
+        codeLines = []
+        for i in itertools.count(0):
+            k = 'codeLines[%s][text]' % i
+            v = self.get_argument(k, None)
+            if v is not None:
+                codeLines.append(Literal(v))
+            else:
+                break
+        if not codeLines:
+            log.info("no codelines received on PUT /code")
+            return
+        with self.settings.graph.currentState(tripleFilter=(None, L9['effect'],
+                                                            effect)) as g:
+            song = next(g.subjects(L9['effect'], effect))
+
+        replaceObjects(self.settings.graph, song, effect, L9['code'], codeLines)
+
+        # right here we could tell if the code has a python error and return it
+        self.send_error(202)
+
+
+class EffectEval(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    @inlineCallbacks
+    def get(self):
+        # return dmx list for that effect
+        uri = URIRef(self.get_argument('uri'))
+        response = yield cyclone.httpclient.fetch(
+            networking.musicPlayer.path('time'))
+        songTime = json.loads(response.body)['t']
+
+        node = EffectNode(self.settings.graph, uri)
+        outSub = node.eval(songTime)
+        self.write(json.dumps(outSub.get_dmx_list()))
+
+
+# Completely not sure where the effect background loop should
+# go. Another process could own it, and get this request repeatedly:
+class SongEffectsEval(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        song = URIRef(self.get_argument('song'))
+        effects = effectsForSong(self.settings.graph, song)  # noqa
+        raise NotImplementedError
+        self.write(maxDict(effectDmxDict(e) for e in effects))  # noqa
+        # return dmx dict for all effects in the song, already combined
+
+
+class App(object):
+
+    def __init__(self, show, outputWhere):
+        self.show = show
+        self.outputWhere = outputWhere
+        self.graph = SyncedGraph(networking.rdfdb.url, "effectEval")
+        self.graph.initiallySynced.addCallback(self.launch).addErrback(
+            log.error)
+
+    def launch(self, *args):
+        log.info('launch')
+        if self.outputWhere:
+            self.loop = makeEffectLoop(self.graph, self.outputWhere)
+            self.loop.startLoop()
+
+        SFH = cyclone.web.StaticFileHandler
+        self.cycloneApp = cyclone.web.Application(handlers=[
+            (r'/()', SFH, {
+                'path': 'light9/effecteval',
+                'default_filename': 'index.html'
+            }),
+            (r'/effect', EffectEdit),
+            (r'/effect\.js', StaticCoffee, {
+                'src': 'light9/effecteval/effect.coffee'
+            }),
+            (r'/(effect-components\.html)', SFH, {
+                'path': 'light9/effecteval'
+            }),
+            (r'/effectUpdates', EffectUpdates),
+            (r'/code', Code),
+            (r'/songEffectsUpdates', SongEffectsUpdates),
+            (r'/effect/eval', EffectEval),
+            (r'/songEffects', SongEffects),
+            (r'/songEffects/eval', SongEffectsEval),
+            metricsRoute(),
+        ],
+                                                  debug=True,
+                                                  graph=self.graph)
+        reactor.listenTCP(networking.effectEval.port, self.cycloneApp)
+        log.info("listening on %s" % networking.effectEval.port)
+
+
+if __name__ == "__main__":
+    parser = optparse.OptionParser()
+    parser.add_option(
+        '--show',
+        help='show URI, like http://light9.bigasterisk.com/show/dance2008',
+        default=showconfig.showUri())
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    parser.add_option("--twistedlog",
+                      action="store_true",
+                      help="twisted logging")
+    parser.add_option("--output", metavar="WHERE", help="dmx or leds")
+    (options, args) = parser.parse_args()
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+    if not options.show:
+        raise ValueError("missing --show http://...")
+
+    app = App(URIRef(options.show), options.output)
+    if options.twistedlog:
+        from twisted.python import log as twlog
+        twlog.startLogging(sys.stderr)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/fade	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec pnpm exec vite -c light9/fade/vite.config.ts
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/gobutton	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,3 @@
+#!/bin/sh
+# uri should be set from $LIGHT9_SHOW/config.n3
+exec curl --silent -d '' http://localhost:8040/go
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/gtk_dnd_demo.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+#!bin/python
+import run_local
+import gtk
+import sys
+sys.path.append(".")
+from rdflib import URIRef
+from light9 import networking
+from light9.editchoicegtk import EditChoice, Local
+from light9.observable import Observable
+from rdfdb.syncedgraph import SyncedGraph
+
+win = gtk.Window()
+
+graph = SyncedGraph(networking.rdfdb.url, "gtkdnddemo")
+
+r1 = URIRef("http://example.com/interestingThing")
+v = Observable(r1)
+win.add(EditChoice(graph, v))
+win.show_all()
+gtk.main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/inputdemo	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,65 @@
+#!bin/python
+import sys
+sys.path.append('/usr/lib/python2.7/dist-packages')  # For gtk
+from twisted.internet import gtk3reactor
+gtk3reactor.install()
+from twisted.internet import reactor
+from rdflib import URIRef
+import optparse, logging, time
+from gi.repository import Gtk
+from run_local import log
+from light9 import networking
+from light9 import clientsession
+from rdfdb.syncedgraph import SyncedGraph
+from light9.curvecalc.client import sendLiveInputPoint
+
+
+class App(object):
+
+    def __init__(self):
+        parser = optparse.OptionParser()
+        parser.set_usage("%prog [opts] [curve uri]")
+        parser.add_option("--debug", action="store_true", help="log at DEBUG")
+        clientsession.add_option(parser)
+        opts, args = parser.parse_args()
+
+        log.setLevel(logging.DEBUG if opts.debug else logging.INFO)
+
+        self.session = clientsession.getUri('inputdemo', opts)
+        self.graph = SyncedGraph(networking.rdfdb.url, "inputdemo")
+
+        self.graph.initiallySynced.addCallback(lambda _: self.launch())
+
+        self.curve = args[0] if args else URIRef(
+            'http://light9.bigasterisk.com/show/dance2014/song1/curve/c-1401259747.675542'
+        )
+        print("sending points on curve %s" % self.curve)
+
+        reactor.run()
+
+    def launch(self):
+        win = Gtk.Window()
+
+        slider = Gtk.Scale.new_with_range(orientation=Gtk.Orientation.VERTICAL,
+                                          min=0,
+                                          max=1,
+                                          step=.001)
+        slider.props.inverted = True
+        slider.connect('value-changed', self.onChanged)
+
+        win.add(slider)
+        win.parse_geometry('50x250')
+        win.connect("delete-event", lambda *a: reactor.crash())
+        win.connect("destroy", lambda *a: reactor.crash())
+        win.show_all()
+
+    def onChanged(self, scale):
+        t1 = time.time()
+        d = sendLiveInputPoint(self.curve, scale.get_value())
+
+        @d.addCallback
+        def done(result):
+            print("posted in %.1f ms" % (1000 * (time.time() - t1)))
+
+
+App()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/inputquneo	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,115 @@
+#!bin/python
+"""
+read Quneo midi events, write to curvecalc and maybe to effects
+"""
+
+from run_local import log
+import logging, urllib.request, urllib.parse, urllib.error
+import cyclone.web, cyclone.httpclient
+from rdflib import URIRef
+from twisted.internet import reactor, task
+from light9.curvecalc.client import sendLiveInputPoint
+from light9.namespaces import L9, RDF
+from rdfdb.syncedgraph import SyncedGraph
+from light9 import networking
+
+import sys
+sys.path.append('/usr/lib/python2.7/dist-packages')  # For pygame
+import pygame.midi
+
+curves = {
+    23: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-2'),
+    24: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-3'),
+    25: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-4'),
+    6: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-5'),
+    18: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-6'),
+}
+
+
+class WatchMidi:
+
+    def __init__(self, graph):
+        self.graph = graph
+        pygame.midi.init()
+
+        dev = self.findQuneo()
+        self.inp = pygame.midi.Input(dev)
+        task.LoopingCall(self.step).start(.05)
+
+        self.noteIsOn = {}
+
+        self.effectMap = {}  # note: effect class uri
+        self.graph.addHandler(self.setupNotes)
+
+    def setupNotes(self):
+        for e in self.graph.subjects(RDF.type, L9['EffectClass']):
+            qn = self.graph.value(e, L9['quneoNote'])
+            if qn:
+                self.effectMap[int(qn)] = e
+        log.info("setup with %s effects", len(self.effectMap))
+
+    def findQuneo(self):
+        for dev in range(pygame.midi.get_count()):
+            interf, name, isInput, isOutput, opened = pygame.midi.get_device_info(
+                dev)
+            if 'QUNEO' in name and isInput:
+                return dev
+        raise ValueError("didn't find quneo input device")
+
+    def step(self):
+        if not self.inp.poll():
+            return
+        NOTEON, NOTEOFF = 144, 128
+        for ev in self.inp.read(999):
+            (status, d1, d2, _), _ = ev
+            if status in [NOTEON, NOTEOFF]:
+                print(status, d1, d2)
+
+            if status == NOTEON:
+                if not self.noteIsOn.get(d1):
+                    self.noteIsOn[d1] = True
+                    try:
+                        e = self.effectMap[d1]
+                        cyclone.httpclient.fetch(
+                            url=networking.effectEval.path('songEffects'),
+                            method='POST',
+                            headers={
+                                'Content-Type':
+                                ['application/x-www-form-urlencoded']
+                            },
+                            postdata=urllib.parse.urlencode([('drop', e)]),
+                        )
+                    except KeyError:
+                        pass
+
+            if status == NOTEOFF:
+                self.noteIsOn[d1] = False
+
+            if 0:
+                # curve editing mode, not done yet
+                for group in [(23, 24, 25), (6, 18)]:
+                    if d1 in group:
+                        if not self.noteIsOn.get(group):
+                            print("start zero")
+
+                            for d in group:
+                                sendLiveInputPoint(curves[d], 0)
+                            self.noteIsOn[group] = True
+                        else:  # miss first update
+                            sendLiveInputPoint(curves[d1], d2 / 127)
+
+                    if status == 128:  #noteoff
+                        for d in group:
+                            sendLiveInputPoint(curves[d], 0)
+                        self.noteIsOn[group] = False
+
+
+def main():
+    log.setLevel(logging.DEBUG)
+    graph = SyncedGraph(networking.rdfdb.url, "inputQuneo")
+    wm = WatchMidi(graph)
+    reactor.run()
+    del wm
+
+
+main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/kcclient	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+"""send KeyboardComposer a fade request, for use from the shell"""
+
+import sys
+import run_local
+from restclient import Resource
+from light9 import networking
+
+subname = sys.argv[1]
+level = sys.argv[2]
+fadesecs = '0'
+if len(sys.argv) > 3:
+    fadesecs = sys.argv[3]
+
+levelServer = Resource(networking.keyboardComposer.url)
+levelServer.post('fadesub', subname=subname, level=level, secs=fadesecs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/keyboardcomposer	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,712 @@
+#!bin/python
+
+from run_local import log
+
+from optparse import OptionParser
+from typing import Any, Dict, Tuple, List
+import cgi, time, logging
+import imp
+import tkinter.tix as tk
+
+from louie import dispatcher
+from rdflib import URIRef, Literal
+from twisted.internet import reactor, tksupport
+from twisted.web import resource
+import webcolors, colorsys
+
+from bcf2000 import BCF2000
+from light9 import clientsession
+from light9 import showconfig, networking, prof
+from light9.Fadable import Fadable
+from light9.effect.sequencer import CodeWatcher
+from light9.effect.settings import DeviceSettings
+from light9.effect.simple_outputs import SimpleOutputs
+from light9.namespaces import L9, RDF, RDFS
+from light9.subclient import SubClient
+from light9.tkdnd import initTkdnd, dragSourceRegister, dropTargetRegister
+from light9.uihelpers import toplevelat
+from rdfdb.patch import Patch
+from rdfdb.syncedgraph import SyncedGraph
+import light9.effect.effecteval
+
+nudge_keys = {'up': list('qwertyui'), 'down': list('asdfghjk')}
+
+
+class DummySliders:
+
+    def valueOut(self, name, value):
+        pass
+
+    def close(self):
+        pass
+
+    def reopen(self):
+        pass
+
+
+class SubScale(tk.Scale, Fadable):
+
+    def __init__(self, master, *args, **kw):
+        self.scale_var = kw.get('variable') or tk.DoubleVar()
+        kw.update({
+            'variable': self.scale_var,
+            'from': 1.,
+            'to': 0.,
+            'showvalue': 0,
+            'sliderlength': 15,
+            'res': 0.001,
+            'width': 40,
+            'troughcolor': 'black',
+            'bg': 'grey40',
+            'highlightthickness': 1,
+            'bd': 1,
+            'highlightcolor': 'red',
+            'highlightbackground': 'black',
+            'activebackground': 'red'
+        })
+        tk.Scale.__init__(self, master, *args, **kw)
+        Fadable.__init__(self, var=self.scale_var, wheel_step=0.05)
+        self.draw_indicator_colors()
+
+    def draw_indicator_colors(self):
+        if self.scale_var.get() == 0:
+            self['troughcolor'] = 'black'
+        else:
+            self['troughcolor'] = 'blue'
+
+
+class SubmasterBox(tk.Frame):
+    """
+    this object owns the level of the submaster (the rdf graph is the
+    real authority)
+
+    This leaks handlers or DoubleVars or something and tries to just
+    skip the obsolete ones. It'll get slower and bigger over
+    time. todo: make aa web version.
+    """
+
+    def __init__(self, master, graph, sub, session, col, row):
+        self.graph = graph
+        self.sub = sub
+        self.session = session
+        self.col, self.row = col, row
+        bg = self.graph.value(sub, L9['color'], default='#000000')
+        rgb = webcolors.hex_to_rgb(bg)
+        hsv = colorsys.rgb_to_hsv(*[x / 255 for x in rgb])
+        darkBg = webcolors.rgb_to_hex(
+            tuple([
+                int(x * 255) for x in colorsys.hsv_to_rgb(hsv[0], hsv[1], .2)
+            ]))
+        tk.Frame.__init__(self, master, bd=1, relief='raised', bg=bg)
+        self.name = self.graph.label(sub)
+        self._val = 0.0
+        self.slider_var = tk.DoubleVar()
+        self.pauseTrace = False
+        self.scale = SubScale(self, variable=self.slider_var, width=20)
+        self.dead = False
+
+        self.namelabel = tk.Label(self,
+                                  font="Arial 9",
+                                  bg=darkBg,
+                                  fg='white',
+                                  pady=0)
+        self.graph.addHandler(self.updateName)
+
+        self.namelabel.pack(side=tk.TOP)
+        self.levellabel = tk.Label(self,
+                                   textvariable=self.slider_var,
+                                   font="Arial 6",
+                                   bg='black',
+                                   fg='white',
+                                   pady=0)
+        self.levellabel.pack(side=tk.TOP)
+        self.scale.pack(side=tk.BOTTOM, expand=1, fill=tk.BOTH)
+
+        for w in [self, self.namelabel, self.levellabel]:
+            dragSourceRegister(w, 'copy', 'text/uri-list', sub)
+
+        self._slider_var_trace = self.slider_var.trace('w', self.slider_changed)
+
+        self.graph.addHandler(self.updateLevelFromGraph)
+
+        # initial position
+        # stil need? dispatcher.send("send_to_hw", sub=sub.uri, hwCol=col + 1)
+
+    def getVal(self) -> float:
+        return self._val
+
+    def setVal(self, newVal: float) -> None:
+        if self.dead:
+            return
+        try:
+            self.scale.set(newVal)
+            self.levellabel.config(text=str(newVal))
+        except Exception:
+            log.warning("disabling handlers on broken subbox")
+            self.dead = True
+
+    def cleanup(self):
+        self.slider_var.trace_vdelete('w', self._slider_var_trace)
+
+    def slider_changed(self, *args):
+        self._val = self.scale.get()
+        self.scale.draw_indicator_colors()
+
+        if self.pauseTrace:
+            return
+        self.updateGraphWithLevel(self.sub, self.getVal())
+
+        # needs fixing: plan is to use dispatcher or a method call to tell a hardware-mapping object who changed, and then it can make io if that's a current hw slider
+        dispatcher.send("send_to_hw",
+                        sub=self.sub,
+                        hwCol=self.col + 1,
+                        boxRow=self.row)
+
+    def updateGraphWithLevel(self, uri, level):
+        """in our per-session graph, we maintain SubSetting objects like this:
+
+           ?session :subSetting [a :SubSetting; :sub ?s; :level ?l]
+        """
+        # move to syncedgraph patchMapping
+
+        self.graph.patchMapping(context=self.session,
+                                subject=self.session,
+                                predicate=L9['subSetting'],
+                                nodeClass=L9['SubSetting'],
+                                keyPred=L9['sub'],
+                                newKey=uri,
+                                valuePred=L9['level'],
+                                newValue=Literal(level))
+
+    def updateLevelFromGraph(self):
+        """read rdf level, write it to subbox.slider_var"""
+        # move this to syncedgraph readMapping
+        graph = self.graph
+
+        for setting in graph.objects(self.session, L9['subSetting']):
+            if graph.value(setting, L9['sub']) == self.sub:
+                self.pauseTrace = True  # don't bounce this update back to server
+                try:
+                    self.setVal(graph.value(setting, L9['level']).toPython())
+                finally:
+                    self.pauseTrace = False
+
+    def updateName(self):
+        if self.scale is None:
+            return
+
+        def shortUri(u):
+            return '.../' + u.split('/')[-1]
+
+        try:
+            self.namelabel.config(
+                text=self.graph.label(self.sub) or shortUri(self.sub))
+        except Exception:
+            log.warn("disabling handlers on broken subbox")
+            self.scale = None
+
+
+class KeyboardComposer(tk.Frame, SubClient):
+
+    def __init__(self,
+                 root: tk.Tk,
+                 graph: SyncedGraph,
+                 session: URIRef,
+                 hw_sliders=True):
+        tk.Frame.__init__(self, root, bg='black')
+        SubClient.__init__(self)
+        self.graph = graph
+        self.session = session
+
+        self.subbox: Dict[URIRef, SubmasterBox] = {}  # sub uri : SubmasterBox
+        self.slider_table: Dict[Tuple[int, int], SubmasterBox] = {
+        }  # coords : SubmasterBox
+        self.rows: List[tk.Frame] = []  # this holds Tk Frames for each row
+
+        self.current_row = 0  # should come from session graph
+
+        self.use_hw_sliders = hw_sliders
+        self.connect_to_hw(hw_sliders)
+
+        self.make_key_hints()
+        self.make_buttons()
+
+        self.graph.addHandler(self.redraw_sliders)
+
+        self.codeWatcher = CodeWatcher(
+            onChange=lambda: self.graph.addHandler(self.redraw_sliders))
+
+        self.send_levels_loop(periodSec=.05)
+        self.graph.addHandler(self.rowFromGraph)
+
+    def make_buttons(self):
+        self.buttonframe = tk.Frame(self, bg='black')
+        self.buttonframe.pack(side=tk.BOTTOM)
+
+        self.sliders_status_var = tk.IntVar()
+        self.sliders_status_var.set(self.use_hw_sliders)
+        self.sliders_checkbutton = tk.Checkbutton(
+            self.buttonframe,
+            text="Sliders",
+            variable=self.sliders_status_var,
+            command=lambda: self.toggle_slider_connectedness(),
+            bg='black',
+            fg='white')
+        self.sliders_checkbutton.pack(side=tk.LEFT)
+
+        self.alltozerobutton = tk.Button(self.buttonframe,
+                                         text="All to Zero",
+                                         command=self.alltozero,
+                                         bg='black',
+                                         fg='white')
+        self.alltozerobutton.pack(side='left')
+
+        self.save_stage_button = tk.Button(
+            self.buttonframe,
+            text="Save",
+            command=lambda: self.save_current_stage(self.sub_name.get()),
+            bg='black',
+            fg='white')
+        self.save_stage_button.pack(side=tk.LEFT)
+        self.sub_name = tk.Entry(self.buttonframe, bg='black', fg='white')
+        self.sub_name.pack(side=tk.LEFT)
+
+    def redraw_sliders(self) -> None:
+        self.draw_sliders()
+        if len(self.rows):
+            self.change_row(self.current_row)
+            self.rows[self.current_row].focus()
+
+        self.stop_frequent_update_time = 0
+
+    def draw_sliders(self):
+        for r in self.rows:
+            r.destroy()
+        self.rows = []
+        for b in list(self.subbox.values()):
+            b.cleanup()
+        self.subbox.clear()
+        self.slider_table.clear()
+
+        self.tk_focusFollowsMouse()
+
+        rowcount = -1
+        col = 0
+        last_group = None
+
+        withgroups = []
+        for effect in self.graph.subjects(RDF.type, L9['Effect']):
+            withgroups.append((self.graph.value(effect, L9['group']),
+                               self.graph.value(effect, L9['order']),
+                               self.graph.label(effect), effect))
+        withgroups.sort()
+
+        log.debug("withgroups %s", withgroups)
+
+        self.effectEval: Dict[URIRef, light9.effect.effecteval.EffectEval] = {}
+        imp.reload(light9.effect.effecteval)
+        simpleOutputs = SimpleOutputs(self.graph)
+        for group, order, sortLabel, effect in withgroups:
+            if col == 0 or group != last_group:
+                row = self.make_row(group)
+                rowcount += 1
+                col = 0
+
+            subbox = SubmasterBox(row, self.graph, effect, self.session, col,
+                                  rowcount)
+            subbox.place(relx=col / 8, rely=0, relwidth=1 / 8, relheight=1)
+            self.subbox[effect] = self.slider_table[(rowcount, col)] = subbox
+
+            self.setup_key_nudgers(subbox.scale)
+
+            self.effectEval[effect] = light9.effect.effecteval.EffectEval(
+                self.graph, effect, simpleOutputs)
+
+            col = (col + 1) % 8
+            last_group = group
+
+    def toggle_slider_connectedness(self):
+        self.use_hw_sliders = not self.use_hw_sliders
+        if self.use_hw_sliders:
+            self.sliders.reopen()
+        else:
+            self.sliders.close()
+        self.change_row(self.current_row)
+        self.rows[self.current_row].focus()
+
+    def connect_to_hw(self, hw_sliders):
+        log.info('connect_to_hw')
+        if hw_sliders:
+            try:
+                self.sliders = Sliders(self)
+                log.info("connected to sliders")
+            except IOError as e:
+                log.info("no hardware sliders %r", e)
+                self.sliders = DummySliders()
+                self.use_hw_sliders = False
+            dispatcher.connect(self.send_to_hw, 'send_to_hw')
+        else:
+            self.sliders = DummySliders()
+
+    def make_key_hints(self):
+        keyhintrow = tk.Frame(self)
+
+        col = 0
+        for upkey, downkey in zip(nudge_keys['up'], nudge_keys['down']):
+            # what a hack!
+            downkey = downkey.replace('semicolon', ';')
+            upkey, downkey = (upkey.upper(), downkey.upper())
+
+            # another what a hack!
+            keylabel = tk.Label(keyhintrow,
+                                text='%s\n%s' % (upkey, downkey),
+                                width=1,
+                                font=('Arial', 10),
+                                bg='red',
+                                fg='white',
+                                anchor='c')
+            keylabel.pack(side=tk.LEFT, expand=1, fill=tk.X)
+            col += 1
+
+        keyhintrow.pack(fill=tk.X, expand=0)
+        self.keyhints = keyhintrow
+
+    def setup_key_nudgers(self, tkobject):
+        for d, keys in list(nudge_keys.items()):
+            for key in keys:
+                # lowercase makes full=0
+                keysym = "<KeyPress-%s>" % key
+                tkobject.bind(keysym,
+                              lambda evt, num=keys.index(key), d=d: self.
+                              got_nudger(num, d))
+
+                # uppercase makes full=1
+                keysym = "<KeyPress-%s>" % key.upper()
+                keysym = keysym.replace('SEMICOLON', 'colon')
+                tkobject.bind(keysym,
+                              lambda evt, num=keys.index(key), d=d: self.
+                              got_nudger(num, d, full=1))
+
+        # Row changing:
+        # Page dn, C-n, and ] do down
+        # Page up, C-p, and ' do up
+        for key in '<Prior> <Next> <Control-n> <Control-p> ' \
+                   '<Key-bracketright> <Key-apostrophe>'.split():
+            tkobject.bind(key, self.change_row_cb)
+
+    def change_row_cb(self, event):
+        diff = 1
+        if event.keysym in ('Prior', 'p', 'bracketright'):
+            diff = -1
+        self.change_row(self.current_row + diff)
+
+    def rowFromGraph(self):
+        self.change_row(int(
+            self.graph.value(self.session, L9['currentRow'], default=0)),
+                        fromGraph=True)
+
+    def change_row(self, row: int, fromGraph=False) -> None:
+        old_row = self.current_row
+        self.current_row = row
+        self.current_row = max(0, self.current_row)
+        self.current_row = min(len(self.rows) - 1, self.current_row)
+        try:
+            row = self.rows[self.current_row]
+        except IndexError:
+            # if we're mid-load, this row might still appear soon. If
+            # we changed interactively, the user is out of bounds and
+            # needs to be brought back in
+            if fromGraph:
+                return
+            raise
+
+        self.unhighlight_row(old_row)
+        self.highlight_row(self.current_row)
+        self.keyhints.pack_configure(before=row)
+
+        if not fromGraph:
+            self.graph.patchObject(self.session, self.session, L9['currentRow'],
+                                   Literal(self.current_row))
+
+        for col in range(1, 9):
+            try:
+                subbox = self.slider_table[(self.current_row, col - 1)]
+                self.sliders.valueOut("button-upper%d" % col, True)
+            except KeyError:
+                # unfilled bottom row has holes (plus rows with incomplete
+                # groups
+                self.sliders.valueOut("button-upper%d" % col, False)
+                self.sliders.valueOut("slider%d" % col, 0)
+                continue
+            self.send_to_hw(sub=subbox.sub, hwCol=col, boxRow=self.current_row)
+
+    def got_nudger(self, number, direction, full=0):
+        try:
+            subbox = self.slider_table[(self.current_row, number)]
+        except KeyError:
+            return
+
+        if direction == 'up':
+            if full:
+                subbox.scale.fade(1)
+            else:
+                subbox.scale.increase()
+        else:
+            if full:
+                subbox.scale.fade(0)
+            else:
+                subbox.scale.decrease()
+
+    def hw_slider_moved(self, col, value):
+        value = int(value * 100) / 100
+        try:
+            subbox = self.slider_table[(self.current_row, col)]
+        except KeyError:
+            return  # no slider assigned at that column
+
+        if hasattr(self, 'pendingHwSet'):
+            import twisted.internet.error
+            try:
+                self.pendingHwSet.cancel()
+            except twisted.internet.error.AlreadyCalled:
+                pass
+        self.pendingHwSet = reactor.callLater(.01, subbox.setVal, value)
+
+    def send_to_hw(self, sub, hwCol, boxRow):
+        if isinstance(self.sliders, DummySliders):
+            return
+
+        assert isinstance(sub, URIRef), repr(sub)
+
+        if boxRow != self.current_row:
+            return
+
+        try:
+            level = self.get_levels()[sub]
+        except KeyError:
+            log.warn("%r not in %r", sub, self.get_levels())
+            raise
+        v = round(127 * level)
+        chan = "slider%s" % hwCol
+
+        # workaround for some rounding issue, where we receive one
+        # value and then decide to send back a value that's one step
+        # lower.  -5 is a fallback for having no last value.  hopefully
+        # we won't really see it
+        if abs(v - self.sliders.lastValue.get(chan, -5)) <= 1:
+            return
+        self.sliders.valueOut(chan, v)
+
+    def make_row(self, group):
+        """group is a URI or None"""
+        row = tk.Frame(self, bd=2, bg='black')
+        row.subGroup = group
+
+        def onDrop(ev):
+            self.change_group(sub=URIRef(ev.data), row=row)
+            return "link"
+
+        dropTargetRegister(row,
+                           onDrop=onDrop,
+                           typeList=['*'],
+                           hoverStyle=dict(background="#555500"))
+
+        row.pack(expand=1, fill=tk.BOTH)
+        self.setup_key_nudgers(row)
+        self.rows.append(row)
+        return row
+
+    def change_group(self, sub, row):
+        """update this sub's group, and maybe other sub groups as needed, so
+        this sub displays in this row"""
+        group = row.subGroup
+        self.graph.patchObject(context=self.session,
+                               subject=sub,
+                               predicate=L9['group'],
+                               newObject=group)
+
+    def highlight_row(self, row):
+        row = self.rows[row]
+        row['bg'] = 'red'
+
+    def unhighlight_row(self, row):
+        row = self.rows[row]
+        row['bg'] = 'black'
+
+    def get_levels(self):
+        return dict([
+            (uri, box.getVal()) for uri, box in list(self.subbox.items())
+        ])
+
+    def get_output_settings(self, _graph=None):
+        _graph = _graph or self.graph
+        outputSettings = []
+        for setting in _graph.objects(self.session, L9['subSetting']):
+            effect = _graph.value(setting, L9['sub'])
+            strength = _graph.value(setting, L9['level'])
+            if strength:
+                now = time.time()
+                out, report = self.effectEval[effect].outputFromEffect(
+                    [(L9['strength'], strength)],
+                    songTime=now,
+                    # should be counting from when you bumped up from 0
+                    noteTime=now)
+                outputSettings.append(out)
+
+        return DeviceSettings.fromList(_graph, outputSettings)
+
+    def save_current_stage(self, subname):
+        log.info("saving current levels as %s", subname)
+        with self.graph.currentState() as g:
+            ds = self.get_output_settings(_graph=g)
+        effect = L9['effect/%s' % subname]
+        ctx = URIRef(showconfig.showUri() + '/effect/' + subname)
+        stmts = ds.statements(effect, ctx, effect + '/', set())
+        stmts.extend([
+            (effect, RDF.type, L9['Effect'], ctx),
+            (effect, RDFS.label, Literal(subname), ctx),
+            (effect, L9['publishAttr'], L9['strength'], ctx),
+        ])
+
+        self.graph.suggestPrefixes(ctx, {'eff': effect + '/'})
+        self.graph.patch(Patch(addQuads=stmts, delQuads=[]))
+
+        self.sub_name.delete(0, tk.END)
+
+    def alltozero(self):
+        for uri, subbox in list(self.subbox.items()):
+            if subbox.scale.scale_var.get() != 0:
+                subbox.scale.fade(value=0.0, length=0)
+
+
+# move to web lib
+def postArgGetter(request):
+    """return a function that takes arg names and returns string
+    values. Supports args encoded in the url or in postdata. No
+    support for repeated args."""
+    # this is something nevow normally does for me
+    request.content.seek(0)
+    fields = cgi.FieldStorage(request.content,
+                              request.received_headers,
+                              environ={'REQUEST_METHOD': 'POST'})
+
+    def getArg(n):
+        try:
+            return request.args[n][0]
+        except KeyError:
+            return fields[n].value
+
+    return getArg
+
+
+class LevelServerHttp(resource.Resource):
+    isLeaf = True
+
+    def __init__(self, name_to_subbox):
+        self.name_to_subbox = name_to_subbox
+
+    def render_POST(self, request):
+        arg = postArgGetter(request)
+
+        if request.path == '/fadesub':
+            # fadesub?subname=scoop&level=0&secs=.2
+            self.name_to_subbox[arg('subname')].scale.fade(
+                float(arg('level')), float(arg('secs')))
+            return "set %s to %s" % (arg('subname'), arg('level'))
+        else:
+            raise NotImplementedError(repr(request))
+
+
+class Sliders(BCF2000):
+
+    def __init__(self, kc):
+        devices = [
+            '/dev/snd/midiC3D0', '/dev/snd/midiC2D0', '/dev/snd/midiC1D0'
+        ]
+        for dev in devices:
+            try:
+                log.info('try sliders on %s', dev)
+                BCF2000.__init__(self, dev=dev)
+            except IOError:
+                if dev is devices[-1]:
+                    raise
+            else:
+                break
+
+        self.kc = kc
+        log.info('found sliders on %s', dev)
+
+    def valueIn(self, name, value):
+        kc = self.kc
+        if name.startswith("slider"):
+            kc.hw_slider_moved(int(name[6:]) - 1, value / 127)
+        elif name.startswith("button-upper"):
+            kc.change_row(kc.current_row)
+        elif name.startswith("button-lower"):
+            col = int(name[12:]) - 1
+            self.valueOut(name, 0)
+            try:
+                tkslider = kc.slider_table[(kc.current_row, col)]
+            except KeyError:
+                return
+
+            if tkslider.getVal() == 1.0:
+                tkslider.setVal(0.0)
+            else:
+                tkslider.setVal(1.0)
+        elif name.startswith("button-corner"):
+            button_num = int(name[13:]) - 1
+            if button_num == 1:
+                diff = -1
+            elif button_num == 3:
+                diff = 1
+            else:
+                return
+
+            kc.change_row(kc.current_row + diff)
+            self.valueOut(name, 0)
+
+
+def launch(opts: Any, root: tk.Tk, graph: SyncedGraph, session: URIRef):
+    tl = toplevelat("Keyboard Composer - %s" % opts.session,
+                    existingtoplevel=root,
+                    graph=graph,
+                    session=session)
+
+    kc = KeyboardComposer(tl, graph, session, hw_sliders=not opts.no_sliders)
+    kc.pack(fill=tk.BOTH, expand=1)
+
+    for helpline in ["Bindings: B3 mute"]:
+        tk.Label(root, text=helpline, font="Helvetica -12 italic",
+                 anchor='w').pack(side='top', fill='x')
+
+
+if __name__ == "__main__":
+    parser = OptionParser()
+    parser.add_option('--no-sliders',
+                      action='store_true',
+                      help="don't attach to hardware sliders")
+    clientsession.add_option(parser)
+    parser.add_option('-v', action='store_true', help="log info level")
+    opts, args = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
+    logging.getLogger('colormath').setLevel(logging.INFO)
+
+    graph = SyncedGraph(networking.rdfdb.url, "keyboardcomposer")
+
+    # i think this also needs delayed start (like subcomposer has), to have a valid graph
+    # before setting any stuff from the ui
+
+    root = tk.Tk()
+    initTkdnd(root.tk, 'tkdnd/trunk/')
+
+    session = clientsession.getUri('keyboardcomposer', opts)
+
+    graph.initiallySynced.addCallback(lambda _: launch(opts, root, graph,
+                                                       session))
+
+    root.protocol('WM_DELETE_WINDOW', reactor.stop)
+
+    tksupport.install(root, ms=20)
+    prof.run(reactor.run, profile=None)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/lightsim	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,138 @@
+#!bin/python
+
+import run_local
+import sys, logging
+
+sys.path.append("lib")
+import qt4reactor
+qt4reactor.install()
+
+from twisted.internet import reactor
+from twisted.internet.task import LoopingCall
+from twisted.web.xmlrpc import Proxy
+from louie import dispatcher
+from PyQt4.QtGui import QWidget, QLabel, QVBoxLayout, QHBoxLayout, QMainWindow
+from OpenGL.GL import *
+from OpenGL.GLU import *
+
+from light9 import networking, Patch, showconfig, dmxclient, updatefreq, prof
+from light9.namespaces import L9
+from lightsim.openglsim import Surface
+
+log = logging.getLogger()
+logging.basicConfig(
+    format=
+    "%(asctime)s %(levelname)-5s %(name)s %(filename)s:%(lineno)d: %(message)s")
+log.setLevel(logging.DEBUG)
+
+
+def filenamesForChan(graph, chan):
+    for lyr in graph.objects(chan, L9['previewLayer']):
+        for imgPath in graph.objects(lyr, L9['path']):
+            yield imgPath
+
+
+_lastLevels = None
+
+
+def poll(graph, serv, pollFreq, oglSurface):
+    pollFreq.update()
+    dispatcher.send("status", key="pollFreq", value=str(pollFreq))
+    d = serv.callRemote("currentlevels", dmxclient._id)
+
+    def received(dmxLevels):
+        global _lastLevels
+        if dmxLevels == _lastLevels:
+            return
+        _lastLevels = dmxLevels
+
+        level = {}  # filename : level
+        for i, lev in enumerate(dmxLevels):
+            if lev == 0:
+                continue
+
+            try:
+                chan = Patch.get_channel_uri(Patch.get_channel_name(i + 1))
+            except KeyError:
+                continue
+
+            for imgPath in filenamesForChan(graph, chan):
+                level[str(imgPath)] = lev
+
+        oglSurface.newLevels(levels=level)
+
+    d.addCallback(received)
+    return d
+
+
+class StatusKeys(QWidget):
+    """listens for dispatcher signal 'status' and displays the key/value args"""
+
+    def __init__(self, parent):
+        QWidget.__init__(self)
+        self.layout = QVBoxLayout()
+        self.setLayout(self.layout)
+        self.row = {}  # key name : (Frame, value Label)
+        dispatcher.connect(self.status, "status")
+
+    def status(self, key, value):
+        if key not in self.row:
+            row = QWidget()
+            self.layout.addWidget(row)
+            cols = QHBoxLayout()
+            row.setLayout(cols)
+            lab1 = QLabel(key)
+            lab2 = QLabel(value)
+            cols.addWidget(lab1)
+            cols.addWidget(lab2)
+            self.row[key] = lab2
+        else:
+            lab = self.row[key]
+            lab.setText(value)
+
+
+class Window(QMainWindow):
+
+    def __init__(self, filenames):
+        QMainWindow.__init__(self, None)
+        self.setWindowTitle(dmxclient._id)
+
+        w = QWidget()
+        self.setCentralWidget(w)
+        mainLayout = QVBoxLayout()
+        w.setLayout(mainLayout)
+
+        self.glWidget = Surface(self, filenames, imgRescaleTo=128 * 2)
+
+        mainLayout.addWidget(self.glWidget)
+
+        status = StatusKeys(mainLayout)
+        mainLayout.addWidget(status)
+
+
+def requiredImages(graph):
+    """filenames that we'll need to show, based on a config structure
+    like this:
+      ch:frontLeft a :Channel;
+         :previewLayer [ :path "lightsim/skyline/front-left.png" ] .
+    """
+    filenames = []
+    for lyr in graph.objects(None, L9['previewLayer']):
+        for p in graph.objects(lyr, L9['path']):
+            filenames.append(str(p))
+    return filenames
+
+
+if __name__ == '__main__':
+    app = reactor.qApp
+
+    graph = showconfig.getGraph()
+
+    window = Window(requiredImages(graph))
+    window.show()
+
+    serv = Proxy(networking.dmxServer.url)
+    pollFreq = updatefreq.Updatefreq()
+    LoopingCall(poll, graph, serv, pollFreq, window.glWidget).start(.05)
+
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/listsongs	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,28 @@
+#!bin/python
+"""for completion, print the available song uris on stdout
+
+in .zshrc:
+
+function _songs { local expl;  _description files expl 'songs';  compadd "$expl[@]" - `${LIGHT9_SHOW}/../../bin/listsongs` }
+compdef _songs curvecalc
+"""
+
+from run_local import log  # noqa
+from twisted.internet import reactor
+from rdflib import RDF
+from light9 import networking
+from light9.namespaces import L9
+from rdfdb.syncedgraph import SyncedGraph
+
+graph = SyncedGraph(networking.rdfdb.url, "listsongs")
+
+
+@graph.initiallySynced.addCallback
+def printSongs(result):
+    with graph.currentState() as current:
+        for song in current.subjects(RDF.type, L9['Song']):
+            print(song)
+    reactor.stop()
+
+
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/live	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,2 @@
+#!/bin/zsh
+exec pnpm exec vite -c light9/live/vite.config.ts
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/load_test_rdfdb	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,40 @@
+#!bin/python
+from run_local import log
+from twisted.internet import reactor, task, defer
+from rdflib import URIRef, Literal
+from twisted.internet.defer import ensureDeferred
+from rdfdb.syncedgraph import SyncedGraph
+import time, logging
+
+from light9 import networking, showconfig
+from light9.namespaces import L9
+
+
+class BusyClient:
+
+    def __init__(self, subj, rate):
+        self.subj = subj
+        self.rate = rate
+
+        self.graph = SyncedGraph(networking.rdfdb.url, "collector")
+        self.graph.initiallySynced.addCallback(self.go)
+
+    def go(self, _):
+        task.LoopingCall(self.loop).start(1 / self.rate)
+
+    def loop(self):
+        self.graph.patchObject(showconfig.showUri() + '/loadTestContext',
+                               subject=self.subj,
+                               predicate=L9['time'],
+                               newObject=Literal(str(time.time())))
+
+
+def main():
+    log.setLevel(logging.INFO)
+
+    clients = [BusyClient(L9['loadTest_%d' % i], 20) for i in range(10)]
+    reactor.run()
+
+
+if __name__ == "__main__":
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/midifade	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+#!/bin/sh
+exec pdm run python light9/midifade/midifade.py
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/movesinks	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,14 @@
+#!/bin/bash 
+
+# from http://askubuntu.com/questions/71863/how-to-change-pulseaudio-sink-with-pacmd-set-default-sink-during-playback/113322#113322
+
+echo "Setting default sink to: $1";
+pacmd set-default-sink $1
+pacmd list-sink-inputs | grep index | while read line
+do
+echo "Moving input: ";
+echo $line | cut -f2 -d' ';
+echo "to sink: $1";
+pacmd move-sink-input `echo $line | cut -f2 -d' '` $1
+
+done
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/mpd_timing_test	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+#!/usr/bin/python
+"""
+records times coming out of ascoltami
+
+for example:
+
+ % mpd_timing_test > timing
+ # play some music in ascoltami, then ctrl-c
+ % gnuplot
+ > plot "timing" with lines
+
+"""
+
+import xmlrpc.client, time
+
+s = xmlrpc.client.ServerProxy("http://localhost:8040")
+start = time.time()
+while True:
+    print(time.time() - start, s.gettime())
+    time.sleep(.01)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/musictime	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,53 @@
+#!bin/python
+import run_local  # noqa
+import light9.networking
+
+import tkinter as tk
+from twisted.internet import reactor, tksupport, task
+
+from light9.ascoltami.musictime_client import MusicTime
+
+mt = MusicTime()
+
+
+class MusicTimeTk(tk.Frame, MusicTime):
+
+    def __init__(self, master, url):
+        tk.Frame.__init__(self)
+        MusicTime.__init__(self, url)
+        self.timevar = tk.DoubleVar()
+        self.timelabel = tk.Label(self,
+                                  textvariable=self.timevar,
+                                  bd=2,
+                                  relief='raised',
+                                  width=10,
+                                  padx=2,
+                                  pady=2,
+                                  anchor='w')
+        self.timelabel.pack(expand=1, fill='both')
+
+        def print_time(evt, *args):
+            self.timevar.set(mt.getLatest().get('t', 0))
+            print(self.timevar.get(), evt.keysym)
+
+        self.timelabel.bind('<KeyPress>', print_time)
+        self.timelabel.bind('<1>', print_time)
+        self.timelabel.focus()
+        task.LoopingCall(self.update_time).start(.1)
+
+    def update_time(self):
+        t = self.getLatest().get('t', 0)
+        self.timevar.set(t)
+
+
+if __name__ == "__main__":
+    from optparse import OptionParser
+    parser = OptionParser()
+    parser.add_option("-u", "--url", default=light9.networking.musicPlayer.url)
+    options, args = parser.parse_args()
+
+    root = tk.Tk()
+    root.title("Time")
+    MusicTimeTk(root, options.url).pack(expand=1, fill='both')
+    tksupport.install(root, ms=20)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/paintserver	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,126 @@
+#!bin/python
+
+from run_local import log
+import json
+from twisted.internet import reactor
+from rdfdb.syncedgraph import SyncedGraph
+from light9 import networking, showconfig
+import optparse, sys, logging
+import cyclone.web
+from rdflib import URIRef
+from light9 import clientsession
+import light9.paint.solve
+from cycloneerr import PrettyErrorHandler
+from light9.namespaces import L9, DEV
+from light9.metrics import metrics
+import imp
+
+
+class Solve(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def post(self):
+        painting = json.loads(self.request.body)
+        with metrics('solve').time():
+            img = self.settings.solver.draw(painting)
+            sample, sampleDist = self.settings.solver.bestMatch(
+                img, device=DEV['aura2'])
+            with self.settings.graph.currentState() as g:
+                bestPath = g.value(sample, L9['imagePath']).replace(L9[''], '')
+            #out = solver.solve(painting)
+            #layers = solver.simulationLayers(out)
+
+        self.write(
+            json.dumps({
+                'bestMatch': {
+                    'uri': sample,
+                    'path': bestPath,
+                    'dist': sampleDist
+                },
+                #    'layers': layers,
+                #    'out': out,
+            }))
+
+    def reloadSolver(self):
+        imp.reload(light9.paint.solve)
+        self.settings.solver = light9.paint.solve.Solver(self.settings.graph)
+        self.settings.solver.loadSamples()
+
+
+class BestMatches(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def post(self):
+        body = json.loads(self.request.body)
+        painting = body['painting']
+        devs = [URIRef(d) for d in body['devices']]
+        with metrics('solve').time():
+            img = self.settings.solver.draw(painting)
+            outSettings = self.settings.solver.bestMatches(img, devs)
+            self.write(json.dumps({'settings': outSettings.asList()}))
+
+
+class App:
+
+    def __init__(self, show, session):
+        self.show = show
+        self.session = session
+
+        self.graph = SyncedGraph(networking.rdfdb.url, "paintServer")
+        self.graph.initiallySynced.addCallback(self.launch).addErrback(
+            log.error)
+
+
+    def launch(self, *args):
+
+        self.solver = light9.paint.solve.Solver(
+            self.graph,
+            sessions=[
+                L9['show/dance2017/capture/aura1/cap1876596'],
+                L9['show/dance2017/capture/aura2/cap1876792'],
+                L9['show/dance2017/capture/aura3/cap1877057'],
+                L9['show/dance2017/capture/aura4/cap1877241'],
+                L9['show/dance2017/capture/aura5/cap1877406'],
+                L9['show/dance2017/capture/q1/cap1874255'],
+                L9['show/dance2017/capture/q2/cap1873665'],
+                L9['show/dance2017/capture/q3/cap1876223'],
+            ])
+        self.solver.loadSamples()
+
+        self.cycloneApp = cyclone.web.Application(handlers=[
+            (r'/solve', Solve),
+            (r'/bestMatches', BestMatches),
+            metricsRoute(),
+        ],
+                                                  debug=True,
+                                                  graph=self.graph,
+                                                  solver=self.solver)
+        reactor.listenTCP(networking.paintServer.port, self.cycloneApp)
+        log.info("listening on %s" % networking.paintServer.port)
+
+
+if __name__ == "__main__":
+    parser = optparse.OptionParser()
+    parser.add_option(
+        '--show',
+        help='show URI, like http://light9.bigasterisk.com/show/dance2008',
+        default=showconfig.showUri())
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    parser.add_option("--twistedlog",
+                      action="store_true",
+                      help="twisted logging")
+    clientsession.add_option(parser)
+    (options, args) = parser.parse_args()
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+    if not options.show:
+        raise ValueError("missing --show http://...")
+
+    session = clientsession.getUri('paint', options)
+
+    app = App(URIRef(options.show), session)
+    if options.twistedlog:
+        from twisted.python import log as twlog
+        twlog.startLogging(sys.stderr)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/patchserver	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+#!bin/python
+
+from run_local import log
+
+from rdflib import URIRef
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, Deferred
+
+import logging
+import optparse
+import os
+import time
+import treq
+import cyclone.web, cyclone.websocket, cyclone.httpclient
+
+from cycloneerr import PrettyErrorHandler
+
+from light9.namespaces import L9, RDF
+from light9 import networking, showconfig
+from rdfdb.syncedgraph import SyncedGraph
+
+from light9.effect.settings import DeviceSettings
+from rdfdb.patch import Patch
+from light9.metrics import metrics, metricsRoute
+
+
+
+def launch(graph):
+    if 0:
+        reactor.listenTCP(
+            networking.captureDevice.port,
+            cyclone.web.Application(handlers=[
+                (r'/()', cyclone.web.StaticFileHandler, {
+                    "path": "light9/web",
+                    "default_filename": "patchServer.html"
+                }),
+                metricsRoute(),
+            ]),
+            interface='::',
+        )
+        log.info('serving http on %s', networking.captureDevice.port)
+
+    def prn():
+        width = {}
+        for dc in graph.subjects(RDF.type, L9['DeviceClass']):
+            for attr in graph.objects(dc, L9['attr']):
+                width[dc] = max(
+                    width.get(dc, 0),
+                    graph.value(attr, L9['dmxOffset']).toPython() + 1)
+
+        user = {}  # chan: [dev]
+        for dev in set(graph.subjects(L9['dmxBase'], None)):
+            dc = graph.value(dev, RDF.type)
+            base = graph.value(dev, L9['dmxBase']).toPython()
+            for offset in range(0, width[dc]):
+                chan = base + offset
+                user.setdefault(chan, []).append(dev)
+
+        for chan in range(1, max(user) + 1):
+            dev = user.get(chan, None)
+            print(f'chan {chan} used by {dev}')
+
+    graph.addHandler(prn)
+
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    (options, args) = parser.parse_args()
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+    graph = SyncedGraph(networking.rdfdb.url, "captureDevice")
+
+    graph.initiallySynced.addCallback(lambda _: launch(graph)).addErrback(
+        log.error)
+    reactor.run()
+
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/picamserve	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,204 @@
+#!env_pi/bin/python
+
+from run_local import log
+import sys
+sys.path.append('/usr/lib/python2.7/dist-packages/')
+import io, logging, traceback, time
+import cyclone.web
+from twisted.internet import reactor, threads
+from twisted.internet.defer import inlineCallbacks
+from light9 import prof
+
+try:
+    import picamera
+    cameraCls = picamera.PiCamera
+except ImportError:
+
+    class cameraCls(object):
+
+        def __enter__(self):
+            return self
+
+        def __exit__(self, *a):
+            pass
+
+        def capture(self, out, *a, **kw):
+            out.write(open('yuv.demo').read())
+
+        def capture_continuous(self, *a, **kw):
+            for i in range(1000):
+                time.sleep(1)
+                yield str(i)
+
+
+def setCameraParams(c, arg):
+    res = int(arg('res', 480))
+    c.resolution = {
+        480: (640, 480),
+        1080: (1920, 1080),
+        1944: (2592, 1944),
+    }[res]
+    c.shutter_speed = int(arg('shutter', 50000))
+    c.exposure_mode = arg('exposure_mode', 'fixedfps')
+    c.awb_mode = arg('awb_mode', 'off')
+    c.brightness = int(arg('brightness', 50))
+    c.exposure_compensation = int(arg('exposure_compensation', 0))
+    c.awb_gains = (float(arg('redgain', 1)), float(arg('bluegain', 1)))
+    c.ISO = int(arg('iso', 250))
+    c.rotation = int(arg('rotation', '0'))
+
+
+def setupCrop(c, arg):
+    c.crop = (float(arg('x', 0)), float(arg('y', 0)), float(arg('w', 1)),
+              float(arg('h', 1)))
+    rw = rh = int(arg('resize', 100))
+    # width 1920, showing w=.3 of image, resize=100 -> scale is 100/.3*1920
+    # scl is [ output px / camera px ]
+    scl1 = rw / (c.crop[2] * c.resolution[0])
+    scl2 = rh / (c.crop[3] * c.resolution[1])
+    if scl1 < scl2:
+        # width is the constraint; reduce height to the same scale
+        rh = int(scl1 * c.crop[3] * c.resolution[1])
+    else:
+        # height is the constraint
+        rw = int(scl2 * c.crop[2] * c.resolution[0])
+    return rw, rh
+
+
+@prof.logTime
+def getFrame(c, arg):
+    setCameraParams(c, arg)
+    resize = setupCrop(c, arg)
+    out = io.BytesIO('w')
+    prof.logTime(c.capture)(out, 'jpeg', use_video_port=True, resize=resize)
+    return out.getvalue()
+
+
+class Pic(cyclone.web.RequestHandler):
+
+    def get(self):
+        try:
+            self.set_header('Content-Type', 'image/jpeg')
+            self.write(getFrame(self.settings.camera, self.get_argument))
+        except Exception:
+            traceback.print_exc()
+
+
+def captureContinuousAsync(c, resize, onFrame):
+    """
+    Calls c.capture_continuous is called in another thread. onFrame is
+    called in this reactor thread with each (frameTime, frame)
+    result. Runs until onFrame raises StopIteration.
+    """
+
+    def runner(c, resize):
+        stream = io.BytesIO()
+        t = time.time()
+        for nextFrame in c.capture_continuous(stream,
+                                              'jpeg',
+                                              use_video_port=True,
+                                              resize=resize):
+            t2 = time.time()
+            log.debug(" - framecap got %s bytes in %.1f ms",
+                      len(stream.getvalue()), 1000 * (t2 - t))
+            try:
+                # This is slow, like 13ms. Hopefully
+                # capture_continuous is working on gathering the next
+                # pic during this time instead of pausing.
+                # Instead, we could be stashing frames onto a queue or
+                # something that the main thread can pull when
+                # possible (and toss if it gets behind).
+                threads.blockingCallFromThread(reactor, onFrame, t,
+                                               stream.getvalue())
+            except StopIteration:
+                break
+            t3 = time.time()
+            log.debug(" - sending to onFrame took %.1fms", 1000 * (t3 - t2))
+            stream.truncate()
+            stream.seek(0)
+            t = time.time()
+
+    return threads.deferToThread(runner, c, resize)
+
+
+class FpsReport(object):
+
+    def __init__(self):
+        self.frameTimes = []
+        self.lastFpsLog = 0
+
+    def frame(self):
+        now = time.time()
+
+        self.frameTimes.append(now)
+
+        if len(self.frameTimes) > 15:
+            del self.frameTimes[:5]
+
+        if now > self.lastFpsLog + 2 and len(self.frameTimes) > 5:
+            deltas = [(b - a)
+                      for a, b in zip(self.frameTimes[:-1], self.frameTimes[1:])
+                     ]
+            avg = sum(deltas) / len(deltas)
+            log.info("fps: %.1f", 1 / avg)
+            self.lastFpsLog = now
+
+
+class Pics(cyclone.web.RequestHandler):
+
+    @inlineCallbacks
+    def get(self):
+        try:
+            self.set_header('Content-Type', 'x-application/length-time-jpeg')
+            c = self.settings.camera
+            setCameraParams(c, self.get_argument)
+            resize = setupCrop(c, self.get_argument)
+
+            self.running = True
+            log.info("connection open from %s", self.request.remote_ip)
+            fpsReport = FpsReport()
+
+            def onFrame(frameTime, frame):
+                if not self.running:
+                    raise StopIteration
+
+                self.write("%s %s\n" % (len(frame), frameTime))
+                self.write(frame)
+                self.flush()
+
+                fpsReport.frame()
+
+            # another camera request coming in at the same time breaks
+            # the server. it would be nice if this request could
+            # let-go-and-reopen when it knows about another request
+            # coming in
+            yield captureContinuousAsync(c, resize, onFrame)
+        except Exception:
+            traceback.print_exc()
+
+    def on_connection_close(self, *a, **kw):
+        log.info("connection closed")
+        self.running = False
+
+
+log.setLevel(logging.INFO)
+
+with cameraCls() as camera:
+    port = 8208
+    reactor.listenTCP(
+        port,
+        cyclone.web.Application(handlers=[
+            (r'/pic', Pic),
+            (r'/pics', Pics),
+            (r'/static/(.*)', cyclone.web.StaticFileHandler, {
+                'path': 'light9/web/'
+            }),
+            (r'/(|gui.js)', cyclone.web.StaticFileHandler, {
+                'path': 'light9/vidref/',
+                'default_filename': 'index.html'
+            }),
+        ],
+                                debug=True,
+                                camera=camera))
+    log.info("serving on %s" % port)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/pytest	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec pdm run pytest "$@"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/python	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,2 @@
+#!/bin/sh
+PYTHONPATH=. pdm run python3 "$@"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/run_local.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,8 @@
+# this file can go away when all the bin/* are just launchers and everyone uses light9/run_local
+
+import sys
+
+# to support 'import light9'
+sys.path.append('.')
+
+from light9.run_local import log
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/staticclient	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,37 @@
+#!bin/python
+"""
+push a dmx level forever
+"""
+
+import time, logging
+from optparse import OptionParser
+import logging, urllib.request, urllib.parse, urllib.error
+from twisted.internet import reactor, tksupport, task
+from rdflib import URIRef, RDF, RDFS, Literal
+
+from run_local import log
+log.setLevel(logging.DEBUG)
+
+from light9 import dmxclient, showconfig, networking
+
+if __name__ == "__main__":
+    parser = OptionParser(usage="%prog")
+    parser.add_option('--chan', help='channel number, starts at 1',
+                      type=int)  #todo: or name or uri
+    parser.add_option('--level', help='0..1', type=float)
+    parser.add_option('-v', action='store_true', help="log debug level")
+
+    opts, args = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
+
+    levels = [0] * (opts.chan - 1) + [opts.level]
+    log.info('staticclient will write this forever: %r', levels)
+
+    def write():
+        log.debug('writing %r', levels)
+        dmxclient.outputlevels(levels, twisted=1)
+
+    log.info('looping...')
+    task.LoopingCall(write).start(1)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/subcomposer	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,314 @@
+#!bin/python
+"""
+subcomposer
+  session
+  observable(currentSub), a Submaster which tracks the graph
+
+    EditChoice widget
+      can change currentSub to another sub
+
+    Levelbox widget
+      watch observable(currentSub) for a new sub, and also watch currentSub for edits to push to the OneLevel widgets
+
+        OneLevel widget
+          UI edits are caught here and go all the way back to currentSub
+
+
+"""
+
+from run_local import log
+import time, logging
+
+log.setLevel(logging.DEBUG)
+
+from optparse import OptionParser
+import logging, urllib.request, urllib.parse, urllib.error
+import tkinter as tk
+import louie as dispatcher
+from twisted.internet import reactor, tksupport, task
+from rdflib import URIRef, RDF, RDFS, Literal
+
+from light9.dmxchanedit import Levelbox
+from light9 import dmxclient, Submaster, prof, showconfig, networking
+from light9.Patch import get_channel_name
+from light9.uihelpers import toplevelat
+from rdfdb.syncedgraph import SyncedGraph
+from light9 import clientsession
+from light9.tkdnd import initTkdnd
+from light9.namespaces import L9
+from rdfdb.patch import Patch
+from light9.observable import Observable
+from light9.editchoice import EditChoice, Local
+from light9.subcomposer import subcomposerweb
+
+
+class Subcomposer(tk.Frame):
+    """
+    <session> l9:currentSub ?sub is the URI of the sub we're tied to for displaying and
+    editing. If we don't have a currentSub, then we're actually
+    editing a session-local sub called <session> l9:currentSub <sessionLocalSub>
+
+    I'm not sure that Locals should even be PersistentSubmaster with
+    uri and graph storage, but I think that way is making fewer
+    special cases.
+
+    Contains an EditChoice widget
+
+    Dependencies:
+
+      graph (?session :currentSub ?s) -> self.currentSub
+      self.currentSub -> graph
+      self.currentSub -> self._currentChoice (which might be Local)
+      self._currentChoice (which might be Local) -> self.currentSub
+
+      inside the current sub:
+      graph -> Submaster levels (handled in Submaster)
+      Submaster levels -> OneLevel widget
+      OneLevel widget -> Submaster.editLevel
+      Submaster.editLevel -> graph (handled in Submaster)
+
+    """
+
+    def __init__(self, master, graph, session):
+        tk.Frame.__init__(self, master, bg='black')
+        self.graph = graph
+        self.session = session
+        self.launchTime = time.time()
+        self.localSerial = 0
+
+        # this is a URIRef or Local. Strangely, the Local case still
+        # has a uri, which you can get from
+        # self.currentSub.uri. Probably that should be on the Local
+        # object too, or maybe Local should be a subclass of URIRef
+        self._currentChoice = Observable(Local)
+
+        # this is a PersistentSubmaster (even for local)
+        self.currentSub = Observable(
+            Submaster.PersistentSubmaster(graph, self.switchToLocal()))
+
+        def pc(val):
+            log.info("change viewed sub to %s", val)
+
+        self._currentChoice.subscribe(pc)
+
+        ec = self.editChoice = EditChoice(self, self.graph, self._currentChoice)
+        ec.frame.pack(side='top')
+
+        ec.subIcon.bind("<ButtonPress-1>", self.clickSubIcon)
+        self.setupSubChoiceLinks()
+        self.setupLevelboxUi()
+
+    def clickSubIcon(self, *args):
+        box = tk.Toplevel(self.editChoice.frame)
+        box.wm_transient(self.editChoice.frame)
+        tk.Label(box, text="Name this sub:").pack()
+        e = tk.Entry(box)
+        e.pack()
+        b = tk.Button(box, text="Make global")
+        b.pack()
+
+        def clicked(*args):
+            self.makeGlobal(newName=e.get())
+            box.destroy()
+
+        b.bind("<Button-1>", clicked)
+        e.focus()
+
+    def makeGlobal(self, newName):
+        """promote our local submaster into a non-local, named one"""
+        uri = self.currentSub().uri
+        newUri = showconfig.showUri() + ("/sub/%s" %
+                                         urllib.parse.quote(newName, safe=''))
+        with self.graph.currentState(tripleFilter=(uri, None, None)) as current:
+            if (uri, RDF.type, L9['LocalSubmaster']) not in current:
+                raise ValueError("%s is not a local submaster" % uri)
+            if (newUri, None, None) in current:
+                raise ValueError("new uri %s is in use" % newUri)
+
+        # the local submaster was storing in ctx=self.session, but now
+        # we want it to be in ctx=uri
+
+        self.relocateSub(newUri, newName)
+
+        # these are in separate patches for clarity as i'm debugging this
+        self.graph.patch(
+            Patch(addQuads=[
+                (newUri, RDFS.label, Literal(newName), newUri),
+            ],
+                  delQuads=[
+                      (newUri, RDF.type, L9['LocalSubmaster'], newUri),
+                  ]))
+        self.graph.patchObject(self.session, self.session, L9['currentSub'],
+                               newUri)
+
+    def relocateSub(self, newUri, newName):
+        # maybe this goes in Submaster
+        uri = self.currentSub().uri
+
+        def repl(u):
+            if u == uri:
+                return newUri
+            return u
+
+        delQuads = self.currentSub().allQuads()
+        addQuads = [(repl(s), p, repl(o), newUri) for s, p, o, c in delQuads]
+        # patch can't span contexts yet
+        self.graph.patch(Patch(addQuads=addQuads, delQuads=[]))
+        self.graph.patch(Patch(addQuads=[], delQuads=delQuads))
+
+    def setupSubChoiceLinks(self):
+        graph = self.graph
+
+        def ann():
+            print("currently: session=%s currentSub=%r _currentChoice=%r" %
+                  (self.session, self.currentSub(), self._currentChoice()))
+
+        @graph.addHandler
+        def graphChanged():
+            # some bug where SC is making tons of graph edits and many
+            # are failing. this calms things down.
+            log.warn('skip graphChanged')
+            return
+
+            s = graph.value(self.session, L9['currentSub'])
+            log.debug('HANDLER getting session currentSub from graph: %s', s)
+            if s is None:
+                s = self.switchToLocal()
+            self.currentSub(Submaster.PersistentSubmaster(graph, s))
+
+        @self.currentSub.subscribe
+        def subChanged(newSub):
+            log.debug('HANDLER currentSub changed to %s', newSub)
+            if newSub is None:
+                graph.patchObject(self.session, self.session, L9['currentSub'],
+                                  None)
+                return
+            self.sendupdate()
+            graph.patchObject(self.session, self.session, L9['currentSub'],
+                              newSub.uri)
+
+            localStmt = (newSub.uri, RDF.type, L9['LocalSubmaster'])
+            with graph.currentState(tripleFilter=localStmt) as current:
+                if newSub and localStmt in current:
+                    log.debug('  HANDLER set _currentChoice to Local')
+                    self._currentChoice(Local)
+                else:
+                    # i think right here is the point that the last local
+                    # becomes garbage, and we could clean it up.
+                    log.debug('  HANDLER set _currentChoice to newSub.uri')
+                    self._currentChoice(newSub.uri)
+
+        dispatcher.connect(self.levelsChanged, "sub levels changed")
+
+        @self._currentChoice.subscribe
+        def choiceChanged(newChoice):
+            log.debug('HANDLER choiceChanged to %s', newChoice)
+            if newChoice is Local:
+                newChoice = self.switchToLocal()
+            if newChoice is not None:
+                newSub = Submaster.PersistentSubmaster(graph, newChoice)
+                log.debug('write new choice to currentSub, from %r to %r',
+                          self.currentSub(), newSub)
+                self.currentSub(newSub)
+
+    def levelsChanged(self, sub):
+        if sub == self.currentSub():
+            self.sendupdate()
+
+    def switchToLocal(self):
+        """
+        change our display to a local submaster
+        """
+        # todo: where will these get stored, or are they local to this
+        # subcomposer process and don't use PersistentSubmaster at all?
+        localId = "%s-%s" % (self.launchTime, self.localSerial)
+        self.localSerial += 1
+        new = URIRef("http://light9.bigasterisk.com/sub/local/%s" % localId)
+        log.debug('making up a local sub %s', new)
+        self.graph.patch(
+            Patch(addQuads=[
+                (new, RDF.type, L9['Submaster'], self.session),
+                (new, RDF.type, L9['LocalSubmaster'], self.session),
+            ]))
+
+        return new
+
+    def setupLevelboxUi(self):
+        self.levelbox = Levelbox(self, self.graph, self.currentSub)
+        self.levelbox.pack(side='top')
+
+        tk.Button(
+            self,
+            text="All to zero",
+            command=lambda *args: self.currentSub().clear()).pack(side='top')
+
+    def savenewsub(self, subname):
+        leveldict = {}
+        for i, lev in zip(list(range(len(self.levels))), self.levels):
+            if lev != 0:
+                leveldict[get_channel_name(i + 1)] = lev
+
+        s = Submaster.Submaster(subname, levels=leveldict)
+        s.save()
+
+    def sendupdate(self):
+        d = self.currentSub().get_dmx_list()
+        dmxclient.outputlevels(d, twisted=True)
+
+
+def launch(opts, args, root, graph, session):
+    if not opts.no_geometry:
+        toplevelat("subcomposer - %s" % opts.session, root, graph, session)
+
+    sc = Subcomposer(root, graph, session)
+    sc.pack()
+
+    subcomposerweb.init(graph, session, sc.currentSub)
+
+    tk.Label(root,
+             text="Bindings: B1 adjust level; B2 set full; B3 instant bump",
+             font="Helvetica -12 italic",
+             anchor='w').pack(side='top', fill='x')
+
+    if len(args) == 1:
+        # it might be a little too weird that cmdline arg to this
+        # process changes anything else in the same session. But also
+        # I'm not sure who would ever make 2 subcomposers of the same
+        # session (except when quitting and restarting, to get the
+        # same window pos), so maybe it doesn't matter. But still,
+        # this tool should probably default to making new sessions
+        # usually instead of loading the same one
+        graph.patchObject(session, session, L9['currentSub'], URIRef(args[0]))
+
+    task.LoopingCall(sc.sendupdate).start(10)
+
+
+#############################
+
+if __name__ == "__main__":
+    parser = OptionParser(usage="%prog [suburi]")
+    parser.add_option('--no-geometry',
+                      action='store_true',
+                      help="don't save/restore window geometry")
+    parser.add_option('-v', action='store_true', help="log debug level")
+
+    clientsession.add_option(parser)
+    opts, args = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
+
+    root = tk.Tk()
+    root.config(bg='black')
+    root.tk_setPalette("#004633")
+
+    initTkdnd(root.tk, 'tkdnd/trunk/')
+
+    graph = SyncedGraph(networking.rdfdb.url, "subcomposer")
+    session = clientsession.getUri('subcomposer', opts)
+
+    graph.initiallySynced.addCallback(lambda _: launch(opts, args, root, graph,
+                                                       session))
+
+    root.protocol('WM_DELETE_WINDOW', reactor.stop)
+    tksupport.install(root, ms=10)
+    prof.run(reactor.run, profile=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/subserver	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,104 @@
+#!bin/python
+"""
+live web display of all existing subs with pictures, mainly for
+dragging them into CC or Timeline
+"""
+from run_local import log
+import optparse, logging, json, subprocess, datetime
+from dateutil.tz import tzlocal
+from twisted.internet import reactor, defer
+import cyclone.web, cyclone.httpclient, cyclone.websocket
+from rdflib import URIRef, Literal
+import pyjade.utils
+from rdfdb.syncedgraph import SyncedGraph
+from rdfdb.patch import Patch
+from light9.namespaces import L9, DCTERMS
+from light9 import networking, showconfig
+
+from cycloneerr import PrettyErrorHandler
+
+
+class Static(PrettyErrorHandler, cyclone.web.StaticFileHandler):
+
+    def get(self, path, *args, **kw):
+        if path in ['', 'effects']:
+            return self.respondStaticJade("light9/subserver/%s.jade" %
+                                          (path or 'index'))
+
+        if path.endswith(".js"):
+            return self.responseStaticCoffee(
+                'light9/subserver/%s' %
+                path.replace(".js", ".coffee"))  # potential security hole
+
+        cyclone.web.StaticFileHandler.get(self, path, *args, **kw)
+
+    def respondStaticJade(self, src):
+        html = pyjade.utils.process(open(src).read())
+        self.write(html)
+
+    def responseStaticCoffee(self, src):
+        self.write(
+            subprocess.check_output(
+                ['/usr/bin/coffee', '--compile', '--print', src]))
+
+
+class Snapshot(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    @defer.inlineCallbacks
+    def post(self):
+        about = URIRef(self.get_argument("about"))
+        response = yield cyclone.httpclient.fetch(
+            networking.vidref.path("snapshot"), method="POST", timeout=1)
+
+        snapUri = URIRef(json.loads(response.body)['snapshot'])
+        # vidref could write about when it was taken, etc. would it be
+        # better for us to tell vidref where to attach the result in
+        # the graph, and then it doesn't even have to return anything?
+
+        ctx = showconfig.showUri() + "/snapshots"
+
+        self.settings.graph.patch(
+            Patch(addQuads=[
+                (about, L9['image'], snapUri, ctx),
+                (snapUri, DCTERMS['created'],
+                 Literal(datetime.datetime.now(tzlocal())), ctx),
+            ]))
+
+        self.write(json.dumps({'snapshot': snapUri}))
+
+
+def newestImage(subject):
+    newest = (None, None)
+    for img in graph.objects(subject, L9['image']):
+        created = graph.value(img, DCTERMS['created'])
+        if created > newest[0]:
+            newest = (created, img)
+    return newest[1]
+
+
+if __name__ == "__main__":
+    parser = optparse.OptionParser()
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    (options, args) = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+    graph = SyncedGraph(networking.rdfdb.url, "subServer")
+
+    port = networking.subServer.port
+    reactor.listenTCP(
+        port,
+        cyclone.web.Application(handlers=[
+            (r'/snapshot', Snapshot),
+            (r'/(.*)', Static, {
+                "path": "light9/subserver",
+                "default_filename": "index.jade"
+            }),
+        ],
+                                debug=True,
+                                graph=graph))
+    log.info("serving on %s" % port)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/timeline	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,3 @@
+#!/bin/zsh
+pnpm exec vite -c light9/web/timeline/vite.config.ts &
+wait
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/tkdnd_minimal_drop.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,56 @@
+#!bin/python
+from run_local import log
+import tkinter as tk
+from light9.tkdnd import initTkdnd, dropTargetRegister
+from twisted.internet import reactor, tksupport
+
+root = tk.Tk()
+initTkdnd(root.tk, "tkdnd/trunk/")
+label = tk.Label(root, borderwidth=2, relief='groove', padx=10, pady=10)
+label.pack()
+label.config(text="drop target %s" % label._w)
+
+frame1 = tk.Frame()
+frame1.pack()
+
+labelInner = tk.Label(frame1, borderwidth=2, relief='groove', padx=10, pady=10)
+labelInner.pack(side='left')
+labelInner.config(text="drop target inner %s" % labelInner._w)
+tk.Label(frame1, text="not a target").pack(side='left')
+
+
+def onDrop(ev):
+    print("onDrop", ev)
+
+
+def enter(ev):
+    print('enter', ev)
+
+
+def leave(ev):
+    print('leave', ev)
+
+
+dropTargetRegister(label,
+                   onDrop=onDrop,
+                   onDropEnter=enter,
+                   onDropLeave=leave,
+                   hoverStyle=dict(background="yellow", relief='groove'))
+
+dropTargetRegister(labelInner,
+                   onDrop=onDrop,
+                   onDropEnter=enter,
+                   onDropLeave=leave,
+                   hoverStyle=dict(background="yellow", relief='groove'))
+
+
+def prn():
+    print("cont", root.winfo_containing(201, 151))
+
+
+b = tk.Button(root, text="coord", command=prn)
+b.pack()
+
+#tk.mainloop()
+tksupport.install(root, ms=10)
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/tracker	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,311 @@
+#!/usr/bin/python
+
+import sys
+sys.path.append("../../editor/pour")
+sys.path.append("../light8")
+
+from Submaster import Submaster
+from skim.zooming import Zooming, Pair
+from math import sqrt, sin, cos
+from pygame.rect import Rect
+from xmlnodebase import xmlnodeclass, collectiveelement, xmldocfile
+from dispatch import dispatcher
+
+import dmxclient
+
+import tkinter as tk
+
+defaultfont = "arial 8"
+
+
+def pairdist(pair1, pair2):
+    return pair1.dist(pair2)
+
+
+def canvashighlighter(canvas, obj, attribute, normalval, highlightval):
+    """creates bindings on a canvas obj that make attribute go
+    from normal to highlight when the mouse is over the obj"""
+    canvas.tag_bind(
+        obj, "<Enter>", lambda ev: canvas.itemconfig(
+            obj, **{attribute: highlightval}))
+    canvas.tag_bind(
+        obj,
+        "<Leave>", lambda ev: canvas.itemconfig(obj, **{attribute: normalval}))
+
+
+class Field(xmlnodeclass):
+    """one light has a field of influence. for any point on the
+    canvas, you can ask this field how strong it is. """
+
+    def name(self, newval=None):
+        """light/sub name"""
+        return self._getorsetattr("name", newval)
+
+    def center(self, x=None, y=None):
+        """x,y float coords for the center of this light in the field. returns
+        a Pair, although it accepts x,y"""
+        return Pair(self._getorsettypedattr("x", float, x),
+                    self._getorsettypedattr("y", float, y))
+
+    def falloff(self, dist=None):
+        """linear falloff from 1 at center, to 0 at dist pixels away
+        from center"""
+        return self._getorsettypedattr("falloff", float, dist)
+
+    def getdistforintensity(self, intens):
+        """returns the distance you'd have to be for the given intensity (0..1)"""
+        return (1 - intens) * self.falloff()
+
+    def calc(self, x, y):
+        """returns field strength at point x,y"""
+        dist = pairdist(Pair(x, y), self.center())
+        return max(0, (self.falloff() - dist) / self.falloff())
+
+
+class Fieldset(collectiveelement):
+    """group of fields. persistent."""
+
+    def childtype(self):
+        return Field
+
+    def version(self):
+        """read-only version attribute on fieldset tag"""
+        return self._getorsetattr("version", None)
+
+    def report(self, x, y):
+        """reports active fields and their intensities"""
+        active = 0
+        for f in self.getall():
+            name = f.name()
+            intens = f.calc(x, y)
+            if intens > 0:
+                print(name, intens, end=' ')
+                active += 1
+        if active > 0:
+            print()
+        self.dmxsend(x, y)
+
+    def dmxsend(self, x, y):
+        """output lights to dmx"""
+        levels = dict([(f.name(), f.calc(x, y)) for f in self.getall()])
+        dmxlist = Submaster(None, levels).get_dmx_list()
+        dmxclient.outputlevels(dmxlist)
+
+    def getbounds(self):
+        """returns xmin,xmax,ymin,ymax for the non-zero areas of this field"""
+        r = None
+        for f in self.getall():
+            rad = f.getdistforintensity(0)
+            fx, fy = f.center()
+            fieldrect = Rect(fx - rad, fy - rad, rad * 2, rad * 2)
+            if r is None:
+                r = fieldrect
+            else:
+                r = r.union(fieldrect)
+        return r.left, r.right, r.top, r.bottom
+
+
+class Fieldsetfile(xmldocfile):
+
+    def __init__(self, filename):
+        self._openornew(filename, topleveltype=Fieldset)
+
+    def fieldset(self):
+        return self._gettoplevel()
+
+
+########################################################################
+########################################################################
+
+
+class FieldDisplay:
+    """the view for a Field."""
+
+    def __init__(self, canvas, field):
+        self.canvas = canvas
+        self.field = field
+        self.tags = [str(id(self))]  # canvas tag to id our objects
+
+    def setcoords(self):
+        """adjust canvas obj coords to match the field"""
+        # this uses the canvas object ids saved by makeobjs
+        f = self.field
+        c = self.canvas
+        w2c = self.canvas.world2canvas
+
+        # rings
+        for intens, ring in list(self.rings.items()):
+            rad = f.getdistforintensity(intens)
+            p1 = w2c(*(f.center() - Pair(rad, rad)))
+            p2 = w2c(*(f.center() + Pair(rad, rad)))
+            c.coords(ring, p1[0], p1[1], p2[0], p2[1])
+
+        # text
+        p1 = w2c(*f.center())
+        c.coords(self.txt, *p1)
+
+    def makeobjs(self):
+        """(re)create the canvas objs (null coords) and make their bindings"""
+        c = self.canvas
+        f = self.field
+        c.delete(self.tags)
+
+        w2c = self.canvas.world2canvas
+
+        # make rings
+        self.rings = {}  # rad,canvasobj
+        for intens, color in (  #(1,'white'),
+            (.8, 'gray90'), (.6, 'gray80'), (.4, 'gray60'), (.2, 'gray50'),
+            (0, '#000080')):
+            self.rings[intens] = c.create_oval(0,
+                                               0,
+                                               0,
+                                               0,
+                                               outline=color,
+                                               width=2,
+                                               tags=self.tags,
+                                               outlinestipple='gray50')
+
+        # make text
+        self.txt = c.create_text(0,
+                                 0,
+                                 text=f.name(),
+                                 font=defaultfont + " bold",
+                                 fill='white',
+                                 anchor='c',
+                                 tags=self.tags)
+
+        # highlight text bindings
+        canvashighlighter(c,
+                          self.txt,
+                          'fill',
+                          normalval='white',
+                          highlightval='red')
+
+        # position drag bindings
+        def press(ev):
+            self._lastmouse = ev.x, ev.y
+
+        def motion(ev):
+            dcan = Pair(*[a - b for a, b in zip((ev.x, ev.y), self._lastmouse)])
+            dworld = c.canvas2world_vector(*dcan)
+            self.field.center(*(self.field.center() + dworld))
+            self._lastmouse = ev.x, ev.y
+            self.setcoords()  # redraw
+
+        def release(ev):
+            if hasattr(self, '_lastmouse'):
+                del self._lastmouse
+            dispatcher.send("field coord changed")  # updates bounds
+
+        c.tag_bind(self.txt, "<ButtonPress-1>", press)
+        c.tag_bind(self.txt, "<B1-Motion>", motion)
+        c.tag_bind(self.txt, "<B1-ButtonRelease>", release)
+
+        # radius drag bindings
+        outerring = self.rings[0]
+        canvashighlighter(c,
+                          outerring,
+                          'outline',
+                          normalval='#000080',
+                          highlightval='#4040ff')
+
+        def motion(ev):
+            worldmouse = self.canvas.canvas2world(ev.x, ev.y)
+            currentdist = pairdist(worldmouse, self.field.center())
+            self.field.falloff(currentdist)
+            self.setcoords()
+
+        c.tag_bind(outerring, "<B1-Motion>", motion)
+        c.tag_bind(outerring, "<B1-ButtonRelease>", release)  # from above
+
+        self.setcoords()
+
+
+class Tracker(tk.Frame):
+    """whole tracker widget, which is mostly a view for a
+    Fieldset. tracker makes its own fieldset"""
+
+    # world coords of the visible canvas (preserved even in window resizes)
+    xmin = 0
+    xmax = 100
+    ymin = 0
+    ymax = 100
+
+    fieldsetfile = None
+    displays = None  # Field : FieldDisplay. we keep these in sync with the fieldset
+
+    def __init__(self, master):
+        tk.Frame.__init__(self, master)
+
+        self.displays = {}
+
+        c = self.canvas = Zooming(self, bg='black', closeenough=5)
+        c.pack(fill='both', exp=1)
+
+        # preserve edge coords over window resize
+        c.bind("<Configure>", self.configcoords)
+
+        c.bind("<Motion>", lambda ev: self._fieldset().report(*c.canvas2world(
+            ev.x, ev.y)))
+
+        def save(ev):
+            print("saving")
+            self.fieldsetfile.save()
+
+        master.bind("<Key-s>", save)
+        dispatcher.connect(self.autobounds, "field coord changed")
+
+    def _fieldset(self):
+        return self.fieldsetfile.fieldset()
+
+    def load(self, filename):
+        self.fieldsetfile = Fieldsetfile(filename)
+        self.displays.clear()
+        for f in self.fieldsetfile.fieldset().getall():
+            self.displays[f] = FieldDisplay(self.canvas, f)
+            self.displays[f].makeobjs()
+        self.autobounds()
+
+    def configcoords(self, *args):
+        # force our canvas coords to stay at the edges of the window
+        c = self.canvas
+        cornerx, cornery = c.canvas2world(0, 0)
+        c.move(cornerx - self.xmin, cornery - self.ymin)
+        c.setscale(0, 0,
+                   c.winfo_width() / (self.xmax - self.xmin),
+                   c.winfo_height() / (self.ymax - self.ymin))
+
+    def autobounds(self):
+        """figure out our bounds from the fieldset, and adjust the display zooms.
+        writes the corner coords onto the canvas."""
+        self.xmin, self.xmax, self.ymin, self.ymax = self._fieldset().getbounds(
+        )
+
+        self.configcoords()
+
+        c = self.canvas
+        c.delete('cornercoords')
+        for x, anc2 in ((self.xmin, 'w'), (self.xmax, 'e')):
+            for y, anc1 in ((self.ymin, 'n'), (self.ymax, 's')):
+                pos = c.world2canvas(x, y)
+                c.create_text(pos[0],
+                              pos[1],
+                              text="%s,%s" % (x, y),
+                              fill='white',
+                              anchor=anc1 + anc2,
+                              tags='cornercoords')
+        [d.setcoords() for d in list(self.displays.values())]
+
+
+########################################################################
+########################################################################
+
+root = tk.Tk()
+root.wm_geometry('700x350')
+tra = Tracker(root)
+tra.pack(fill='both', exp=1)
+
+tra.load("fieldsets/demo")
+
+root.mainloop()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/vidref	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,188 @@
+#!bin/python
+"""
+Camera images of the stage. View live on a web page and also save
+them to disk. Retrieve images based on the song and time that was
+playing when they were taken. Also, save snapshot images to a place
+they can be used again as thumbnails of effects.
+
+bin/vidref main
+light9/vidref/videorecorder.py capture frames and save them
+light9/vidref/replay.py backend for vidref.js playback element- figures out which frames go with the current song and time
+light9/vidref/index.html web ui for watching current stage and song playback
+light9/vidref/setup.html web ui for setup of camera params and frame crop
+light9/web/light9-vidref-live.js LitElement for live video frames
+light9/web/light9-vidref-playback.js LitElement for video playback
+
+"""
+from run_local import log
+
+from typing import cast
+import logging, optparse, json, base64, os, glob
+
+from light9.metrics import metrics, metricsRoute
+
+from rdflib import URIRef
+from twisted.internet import reactor, defer
+import cyclone.web, cyclone.httpclient, cyclone.websocket
+
+from cycloneerr import PrettyErrorHandler
+from light9 import networking, showconfig
+from light9.newtypes import Song
+from light9.vidref import videorecorder
+from rdfdb.syncedgraph import SyncedGraph
+
+parser = optparse.OptionParser()
+parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG")
+(options, args) = parser.parse_args()
+
+log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+
+
+class Snapshot(cyclone.web.RequestHandler):
+
+    @defer.inlineCallbacks
+    def post(self):
+        # save next pic
+        # return /snapshot/path
+        try:
+            snapshotDir = 'todo'
+            outputFilename = yield self.settings.gui.snapshot()
+
+            assert outputFilename.startswith(snapshotDir)
+            out = networking.vidref.path(
+                "snapshot/%s" % outputFilename[len(snapshotDir):].lstrip('/'))
+
+            self.write(json.dumps({'snapshot': out}))
+            self.set_header("Location", out)
+            self.set_status(303)
+        except Exception:
+            import traceback
+            traceback.print_exc()
+            raise
+
+
+pipeline = videorecorder.GstSource(
+    #'/dev/v4l/by-id/usb-Bison_HD_Webcam_200901010001-video-index0'
+    '/dev/v4l/by-id/usb-Generic_FULL_HD_1080P_Webcam_200901010001-video-index0')
+
+
+class Live(cyclone.websocket.WebSocketHandler):
+
+    def connectionMade(self, *args, **kwargs):
+        pipeline.liveImages.subscribe(on_next=self.onFrame)
+        metrics('live_clients').offset(1)
+
+    def connectionLost(self, reason):
+        #self.subj.dispose()
+        metrics('live_clients').offset(-1)
+
+    def onFrame(self, cf: videorecorder.CaptureFrame):
+        if cf is None: return
+
+        with metrics('live_websocket_frame_fps').time():
+            self.sendMessage(
+                json.dumps({
+                    'jpeg': base64.b64encode(cf.asJpeg()).decode('ascii'),
+                    'description': f't={cf.t}',
+                }))
+
+
+class SnapshotPic(cyclone.web.StaticFileHandler):
+    pass
+
+
+class Time(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def put(self):
+        body = json.loads(self.request.body)
+        t = body['t']
+        for listener in TimeStream.time_stream_listeners:
+            listener.sendMessage(json.dumps({
+                'st': t,
+                'song': body['song'],
+            }))
+        self.set_status(202)
+
+
+class TimeStream(cyclone.websocket.WebSocketHandler):
+    time_stream_listeners = []
+
+    def connectionMade(self, *args, **kwargs):
+        TimeStream.time_stream_listeners.append(self)
+
+    def connectionLost(self, reason):
+        TimeStream.time_stream_listeners.remove(self)
+
+
+class Clips(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def delete(self):
+        clip = URIRef(self.get_argument('uri'))
+        videorecorder.deleteClip(clip)
+
+
+class ReplayMap(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        song = Song(self.get_argument('song'))
+        clips = []
+        videoPaths = glob.glob(
+            os.path.join(videorecorder.songDir(song), b'*.mp4'))
+        for vid in videoPaths:
+            pts = []
+            for line in open(vid.replace(b'.mp4', b'.timing'), 'rb'):
+                _v, vt, _eq, _song, st = line.split()
+                pts.append([float(st), float(vt)])
+
+            url = vid[len(os.path.dirname(os.path.dirname(showconfig.root()))
+                         ):].decode('ascii')
+
+            clips.append({
+                'uri': videorecorder.takeUri(vid),
+                'videoUrl': url,
+                'songToVideo': pts
+            })
+
+        clips.sort(key=lambda c: len(cast(list, c['songToVideo'])))
+        clips = clips[-int(self.get_argument('maxClips', '3')):]
+        clips.sort(key=lambda c: c['uri'], reverse=True)
+
+        ret = json.dumps(clips)
+        log.info('replayMap had %s videos; json is %s bytes', len(clips),
+                 len(ret))
+        self.write(ret)
+
+
+graph = SyncedGraph(networking.rdfdb.url, "vidref")
+outVideos = videorecorder.FramesToVideoFiles(
+    pipeline.liveImages, os.path.join(showconfig.root(), b'video'))
+
+port = networking.vidref.port
+reactor.listenTCP(
+    port,
+    cyclone.web.Application(
+        handlers=[
+            (r'/()', cyclone.web.StaticFileHandler, {
+                'path': 'light9/vidref',
+                'default_filename': 'index.html'
+            }),
+            (r'/setup/()', cyclone.web.StaticFileHandler, {
+                'path': 'light9/vidref',
+                'default_filename': 'setup.html'
+            }),
+            (r'/live', Live),
+            (r'/clips', Clips),
+            (r'/replayMap', ReplayMap),
+            (r'/snapshot', Snapshot),
+            (r'/snapshot/(.*)', SnapshotPic, {
+                "path": 'todo',
+            }),
+            (r'/time', Time),
+            (r'/time/stream', TimeStream),
+            metricsRoute(),
+        ],
+        debug=True,
+    ))
+log.info("serving on %s" % port)
+
+reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/vidrefsetup	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+#!bin/python
+""" this should be part of vidref, but I haven't worked out sharing
+camera captures with a continuous camera capture yet """
+
+from run_local import log
+import optparse, logging
+from twisted.internet import reactor
+import cyclone.web, cyclone.httpclient, cyclone.websocket
+from rdflib import URIRef
+from rdfdb.syncedgraph import SyncedGraph
+from light9.namespaces import L9
+from light9 import networking, showconfig
+
+from cycloneerr import PrettyErrorHandler
+
+
+class RedirToCamera(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        return self.redirect(
+            networking.picamserve.path('pic?' + self.request.query))
+
+
+class UrlToCamera(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        self.set_header('Content-Type', 'text/plain')
+        self.write(networking.picamserve.path('pic'))
+
+
+class VidrefCamRequest(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def get(self):
+        graph = self.settings.graph
+        show = showconfig.showUri()
+        with graph.currentState(tripleFilter=(show, None, None)) as g:
+            ret = g.value(show, L9['vidrefCamRequest'])
+            if ret is None:
+                self.send_error(404)
+            self.redirect(ret)
+
+    def put(self):
+        graph = self.settings.graph
+        show = showconfig.showUri()
+        graph.patchObject(context=URIRef(show + '/vidrefConfig'),
+                          subject=show,
+                          predicate=L9['vidrefCamRequest'],
+                          newObject=URIRef(self.get_argument('uri')))
+        self.send_error(202)
+
+
+def main():
+    parser = optparse.OptionParser()
+    parser.add_option("-v",
+                      "--verbose",
+                      action="store_true",
+                      help="logging.DEBUG")
+    (options, args) = parser.parse_args()
+
+    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
+    graph = SyncedGraph(networking.rdfdb.url, "vidrefsetup")
+
+    # deliberately conflict with vidref since they can't talk at once to cam
+    port = networking.vidref.port
+
+    reactor.listenTCP(
+        port,
+        cyclone.web.Application(handlers=[
+            (r'/pic', RedirToCamera),
+            (r'/picUrl', UrlToCamera),
+            (r'/vidrefCamRequest', VidrefCamRequest),
+            (r'/()', cyclone.web.StaticFileHandler, {
+                'path': 'light9/vidref/',
+                'default_filename': 'vidref.html'
+            }),
+        ],
+                                debug=True,
+                                graph=graph))
+    log.info("serving on %s" % port)
+    reactor.run()
+
+
+main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/wavecurve	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,46 @@
+#!bin/python
+import optparse
+from run_local import log
+from light9.wavepoints import simp
+
+
+def createCurve(inpath, outpath, t):
+    print("reading %s, writing %s" % (inpath, outpath))
+    points = simp(inpath.replace('.ogg', '.wav'), seconds_per_average=t)
+
+    f = open(outpath, 'w')
+    for time_val in points:
+        print("%s %s" % time_val, file=f)
+    log.info(r'Wrote {outpath}')
+
+
+parser = optparse.OptionParser(usage="""%prog inputSong.wav outputCurve
+
+You probably just want -a
+
+""")
+parser.add_option("-t",
+                  type="float",
+                  default=.01,
+                  help="seconds per sample (default .01, .07 is smooth)")
+parser.add_option("-a",
+                  "--all",
+                  action="store_true",
+                  help="make standard curves for all songs")
+options, args = parser.parse_args()
+
+if options.all:
+    from light9 import showconfig
+    from light9.ascoltami.playlist import Playlist
+    graph = showconfig.getGraph()
+
+    playlist = Playlist.fromShow(showconfig.getGraph(), showconfig.showUri())
+    for song in playlist.allSongs():
+        inpath = showconfig.songOnDisk(song)
+        for curveName, t in [('music', .01), ('smooth_music', .07)]:
+            outpath = showconfig.curvesDir() + "/%s-%s" % (
+                showconfig.songFilenameFromURI(song), curveName)
+            createCurve(inpath, outpath, t)
+else:
+    inpath, outpath = args
+    createCurve(inpath, outpath, options.t)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/attic/webcontrol	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,122 @@
+#!bin/python
+"""
+web UI for various commands that we might want to run from remote
+computers and phones
+
+todo:
+disable buttons that don't make sense
+"""
+import sys, xmlrpc.client, traceback
+from twisted.internet import reactor
+from twisted.python import log
+from twisted.python.util import sibpath
+from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.web.client import getPage
+from nevow.appserver import NevowSite
+from nevow import rend, static, loaders, inevow, url, tags as T
+from rdflib import URIRef
+from louie.robustapply import robust_apply
+sys.path.append(".")
+from light9 import showconfig, networking
+from light9.namespaces import L9
+from urllib.parse import urlencode
+
+
+# move to web lib
+def post(url, **args):
+    return getPage(url, method='POST', postdata=urlencode(args))
+
+
+class Commands(object):
+
+    @staticmethod
+    def playSong(graph, songUri):
+        s = xmlrpc.client.ServerProxy(networking.musicPlayer.url)
+        songPath = graph.value(URIRef(songUri), L9.showPath)
+        if songPath is None:
+            raise ValueError("unknown song %s" % songUri)
+        return s.playfile(songPath.encode('ascii'))
+
+    @staticmethod
+    def stopMusic(graph):
+        s = xmlrpc.client.ServerProxy(networking.musicPlayer.url)
+        return s.stop()
+
+    @staticmethod
+    def worklightsOn(graph):
+        return post(networking.keyboardComposer.path('fadesub'),
+                    subname='scoop',
+                    level=.5,
+                    secs=.5)
+
+    @staticmethod
+    def worklightsOff(graph):
+        return post(networking.keyboardComposer.path('fadesub'),
+                    subname='scoop',
+                    level=0,
+                    secs=.5)
+
+    @staticmethod
+    def dimmerSet(graph, dimmer, value):
+        raise NotImplementedError("subcomposer doesnt have an http port yet")
+
+
+class Main(rend.Page):
+    docFactory = loaders.xmlfile(sibpath(__file__, "../light9/webcontrol.html"))
+
+    def __init__(self, graph):
+        self.graph = graph
+        rend.Page.__init__(self)
+
+    def render_status(self, ctx, data):
+        pic = T.img(src="icon/enabled.png")
+        if ctx.arg('error'):
+            pic = T.img(src="icon/warning.png")
+        return [pic, ctx.arg('status') or 'ready']
+
+    def render_songButtons(self, ctx, data):
+        playList = graph.value(show, L9['playList'])
+        songs = list(graph.items(playList))
+        out = []
+        for song in songs:
+            out.append(
+                T.form(method="post", action="playSong")
+                [T.input(type='hidden', name='songUri', value=song),
+                 T.button(type='submit')[graph.label(song)]])
+        return out
+
+    @inlineCallbacks
+    def locateChild(self, ctx, segments):
+        try:
+            func = getattr(Commands, segments[0])
+            req = inevow.IRequest(ctx)
+            simpleArgDict = dict((k, v[0]) for k, v in list(req.args.items()))
+            try:
+                ret = yield robust_apply(func, func, self.graph,
+                                         **simpleArgDict)
+            except KeyboardInterrupt:
+                raise
+            except Exception as e:
+                print("Error on command %s" % segments[0])
+                traceback.print_exc()
+                returnValue((url.here.up().add('status',
+                                               str(e)).add('error',
+                                                           1), segments[1:]))
+
+            returnValue((url.here.up().add('status', ret), segments[1:]))
+            #actually return the orig page, with a status message from the func
+        except AttributeError:
+            pass
+        returnValue(rend.Page.locateChild(self, ctx, segments))
+
+    def child_icon(self, ctx):
+        return static.File("/usr/share/pyshared/elisa/plugins/poblesec/tango")
+
+
+graph = showconfig.getGraph()
+show = showconfig.showUri()
+
+log.startLogging(sys.stdout)
+
+reactor.listenTCP(9000, NevowSite(Main(graph)))
+reactor.run()
--- a/bin/bcf_puppet_demo	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,17 +0,0 @@
-#!/usr/bin/python
-"""
-tiny bcf2000 controller demo
-"""
-from bcf2000 import BCF2000
-from twisted.internet import reactor
-
-
-class PuppetSliders(BCF2000):
-
-    def valueIn(self, name, value):
-        if name == 'slider1':
-            self.valueOut('slider5', value)
-
-
-b = PuppetSliders()
-reactor.run()
--- a/bin/bumppad	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-#!bin/python
-
-import sys, time, math
-import tkinter as tk
-
-import run_local
-import light9.dmxclient as dmxclient
-from light9.TLUtility import make_attributes_from_args
-
-from light9.Submaster import Submaster, sub_maxes
-
-
-class pad(tk.Frame):
-    levs = None  # Submaster : level
-
-    def __init__(self, master, root, mag):
-        make_attributes_from_args('master', 'mag')
-        tk.Frame.__init__(self, master)
-        self.levs = {}
-        for xy, key, subname in [
-            ((1, 1), 'KP_Up', 'centered'),
-            ((1, 3), "KP_Down", 'third-c'),
-            ((0, 2), 'KP_Left', 'scoop-l'),
-            ((2, 2), 'KP_Right', 'scoop-r'),
-            ((1, 0), 'KP_Divide', 'cyc'),
-            ((0, 3), "KP_End", 'hottest'),
-            ((2, 3), 'KP_Next', 'deepblues'),
-            ((0, 4), 'KP_Insert', "zip_red"),
-            ((2, 4), 'KP_Delete', "zip_orange"),
-            ((3, 1), 'KP_Add', 'strobedim'),
-            ((3, 3), 'KP_Enter', 'zip_blue'),
-            ((1, 2), 'KP_Begin', 'scoop-c'),
-        ]:
-
-            sub = Submaster(subname)
-            self.levs[sub] = 0
-
-            l = tk.Label(self,
-                         font="arial 12 bold",
-                         anchor='w',
-                         height=2,
-                         relief='groove',
-                         bd=5,
-                         text="%s\n%s" % (key.replace('KP_', ''), sub.name))
-            l.grid(column=xy[0], row=xy[1], sticky='news')
-
-            root.bind(
-                "<KeyPress-%s>" % key, lambda ev, sub=sub: self.bumpto(sub, 1))
-            root.bind("<KeyRelease-%s>" % key,
-                      lambda ev, sub=sub: self.bumpto(sub, 0))
-
-    def bumpto(self, sub, lev):
-        now = time.time()
-        self.levs[sub] = lev * self.mag.get()
-        self.master.after_idle(self.output)
-
-    def output(self):
-        dmx = sub_maxes(*[s * l
-                          for s, l in list(self.levs.items())]).get_dmx_list()
-        dmxclient.outputlevels(dmx, clientid="bumppad")
-
-
-root = tk.Tk()
-root.tk_setPalette("maroon4")
-root.wm_title("bumppad")
-mag = tk.DoubleVar()
-
-tk.Label(root,
-         text="Keypad press/release activate sub; 1..5 set mag",
-         font="Helvetica -12 italic",
-         anchor='w').pack(side='bottom', fill='x')
-
-pad(root, root, mag).pack(side='left', fill='both', exp=1)
-
-magscl = tk.Scale(root,
-                  orient='vertical',
-                  from_=1,
-                  to=0,
-                  res=.01,
-                  showval=1,
-                  variable=mag,
-                  label='mag',
-                  relief='raised',
-                  bd=1)
-for i in range(1, 6):
-    root.bind("<Key-%s>" % i, lambda ev, i=i: mag.set(math.sqrt((i) / 5)))
-magscl.pack(side='left', fill='y')
-
-root.mainloop()
--- a/bin/captureDevice	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,199 +0,0 @@
-#!bin/python
-"""
-Operate a motorized light and take pictures of it in every position.
-"""
-from rdflib import URIRef
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks, Deferred
-
-import logging
-import optparse
-import os
-import time
-import treq
-import cyclone.web, cyclone.websocket, cyclone.httpclient
-from light9.metrics import metrics, metricsRoute
-from run_local import log
-from cycloneerr import PrettyErrorHandler
-
-from light9.namespaces import L9, RDF
-from light9 import networking, showconfig
-from rdfdb.syncedgraph import SyncedGraph
-from light9.paint.capture import writeCaptureDescription
-from light9.effect.settings import DeviceSettings
-from light9.collector.collector_client import sendToCollector
-from rdfdb.patch import Patch
-from light9.zmqtransport import parseJsonMessage
-
-
-
-class Camera(object):
-
-    def __init__(self, imageUrl):
-        self.imageUrl = imageUrl
-
-    def takePic(self, uri, writePath):
-        log.info('takePic %s', uri)
-        return treq.get(
-            self.imageUrl).addCallbacks(lambda r: self._done(writePath, r),
-                                        log.error)
-
-    @inlineCallbacks
-    def _done(self, writePath, response):
-        jpg = yield response.content()
-        try:
-            os.makedirs(os.path.dirname(writePath))
-        except OSError:
-            pass
-        with open(writePath, 'w') as out:
-            out.write(jpg)
-        log.info('wrote %s', writePath)
-
-
-def deferSleep(sec):
-    d = Deferred()
-    reactor.callLater(sec, d.callback, None)
-    return d
-
-
-class Capture(object):
-    firstMoveTime = 3
-    settleTime = .5
-
-    def __init__(self, graph, dev):
-        self.graph = graph
-        self.dev = dev
-
-        def steps(a, b, n):
-            return [round(a + (b - a) * i / n, 5) for i in range(n)]
-
-        startTime = time.time()
-        self.captureId = 'cap%s' % (int(startTime) - 1495170000)
-        self.toGather = []
-
-        #quantum
-        rxSteps = steps(.06, .952, 10)
-        rySteps = steps(0.1, .77, 5)
-        zoomSteps = steps(.12, .85, 3)
-        # aura
-        rxSteps = steps(0.15, .95, 10)
-        rySteps = steps(0, .9, 5)
-        zoomSteps = steps(.6, .9, 3)
-
-        row = 0
-        for ry in rySteps:
-            xSteps = rxSteps[:]
-            if row % 2:
-                xSteps.reverse()
-            row += 1
-            for rx in xSteps:
-                for zoom in zoomSteps:
-                    self.toGather.append(
-                        DeviceSettings(
-                            graph,
-                            [
-                                (dev, L9['rx'], rx),
-                                (dev, L9['ry'], ry),
-                                (dev, L9['color'], '#ffffff'),
-                                (dev, L9['zoom'], zoom),
-                                #(dev, L9['focus'], 0.13),
-                            ]))
-
-        self.devTail = dev.rsplit('/')[-1]
-        self.session = URIRef('/'.join(
-            [showconfig.showUri(), 'capture', self.devTail, self.captureId]))
-        self.ctx = URIRef(self.session + '/index')
-
-        self.graph.patch(
-            Patch(addQuads=[
-                (self.session, RDF.type, L9['CaptureSession'], self.ctx),
-            ]))
-
-        self.numPics = 0
-        self.settingsCache = set()
-        self.step().addErrback(log.error)
-
-    def off(self):
-        return sendToCollector(client='captureDevice',
-                               session='main',
-                               settings=DeviceSettings(self.graph, []))
-
-    @inlineCallbacks
-    def step(self):
-        if not self.toGather:
-            yield self.off()
-            yield deferSleep(1)
-            reactor.stop()
-            return
-        settings = self.toGather.pop()
-
-        log.info('[%s left] move to %r', len(self.toGather), settings)
-        yield sendToCollector(client='captureDevice',
-                              session='main',
-                              settings=settings)
-
-        yield deferSleep(self.firstMoveTime if self.numPics ==
-                         0 else self.settleTime)
-
-        picId = 'pic%s' % self.numPics
-        path = '/'.join(['capture', self.devTail, self.captureId, picId
-                        ]) + '.jpg'
-        uri = URIRef(self.session + '/' + picId)
-
-        yield camera.takePic(uri, os.path.join(showconfig.root(), path))
-        self.numPics += 1
-
-        writeCaptureDescription(self.graph, self.ctx, self.session, uri,
-                                self.dev, path, self.settingsCache, settings)
-
-        reactor.callLater(0, self.step)
-
-
-camera = Camera(
-    'http://plus:8200/picamserve/pic?res=1080&resize=800&iso=800&redgain=1.6&bluegain=1.6&shutter=60000&x=0&w=1&y=0&h=.952'
-)
-
-
-class Attrs(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    @metrics('set_attr').time()
-    def put(self):
-        client, clientSession, settings, sendTime = parseJsonMessage(
-            self.request.body)
-        self.set_status(202)
-
-
-def launch(graph):
-
-    cap = Capture(graph, dev=L9['device/aura5'])
-    reactor.listenTCP(networking.captureDevice.port,
-                      cyclone.web.Application(handlers=[
-                          (r'/()', cyclone.web.StaticFileHandler, {
-                              "path": "light9/web",
-                              "default_filename": "captureDevice.html"
-                          }),
-                          metricsRoute(),
-                      ]),
-                      interface='::',
-                      cap=cap)
-    log.info('serving http on %s', networking.captureDevice.port)
-
-
-def main():
-    parser = optparse.OptionParser()
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    (options, args) = parser.parse_args()
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-    graph = SyncedGraph(networking.rdfdb.url, "captureDevice")
-
-    graph.initiallySynced.addCallback(lambda _: launch(graph)).addErrback(
-        log.error)
-    reactor.run()
-
-
-if __name__ == '__main__':
-    main()
--- a/bin/clientdemo	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#!bin/python
-
-import os, sys
-sys.path.append(".")
-from twisted.internet import reactor
-import cyclone.web, cyclone.httpclient, logging
-from rdflib import Namespace, Literal, URIRef
-from light9 import networking
-from rdfdb.patch import Patch
-from rdfdb.syncedgraph import SyncedGraph
-
-if __name__ == "__main__":
-    logging.basicConfig(level=logging.DEBUG)
-    log = logging.getLogger()
-
-    g = SyncedGraph(networking.rdfdb.url, "clientdemo")
-
-    from light9.Submaster import PersistentSubmaster
-    sub = PersistentSubmaster(
-        graph=g, uri=URIRef("http://light9.bigasterisk.com/sub/bcools"))
-
-    #get sub to show its updating name, then push that all the way into KC gui so we can see just names refresh in there
-
-    L9 = Namespace("http://light9.bigasterisk.com/")
-
-    def updateDemoValue():
-        v = list(g.objects(L9['demo'], L9['is']))
-        print("demo value is %r" % v)
-
-    g.addHandler(updateDemoValue)
-
-    def adj():
-        g.patch(
-            Patch(addQuads=[(L9['demo'], L9['is'], Literal(os.getpid()),
-                             L9['clientdemo'])],
-                  delQuads=[]))
-
-    reactor.callLater(2, adj)
-    reactor.run()
--- a/bin/collector_loadtest.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-#!bin/python
-import asyncio
-import logging
-import random
-from rdflib import URIRef
-import time
-
-from light9.collector.collector_client_asyncio import sendToCollector
-from light9.effect.settings import DeviceSettings
-from light9.namespaces import DEV, L9
-from light9.run_local import log
-
-log.setLevel(logging.DEBUG)
-
-
-async def loadTest():
-    print("scheduling loadtest")
-    n = 200000
-    period=.02
-    times = []
-    session = "loadtest%s" % time.time()
-    for i in range(n):
-        if i % 100 == 0:
-            log.info('sendToCollector %s', i)
-        start = time.time()
-        await sendToCollector(
-            "http://localhost:8202/",
-            session,
-            DeviceSettings(
-                graph=None,
-                settingsList=[
-                    # [DEV["backlight1"], L9["color"], "#ffffff"],  #
-                    # [DEV["backlight2"], L9["color"], "#ffffff"],
-                    # [DEV["backlight3"], L9["color"], "#ffffff"],
-                    # [DEV["backlight4"], L9["color"], "#ffffff"],
-                    # [DEV["backlight5"], L9["color"], "#ffffff"],
-                    # [DEV["down2"], L9["color"], "#ffffff"],
-                    # [DEV["down3"], L9["color"], "#ffffff"],
-                    # [DEV["down4"], L9["color"], "#ffffff"],
-                    [URIRef('http://light9.bigasterisk.com/theater/skyline/device/down1'), L9["brightness"], random.random()],
-                    [DEV["backlight5"], L9["uv"], 0.011]
-                ]))
-        times.append(time.time() - start)
-        await asyncio.sleep(period)
-
-    print("loadtest done")
-    with open('/tmp/times', 'w') as f:
-        f.write(''.join('%s\n' % t for t in times))
-
-
-if __name__ == '__main__':
-    asyncio.run(loadTest())
--- a/bin/curvecalc	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,574 +0,0 @@
-#!bin/python
-"""
-now launches like this:
-% bin/curvecalc http://light9.bigasterisk.com/show/dance2007/song1
-
-
-
-todo: curveview should preserve more objects, for speed maybe
-
-"""
-
-import sys
-import imp
-sys.path.append('/usr/lib/python2.7/dist-packages')  # For gtk
-from twisted.internet import gtk3reactor
-gtk3reactor.install()
-from twisted.internet import reactor
-
-import time, textwrap, os, optparse, linecache, signal, traceback, json
-import gi
-from gi.repository import Gtk
-from gi.repository import GObject
-from gi.repository import Gdk
-
-from urllib.parse import parse_qsl
-import louie as dispatcher
-from rdflib import URIRef, Literal, RDF, RDFS
-import logging
-
-from run_local import log
-from light9 import showconfig, networking
-from light9.curvecalc import curveview
-from light9.curvecalc.curve import Curveset
-from light9.curvecalc.curveedit import serveCurveEdit
-from light9.curvecalc.musicaccess import Music
-from light9.curvecalc.output import Output
-from light9.curvecalc.subterm import Subterm
-from light9.curvecalc.subtermview import add_one_subterm
-from light9.editchoicegtk import EditChoice, Local
-from light9.gtkpyconsole import togglePyConsole
-from light9.namespaces import L9
-from light9.observable import Observable
-from light9 import clientsession
-from rdfdb.patch import Patch
-from rdfdb.syncedgraph import SyncedGraph
-from light9.wavelength import wavelength
-
-
-class SubtermExists(ValueError):
-    pass
-
-
-class Main(object):
-
-    def __init__(self, graph, opts, session, curveset, music):
-        self.graph, self.opts, self.session = graph, opts, session
-        self.curveset, self.music = curveset, music
-        self.lastSeenInputTime = 0
-        self.currentSubterms = [
-        ]  # Subterm objects that are synced to the graph
-
-        self.setTheme()
-        wtree = self.wtree = Gtk.Builder()
-        wtree.add_from_file("light9/curvecalc/curvecalc.glade")
-        mainwin = wtree.get_object("MainWindow")
-
-        mainwin.connect("destroy", self.onQuit)
-        wtree.connect_signals(self)
-
-        mainwin.show_all()
-
-        mainwin.connect("delete-event", lambda *args: reactor.crash())
-
-        def updateTitle():
-            mainwin.set_title(
-                "curvecalc - %s" %
-                graph.label(graph.value(session, L9['currentSong'])))
-
-        graph.addHandler(updateTitle)
-
-        songChoice = Observable(None)  # to be connected with the session song
-
-        self.registerGraphToSongChoice(wtree, session, graph, songChoice)
-        self.registerSongChoiceToGraph(session, graph, songChoice)
-        self.registerCurrentPlayerSongToUi(wtree, graph, songChoice)
-
-        ec = EditChoice(graph, songChoice, label="Editing song:")
-        wtree.get_object("currentSongEditChoice").add(ec)
-        ec.show()
-
-        wtree.get_object("subterms").connect("add", self.onSubtermChildAdded)
-
-        self.refreshCurveView()
-
-        self.makeStatusLines(wtree.get_object("status"))
-        self.setupNewSubZone()
-        self.acceptDragsOnCurveViews()
-
-        # may not work
-        wtree.get_object("paned1").set_position(600)
-
-    def registerGraphToSongChoice(self, wtree, session, graph, songChoice):
-
-        def setSong():
-            current = graph.value(session, L9['currentSong'])
-            if not wtree.get_object("followPlayerSongChoice").get_active():
-                songChoice(current)
-                dispatcher.send("song_has_changed")
-
-        graph.addHandler(setSong)
-
-    def registerSongChoiceToGraph(self, session, graph, songChoice):
-        self.muteSongChoiceUntil = 0
-
-        def songChoiceToGraph(newSong):
-            if newSong is Local:
-                raise NotImplementedError('what do i patch')
-            log.debug('songChoiceToGraph is going to set to %r', newSong)
-
-            # I get bogus newSong values in here sometimes. This
-            # workaround may not even be helping.
-            now = time.time()
-            if now < self.muteSongChoiceUntil:
-                log.debug('muted')
-                return
-            self.muteSongChoiceUntil = now + 1
-
-            graph.patchObject(context=session,
-                              subject=session,
-                              predicate=L9['currentSong'],
-                              newObject=newSong)
-
-        songChoice.subscribe(songChoiceToGraph)
-
-    def registerCurrentPlayerSongToUi(self, wtree, graph, songChoice):
-        """current_player_song 'song' param -> playerSong ui
-        and
-        current_player_song 'song' param -> songChoice, if you're in autofollow
-        """
-
-        def current_player_song(song):
-            # (this is run on every frame)
-            ps = wtree.get_object("playerSong")
-            if URIRef(ps.get_uri()) != song:
-                log.debug("update playerSong to %s", ps.get_uri())
-
-                def setLabel():
-                    ps.set_label(graph.label(song))
-
-                graph.addHandler(setLabel)
-                ps.set_uri(song)
-            if song != songChoice():
-                if wtree.get_object("followPlayerSongChoice").get_active():
-                    log.debug('followPlayerSongChoice is on')
-                    songChoice(song)
-
-        dispatcher.connect(current_player_song, "current_player_song")
-        self.current_player_song = current_player_song
-
-    def setupNewSubZone(self):
-        self.wtree.get_object("newSubZone").drag_dest_set(
-            flags=Gtk.DestDefaults.ALL,
-            targets=[Gtk.TargetEntry('text/uri-list', 0, 0)],
-            actions=Gdk.DragAction.COPY)
-
-    def acceptDragsOnCurveViews(self):
-        w = self.wtree.get_object("curves")
-        w.drag_dest_set(flags=Gtk.DestDefaults.ALL,
-                        targets=[Gtk.TargetEntry('text/uri-list', 0, 0)],
-                        actions=Gdk.DragAction.COPY)
-
-        def recv(widget, context, x, y, selection, targetType, time):
-            subUri = URIRef(selection.data.strip())
-            print("into curves", subUri)
-            with self.graph.currentState(tripleFilter=(subUri, RDFS.label,
-                                                       None)) as current:
-                subName = current.label(subUri)
-
-            if '?' in subUri:
-                subName = self.handleSubtermDrop(subUri)
-            else:
-                try:
-                    self.makeSubterm(subName,
-                                     withCurve=True,
-                                     sub=subUri,
-                                     expr="%s(t)" % subName)
-                except SubtermExists:
-                    # we're not making sure the expression/etc are
-                    # correct-- user mihgt need to fix things
-                    pass
-            curveView = self.curvesetView.row(subName).curveView
-            t = self.lastSeenInputTime  # curveView.current_time() # new curve hasn't heard the time yet. this has gotten too messy- everyone just needs to be able to reach the time source
-            print("time", t)
-            curveView.add_points([(t - .5, 0), (t, 1)])
-
-        w.connect("drag-data-received", recv)
-
-    def onDragDataInNewSubZone(self, widget, context, x, y, selection,
-                               targetType, time):
-        data = URIRef(selection.data.strip())
-        if '?' in data:
-            self.handleSubtermDrop(data)
-            return
-        with self.graph.currentState(tripleFilter=(data, None,
-                                                   None)) as current:
-            subName = current.label(data)
-        self.makeSubterm(newname=subName,
-                         withCurve=True,
-                         sub=data,
-                         expr="%s(t)" % subName)
-
-    def handleSubtermDrop(self, data):
-        params = parse_qsl(data.split('?')[1])
-        flattened = dict(params)
-        self.makeSubterm(Literal(flattened['subtermName']),
-                         expr=flattened['subtermExpr'])
-
-        for cmd, name in params:
-            if cmd == 'curve':
-                self.curveset.new_curve(name)
-        return name
-
-    def onNewCurve(self, *args):
-        dialog = self.wtree.get_object("newCurve")
-        entry = self.wtree.get_object("newCurveName")
-        # if you don't have songx, that should be the suggested name
-        entry.set_text("")
-        if dialog.run() == 1:
-            self.curveset.new_curve(entry.get_text())
-        dialog.hide()
-
-    def onRedrawCurves(self, *args):
-        dispatcher.send("all curves rebuild")
-
-    def onSubtermsMap(self, *args):
-        # if this was called too soon, like in __init__, the gtktable
-        # would get its children but it wouldn't lay anything out that
-        # I can see, and I'm not sure why. Waiting for map event is
-        # just a wild guess.
-        self.graph.addHandler(self.set_subterms_from_graph)
-
-    def onNewSubterm(self, *args):
-        self.makeSubterm(Literal(""), withCurve=False)
-        return
-
-        # pretty sure i don't want this back, but not completely sure
-        # what the UX should be to get the new curve.
-
-        dialog = self.wtree.get_object("newSubterm")
-        # the plan is to autocomplete this on existing subterm names
-        # (but let you make one up, too)
-        entry = self.wtree.get_object("newSubtermName").get_children()[0]
-        entry.set_text("")
-        entry.grab_focus()
-        if dialog.run() == 1:
-            newname = entry.get_text()
-            wc = self.wtree.get_object("newSubtermMakeCurve").get_active()
-            self.makeSubterm(newname, withCurve=wc)
-        dialog.hide()
-
-    def currentSong(self):
-
-        with self.graph.currentState(tripleFilter=(self.session,
-                                                   L9['currentSong'],
-                                                   None)) as current:
-            return current.value(self.session, L9['currentSong'])
-
-    def songSubtermsContext(self):
-        return self.currentSong()
-
-    def makeSubterm(self, newname, withCurve=False, expr=None, sub=None):
-        """
-        raises SubtermExists if we had a subterm with a sub with the given
-        name. what about a no-sub term with the same label? who knows
-        """
-        assert isinstance(newname, Literal), repr(newname)
-        if withCurve:
-            self.curveset.new_curve(newname)
-        if newname in self.all_subterm_labels():
-            raise SubtermExists("have a subterm who sub is named %r" % newname)
-        with self.graph.currentState() as current:
-            song = self.currentSong()
-            for i in range(1000):
-                uri = song + "/subterm/%d" % i
-                if (uri, None, None) not in current:
-                    break
-            else:
-                raise ValueError("can't pick a name for the new subterm")
-
-        ctx = self.songSubtermsContext()
-        quads = [
-            (uri, RDF.type, L9.Subterm, ctx),
-            (uri, RDFS.label, Literal(newname), ctx),
-            (self.currentSong(), L9['subterm'], uri, ctx),
-        ]
-        if sub is not None:
-            quads.append((uri, L9['sub'], sub, ctx))
-        if expr is not None:
-            quads.append((uri, L9['expression'], Literal(expr), ctx))
-        self.graph.patch(Patch(addQuads=quads))
-
-        return uri
-
-    def all_subterm_labels(self):
-        """
-        Literal labels of subs in subterms. doesn't currently include labels of the
-        subterm resources. I'm not sure what I'm going to do with
-        those.
-        """
-        labels = []
-        with self.graph.currentState() as current:
-            for st in current.objects(
-                    current.value(self.session, L9['currentSong']),
-                    L9['subterm']):
-                sub = current.value(st, L9['sub'])
-                if sub is not None:
-                    labels.append(current.label(sub))
-        return labels
-
-    def set_subterms_from_graph(self):
-        """rebuild all the gtktable 'subterms' widgets and the
-        self.currentSubterms list"""
-        song = self.graph.value(self.session, L9['currentSong'])
-
-        newList = []
-        for st in set(self.graph.objects(song, L9['subterm'])):
-            log.debug("song %s has subterm %s", song, st)
-            term = Subterm(self.graph, st, self.songSubtermsContext(),
-                           self.curveset)
-            newList.append(term)
-        self.currentSubterms[:] = newList
-
-        master = self.wtree.get_object("subterms")
-        log.debug("removing subterm widgets")
-        [master.remove(c) for c in master.get_children()]
-        for term in self.currentSubterms:
-            add_one_subterm(term, self.curveset, master)
-        master.show_all()
-        log.debug("%s table children showing" % len(master.get_children()))
-
-    def setTheme(self):
-        settings = Gtk.Settings.get_default()
-        settings.set_property("gtk-application-prefer-dark-theme", True)
-
-        providers = []
-        providers.append(Gtk.CssProvider())
-        providers[-1].load_from_path("theme/Just-Dark/gtk-3.0/gtk.css")
-        providers.append(Gtk.CssProvider())
-        providers[-1].load_from_data('''
-          * { font-size: 92%; }
-          .button:link { font-size: 7px }
-        ''')
-
-        screen = Gdk.Display.get_default_screen(Gdk.Display.get_default())
-        for p in providers:
-            Gtk.StyleContext.add_provider_for_screen(
-                screen, p, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
-
-    def onSubtermChildAdded(self, subtermsTable, *args):
-        # this would probably work, but isn't getting called
-        log.info("onSubtermChildAdded")
-        v = subtermsTable.get_parent().props.vadjustment
-        v.props.value = v.props.upper
-
-    def onQuit(self, *args):
-        reactor.crash()
-        # there's a hang after this, maybe in sem_wait in two
-        # threads. I don't know whose they are.
-        # This fix affects profilers who want to write output at the end.
-        os.kill(os.getpid(), signal.SIGKILL)
-
-    def onCollapseAll(self, *args):
-        self.curvesetView.collapseAll()
-
-    def onCollapseNone(self, *args):
-        self.curvesetView.collapseNone()
-
-    def onDelete(self, *args):
-        self.curvesetView.onDelete()
-
-    def onPythonConsole(self, item):
-        ns = dict()
-        ns.update(globals())
-        ns.update(self.__dict__)
-        togglePyConsole(self, item, ns)
-
-    def onSeeCurrentTime(self, item):
-        dispatcher.send("see time")
-
-    def onSeeTimeUntilEnd(self, item):
-        dispatcher.send("see time until end")
-
-    def onZoomAll(self, item):
-        dispatcher.send("show all")
-
-    def onPlayPause(self, item):
-        # since the X coord in a curveview affects the handling, one
-        # of them may be able to pick this up
-        results = dispatcher.send("onPlayPause")
-        times = [t for listener, t in results if t is not None]
-        self.music.playOrPause(t=times[0] if times else None)
-
-    def onSave(self, *args):
-        # only doing curves still. I hope to eliminate all this.
-        log.info("saving curves")
-        self.curveset.save()
-        log.info("saved")
-
-    def makeStatusLines(self, master):
-        """various labels that listen for dispatcher signals"""
-        for row, (signame, textfilter) in enumerate([
-            ('input time', lambda t: "%.2fs" % t),
-            ('output levels', lambda levels: textwrap.fill(
-                "; ".join([
-                    "%s:%.2f" % (n, v) for n, v in list(levels.items())[:2] if v
-                    > 0
-                ]), 70)),
-            ('update period', lambda t: "%.1fms" % (t * 1000)),
-            ('update status', lambda x: str(x)),
-        ]):
-            key = Gtk.Label("%s:" % signame)
-            value = Gtk.Label("")
-            master.resize(row + 1, 2)
-            master.attach(key, 0, 1, row, row + 1)
-            master.attach(value, 1, 2, row, row + 1)
-            key.set_alignment(1, 0)
-            value.set_alignment(0, 0)
-
-            dispatcher.connect(lambda val, value=value, tf=textfilter: value.
-                               set_text(tf(val)),
-                               signame,
-                               weak=False)
-        dispatcher.connect(lambda val: setattr(self, 'lastSeenInputTime', val),
-                           'input time',
-                           weak=False)
-        master.show_all()
-
-    def refreshCurveView(self):
-        wtree = self.wtree
-        mtimes = [
-            os.path.getmtime(f) for f in [
-                'light9/curvecalc/curveview.py',
-                'light9/curvecalc/zoomcontrol.py',
-            ]
-        ]
-
-        if (not hasattr(self, 'curvesetView') or
-                self.curvesetView._mtimes != mtimes):
-            print("reload curveview.py")
-            curvesVBox = wtree.get_object("curves")
-            zoomControlBox = wtree.get_object("zoomControlBox")
-            [curvesVBox.remove(c) for c in curvesVBox.get_children()]
-            [zoomControlBox.remove(c) for c in zoomControlBox.get_children()]
-            try:
-                linecache.clearcache()
-                imp.reload(curveview)
-
-                # old ones are not getting deleted right
-                if hasattr(self, 'curvesetView'):
-                    self.curvesetView.live = False
-
-                # mem problem somewhere; need to hold a ref to this
-                self.curvesetView = curveview.Curvesetview(
-                    self.graph, curvesVBox, zoomControlBox, self.curveset)
-                self.curvesetView._mtimes = mtimes
-
-                # this is scheduled after some tk shuffling, to
-                # try to minimize the number of times we redraw
-                # the curve at startup. If tk is very slow, it's
-                # ok. You'll just get some wasted redraws.
-                self.curvesetView.goLive()
-            except Exception:
-                print("reload failed:")
-                traceback.print_exc()
-        if self.opts.reload:
-            reactor.callLater(1, self.refreshCurveView)
-
-
-class MaxTime(object):
-    """
-    looks up the time in seconds for the session's current song
-    """
-
-    def __init__(self, graph, session):
-        self.graph, self.session = graph, session
-        graph.addHandler(self.update)
-
-    def update(self):
-        song = self.graph.value(self.session, L9['currentSong'])
-        if song is None:
-            self.maxtime = 0
-            return
-        musicfilename = showconfig.songOnDisk(song)
-        self.maxtime = wavelength(musicfilename)
-        log.info("new max time %r", self.maxtime)
-        dispatcher.send("max time", maxtime=self.maxtime)
-
-    def get(self):
-        return self.maxtime
-
-
-def launch(args, graph, session, opts, startTime, music):
-
-    try:
-        song = URIRef(args[0])
-        graph.patchObject(context=session,
-                          subject=session,
-                          predicate=L9['currentSong'],
-                          newObject=song)
-    except IndexError:
-        pass
-
-    curveset = Curveset(graph=graph, session=session)
-
-    log.debug("startup: output %s", time.time() - startTime)
-
-    mt = MaxTime(graph, session)
-    dispatcher.connect(lambda: mt.get(), "get max time", weak=False)
-
-    start = Main(graph, opts, session, curveset, music)
-    out = Output(graph, session, music, curveset, start.currentSubterms)
-
-    dispatcher.send("show all")
-
-    if opts.startup_only:
-        log.debug("quitting now because of --startup-only")
-        return
-
-    def hoverTimeResponse(requestHandler):
-        results = dispatcher.send("onPlayPause")
-        times = [t for listener, t in results if t is not None]
-        if not times:
-            requestHandler.set_status(404)
-            requestHandler.write("not hovering over any time")
-            return
-        with graph.currentState(tripleFilter=(session, L9['currentSong'],
-                                              None)) as g:
-            song = g.value(session, L9['currentSong'])
-            json.dump({"song": song, "hoverTime": times[0]}, requestHandler)
-
-    serveCurveEdit(networking.curveCalc.port, hoverTimeResponse, start.curveset)
-
-
-def main():
-    startTime = time.time()
-    parser = optparse.OptionParser()
-    parser.set_usage("%prog [opts] [songURI]")
-    parser.add_option("--debug", action="store_true", help="log at DEBUG")
-    parser.add_option("--reload",
-                      action="store_true",
-                      help="live reload of themes and code")
-    parser.add_option("--startup-only",
-                      action='store_true',
-                      help="quit after loading everything (for timing tests)")
-    parser.add_option("--profile", help='"hotshot" or "stat"')
-    clientsession.add_option(parser)
-    opts, args = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if opts.debug else logging.INFO)
-
-    log.debug("startup: music %s", time.time() - startTime)
-
-    session = clientsession.getUri('curvecalc', opts)
-
-    music = Music()
-    graph = SyncedGraph(networking.rdfdb.url, "curvecalc")
-
-    graph.initiallySynced.addCallback(lambda _: launch(args, graph, session,
-                                                       opts, startTime, music))
-    from light9 import prof
-    prof.run(reactor.run, profile=opts.profile)
-
-
-main()
--- a/bin/curvecalc_all_subterms	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-#!/bin/zsh
-echo broken: use a plain shell loop
-exit 1
-
-
-for x (`ls $LIGHT9_SHOW/subterms`) { bin/curvecalc $x }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/debug/clientdemo	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,39 @@
+#!bin/python
+
+import os, sys
+sys.path.append(".")
+from twisted.internet import reactor
+import cyclone.web, cyclone.httpclient, logging
+from rdflib import Namespace, Literal, URIRef
+from light9 import networking
+from rdfdb.patch import Patch
+from rdfdb.syncedgraph import SyncedGraph
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.DEBUG)
+    log = logging.getLogger()
+
+    g = SyncedGraph(networking.rdfdb.url, "clientdemo")
+
+    from light9.Submaster import PersistentSubmaster
+    sub = PersistentSubmaster(
+        graph=g, uri=URIRef("http://light9.bigasterisk.com/sub/bcools"))
+
+    #get sub to show its updating name, then push that all the way into KC gui so we can see just names refresh in there
+
+    L9 = Namespace("http://light9.bigasterisk.com/")
+
+    def updateDemoValue():
+        v = list(g.objects(L9['demo'], L9['is']))
+        print("demo value is %r" % v)
+
+    g.addHandler(updateDemoValue)
+
+    def adj():
+        g.patch(
+            Patch(addQuads=[(L9['demo'], L9['is'], Literal(os.getpid()),
+                             L9['clientdemo'])],
+                  delQuads=[]))
+
+    reactor.callLater(2, adj)
+    reactor.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/debug/collector_loadtest.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,52 @@
+#!bin/python
+import asyncio
+import logging
+import random
+from rdflib import URIRef
+import time
+
+from light9.collector.collector_client_asyncio import sendToCollector
+from light9.effect.settings import DeviceSettings
+from light9.namespaces import DEV, L9
+from light9.run_local import log
+
+log.setLevel(logging.DEBUG)
+
+
+async def loadTest():
+    print("scheduling loadtest")
+    n = 200000
+    period=.02
+    times = []
+    session = "loadtest%s" % time.time()
+    for i in range(n):
+        if i % 100 == 0:
+            log.info('sendToCollector %s', i)
+        start = time.time()
+        await sendToCollector(
+            "http://localhost:8202/",
+            session,
+            DeviceSettings(
+                graph=None,
+                settingsList=[
+                    # [DEV["backlight1"], L9["color"], "#ffffff"],  #
+                    # [DEV["backlight2"], L9["color"], "#ffffff"],
+                    # [DEV["backlight3"], L9["color"], "#ffffff"],
+                    # [DEV["backlight4"], L9["color"], "#ffffff"],
+                    # [DEV["backlight5"], L9["color"], "#ffffff"],
+                    # [DEV["down2"], L9["color"], "#ffffff"],
+                    # [DEV["down3"], L9["color"], "#ffffff"],
+                    # [DEV["down4"], L9["color"], "#ffffff"],
+                    [URIRef('http://light9.bigasterisk.com/theater/skyline/device/down1'), L9["brightness"], random.random()],
+                    [DEV["backlight5"], L9["uv"], 0.011]
+                ]))
+        times.append(time.time() - start)
+        await asyncio.sleep(period)
+
+    print("loadtest done")
+    with open('/tmp/times', 'w') as f:
+        f.write(''.join('%s\n' % t for t in times))
+
+
+if __name__ == '__main__':
+    asyncio.run(loadTest())
--- a/bin/dmx_color_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#!bin/python
-from run_local import log
-import colorsys, time, logging
-from light9 import dmxclient
-from twisted.internet import reactor, task
-
-log.setLevel(logging.INFO)
-firstDmxChannel = 10
-
-
-def step():
-    hue = (time.time() * .2) % 1.0
-    r, g, b = colorsys.hsv_to_rgb(hue, 1, 1)
-    chans = [r, g, b]
-    log.info(chans)
-    dmxclient.outputlevels([0] * (firstDmxChannel - 1) + chans, twisted=True)
-
-
-task.LoopingCall(step).start(.05)
-reactor.run()
--- a/bin/dmxserver	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,280 +0,0 @@
-#!bin/python
-"""
-Replaced by bin/collector
-
-
-this is the only process to talk to the dmx hardware. other clients
-can connect to this server and present dmx output, and this server
-will max ('pile-on') all the client requests.
-
-this server has a level display which is the final set of values that
-goes to the hardware.
-
-clients shall connect to the xmlrpc server and send:
-
-  their PID (or some other cookie)
-
-  a length-n list of 0..1 levels which will represent the channel
-    values for the n first dmx channels.
-
-server is port 8030; xmlrpc method is called outputlevels(pid,levellist).
-
-todo:
-  save dmx on quit and restore on restart
-  if parport fails, run in dummy mode (and make an option for that too)
-"""
-
-from twisted.internet import reactor
-from twisted.web import xmlrpc, server
-import sys, time, os
-from optparse import OptionParser
-import run_local
-import txosc.dispatch, txosc. async
-from light9.io import ParportDMX, UsbDMX
-
-from light9.updatefreq import Updatefreq
-from light9 import networking
-
-from txzmq import ZmqEndpoint, ZmqFactory, ZmqPullConnection, ZmqRequestTimeoutError
-import json
-
-
-def startZmq(port, outputlevels):
-    zf = ZmqFactory()
-    e = ZmqEndpoint('bind', 'tcp://*:%s' % port)
-    s = ZmqPullConnection(zf, e)
-
-    def onPull(message):
-        msg = json.loads(message[0])
-        outputlevels(msg['clientid'], msg['levellist'])
-
-    s.onPull = onPull
-
-
-class ReceiverApplication(object):
-    """
-    receive UDP OSC messages. address is /dmx/1 for dmx channel 1,
-    arguments are 0-1 floats for that channel and any number of
-    following channels.
-    """
-
-    def __init__(self, port, lightServer):
-        self.port = port
-        self.lightServer = lightServer
-        self.receiver = txosc.dispatch.Receiver()
-        self.receiver.addCallback("/dmx/*", self.pixel_handler)
-        self._server_port = reactor.listenUDP(
-            self.port,
-            txosc. async .DatagramServerProtocol(self.receiver),
-            interface='0.0.0.0')
-        print("Listening OSC on udp port %s" % (self.port))
-
-    def pixel_handler(self, message, address):
-        # this is already 1-based though I don't know why
-        startChannel = int(message.address.split('/')[2])
-        levels = [a.value for a in message.arguments]
-        allLevels = [0] * (startChannel - 1) + levels
-        self.lightServer.xmlrpc_outputlevels("osc@%s" % startChannel, allLevels)
-
-
-class XMLRPCServe(xmlrpc.XMLRPC):
-
-    def __init__(self, options):
-
-        xmlrpc.XMLRPC.__init__(self)
-
-        self.clientlevels = {}  # clientID : list of levels
-        self.lastseen = {}  # clientID : time last seen
-        self.clientfreq = {}  # clientID : updatefreq
-
-        self.combinedlevels = []  # list of levels, after max'ing the clients
-        self.clientschanged = 1  # have clients sent anything since the last send?
-        self.options = options
-        self.lastupdate = 0  # time of last dmx send
-        self.laststatsprint = 0  # time
-
-        # desired seconds between sendlevels() calls
-        self.calldelay = 1 / options.updates_per_sec
-
-        print("starting parport connection")
-        self.parportdmx = UsbDMX(dimmers=90, port=options.dmx_device)
-        if os.environ.get('DMXDUMMY', 0):
-            self.parportdmx.godummy()
-        else:
-            self.parportdmx.golive()
-
-        self.updatefreq = Updatefreq()  # freq of actual dmx sends
-        self.num_unshown_updates = None
-        self.lastshownlevels = None
-        # start the loop
-        self.sendlevels()
-
-        # the other loop
-        self.purgeclients()
-
-    def purgeclients(self):
-        """forget about any clients who haven't sent levels in a while.
-        this runs in a loop"""
-
-        purge_age = 10  # seconds
-
-        reactor.callLater(1, self.purgeclients)
-
-        now = time.time()
-        cids = list(self.lastseen.keys())
-        for cid in cids:
-            lastseen = self.lastseen[cid]
-            if lastseen < now - purge_age:
-                print(("forgetting client %s (no activity for %s sec)" %
-                       (cid, purge_age)))
-                try:
-                    del self.clientlevels[cid]
-                except KeyError:
-                    pass
-                del self.clientfreq[cid]
-                del self.lastseen[cid]
-
-    def sendlevels(self):
-        """sends to dmx if levels have changed, or if we havent sent
-        in a while"""
-
-        reactor.callLater(self.calldelay, self.sendlevels)
-
-        if self.clientschanged:
-            # recalc levels
-
-            self.calclevels()
-
-            if (self.num_unshown_updates is None or  # first time
-                    self.options.fast_updates or  # show always
-                (
-                    self.combinedlevels != self.lastshownlevels and  # changed
-                    self.num_unshown_updates > 5)):  # not too frequent
-                self.num_unshown_updates = 0
-                self.printlevels()
-                self.lastshownlevels = self.combinedlevels[:]
-            else:
-                self.num_unshown_updates += 1
-
-        if time.time() > self.laststatsprint + 2:
-            self.laststatsprint = time.time()
-            self.printstats()
-
-        # used to be a fixed 1 in here, for the max delay between
-        # calls, instead of calldelay
-        if self.clientschanged or time.time(
-        ) > self.lastupdate + self.calldelay:
-            self.lastupdate = time.time()
-            self.sendlevels_dmx()
-
-        self.clientschanged = 0  # clear the flag
-
-    def calclevels(self):
-        """combine all the known client levels into self.combinedlevels"""
-        self.combinedlevels = []
-        for chan in range(0, self.parportdmx.dimmers):
-            x = 0
-            for clientlist in list(self.clientlevels.values()):
-                if len(clientlist) > chan:
-                    # clamp client levels to 0..1
-                    cl = max(0, min(1, clientlist[chan]))
-                    x = max(x, cl)
-            self.combinedlevels.append(x)
-
-    def printlevels(self):
-        """write all the levels to stdout"""
-        print("Levels:",
-              "".join(["% 2d " % (x * 100) for x in self.combinedlevels]))
-
-    def printstats(self):
-        """print the clock, freq, etc, with a \r at the end"""
-
-        sys.stdout.write("dmxserver up at %s, [polls %s] " % (
-            time.strftime("%H:%M:%S"),
-            str(self.updatefreq),
-        ))
-        for cid, freq in list(self.clientfreq.items()):
-            sys.stdout.write("[%s %s] " % (cid, str(freq)))
-        sys.stdout.write("\r")
-        sys.stdout.flush()
-
-    def sendlevels_dmx(self):
-        """output self.combinedlevels to dmx, and keep the updates/sec stats"""
-        # they'll get divided by 100
-        if self.parportdmx:
-            self.parportdmx.sendlevels([l * 100 for l in self.combinedlevels])
-        self.updatefreq.update()
-
-    def xmlrpc_echo(self, x):
-        return x
-
-    def xmlrpc_outputlevels(self, cid, levellist):
-        """send a unique id for your client (name+pid maybe), then
-        the variable-length dmx levellist (scaled 0..1)"""
-        if levellist != self.clientlevels.get(cid, None):
-            self.clientlevels[cid] = levellist
-            self.clientschanged = 1
-        self.trackClientFreq(cid)
-        return "ok"
-
-    def xmlrpc_currentlevels(self, cid):
-        """get a list of levels we're currently sending out. All
-        channels beyond the list you get back, they're at zero."""
-        # if this is still too slow, it might help to return a single
-        # pickled string
-        self.trackClientFreq(cid)
-        trunc = self.combinedlevels[:]
-        i = len(trunc) - 1
-        if i < 0:
-            return []
-        while trunc[i] == 0 and i >= 0:
-            i -= 1
-        if i < 0:
-            return []
-        trunc = trunc[:i + 1]
-        return trunc
-
-    def trackClientFreq(self, cid):
-        if cid not in self.lastseen:
-            print("hello new client %s" % cid)
-            self.clientfreq[cid] = Updatefreq()
-        self.lastseen[cid] = time.time()
-        self.clientfreq[cid].update()
-
-
-parser = OptionParser()
-parser.add_option("-f",
-                  "--fast-updates",
-                  action='store_true',
-                  help=('display all dmx output to stdout instead '
-                        'of the usual reduced output'))
-parser.add_option("-r",
-                  "--updates-per-sec",
-                  type='float',
-                  default=20,
-                  help=('dmx output frequency'))
-parser.add_option("-d",
-                  "--dmx-device",
-                  default='/dev/dmx0',
-                  help='dmx device name')
-parser.add_option("-n",
-                  "--dummy",
-                  action="store_true",
-                  help="dummy mode, same as DMXDUMMY=1 env variable")
-(options, songfiles) = parser.parse_args()
-
-print(options)
-
-if options.dummy:
-    os.environ['DMXDUMMY'] = "1"
-
-port = networking.dmxServer.port
-print("starting xmlrpc server on port %s" % port)
-xmlrpcServe = XMLRPCServe(options)
-reactor.listenTCP(port, server.Site(xmlrpcServe))
-
-startZmq(networking.dmxServerZmq.port, xmlrpcServe.xmlrpc_outputlevels)
-
-oscApp = ReceiverApplication(9051, xmlrpcServe)
-
-reactor.run()
--- a/bin/effectListing	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-#!/bin/sh
-pnpm exec vite -c light9/effect/listing/web/vite.config.ts &
-wait
-
--- a/bin/effectSequencer	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-#!/bin/sh
-pnpm exec vite -c light9/effect/sequencer/web/vite.config.ts &
-pdm run uvicorn light9.effect.sequencer.service:app --host 0.0.0.0 --port 8213 --no-access-log 
-wait
-
--- a/bin/effecteval	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,283 +0,0 @@
-#!bin/python
-
-from run_local import log
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks, returnValue
-import cyclone.web, cyclone.websocket, cyclone.httpclient
-import sys, optparse, logging, json, itertools
-from rdflib import URIRef, Literal
-
-from light9 import networking, showconfig
-from light9.effecteval.effect import EffectNode
-from light9.effect.edit import getMusicStatus, songNotePatch
-from light9.effecteval.effectloop import makeEffectLoop
-from light9.metrics import metrics, metricsRoute
-from light9.namespaces import L9
-from rdfdb.patch import Patch
-from rdfdb.syncedgraph import SyncedGraph
-
-from cycloneerr import PrettyErrorHandler
-from light9.coffee import StaticCoffee
-
-
-
-class EffectEdit(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        self.set_header('Content-Type', 'text/html')
-        self.write(open("light9/effecteval/effect.html").read())
-
-    def delete(self):
-        graph = self.settings.graph
-        uri = URIRef(self.get_argument('uri'))
-        with graph.currentState(tripleFilter=(None, L9['effect'], uri)) as g:
-            song = ctx = list(g.subjects(L9['effect'], uri))[0]
-        self.settings.graph.patch(
-            Patch(delQuads=[
-                (song, L9['effect'], uri, ctx),
-            ]))
-
-
-@inlineCallbacks
-def currentSong():
-    s = (yield getMusicStatus())['song']
-    if s is None:
-        raise ValueError("no current song")
-    returnValue(URIRef(s))
-
-
-class SongEffects(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def wideOpenCors(self):
-        self.set_header('Access-Control-Allow-Origin', '*')
-        self.set_header('Access-Control-Allow-Methods',
-                        'GET, PUT, POST, DELETE, OPTIONS')
-        self.set_header('Access-Control-Max-Age', '1000')
-        self.set_header('Access-Control-Allow-Headers',
-                        'Content-Type, Authorization, X-Requested-With')
-
-    def options(self):
-        self.wideOpenCors()
-        self.write('')
-
-    @inlineCallbacks
-    def post(self):
-        self.wideOpenCors()
-        dropped = URIRef(self.get_argument('drop'))
-
-        try:
-            song = URIRef(self.get_argument('uri'))
-        except Exception:  # which?
-            song = yield currentSong()
-
-        event = self.get_argument('event', default='default')
-
-        note = self.get_argument('note', default=None)
-        if note is not None:
-            note = URIRef(note)
-
-        log.info("adding to %s", song)
-        note, p = yield songNotePatch(self.settings.graph,
-                                      dropped,
-                                      song,
-                                      event,
-                                      ctx=song,
-                                      note=note)
-
-        self.settings.graph.patch(p)
-        self.settings.graph.suggestPrefixes(song, {'song': URIRef(song + '/')})
-        self.write(json.dumps({'note': note}))
-
-
-class SongEffectsUpdates(cyclone.websocket.WebSocketHandler):
-
-    def connectionMade(self, *args, **kwargs):
-        self.graph = self.settings.graph
-        self.graph.addHandler(self.updateClient)
-
-    def updateClient(self):
-        # todo: abort if client is gone
-        playlist = self.graph.value(showconfig.showUri(), L9['playList'])
-        songs = list(self.graph.items(playlist))
-        out = []
-        for s in songs:
-            out.append({'uri': s, 'label': self.graph.label(s), 'effects': []})
-            seen = set()
-            for n in self.graph.objects(s, L9['note']):
-                for uri in self.graph.objects(n, L9['effectClass']):
-                    if uri in seen:
-                        continue
-                    seen.add(uri)
-                    out[-1]['effects'].append({
-                        'uri': uri,
-                        'label': self.graph.label(uri)
-                    })
-            out[-1]['effects'].sort(key=lambda e: e['uri'])
-
-        self.sendMessage({'songs': out})
-
-
-class EffectUpdates(cyclone.websocket.WebSocketHandler):
-    """
-    stays alive for the life of the effect page
-    """
-
-    def connectionMade(self, *args, **kwargs):
-        log.info("websocket opened")
-        self.uri = URIRef(self.get_argument('uri'))
-        self.sendMessage({'hello': repr(self)})
-
-        self.graph = self.settings.graph
-        self.graph.addHandler(self.updateClient)
-
-    def updateClient(self):
-        # todo: if client has dropped, abort and don't get any more
-        # graph updates
-
-        # EffectNode knows how to put them in order. Somehow this is
-        # not triggering an update when the order changes.
-        en = EffectNode(self.graph, self.uri)
-        codeLines = [c.code for c in en.codes]
-        self.sendMessage({'codeLines': codeLines})
-
-    def connectionLost(self, reason):
-        log.info("websocket closed")
-
-    def messageReceived(self, message):
-        log.info("got message %s" % message)
-        # write a patch back to the graph
-
-
-def replaceObjects(graph, c, s, p, newObjs):
-    patch = graph.getObjectPatch(context=c,
-                                 subject=s,
-                                 predicate=p,
-                                 newObject=newObjs[0])
-
-    moreAdds = []
-    for line in newObjs[1:]:
-        moreAdds.append((s, p, line, c))
-    fullPatch = Patch(delQuads=patch.delQuads,
-                      addQuads=patch.addQuads + moreAdds)
-    graph.patch(fullPatch)
-
-
-class Code(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def put(self):
-        effect = URIRef(self.get_argument('uri'))
-        codeLines = []
-        for i in itertools.count(0):
-            k = 'codeLines[%s][text]' % i
-            v = self.get_argument(k, None)
-            if v is not None:
-                codeLines.append(Literal(v))
-            else:
-                break
-        if not codeLines:
-            log.info("no codelines received on PUT /code")
-            return
-        with self.settings.graph.currentState(tripleFilter=(None, L9['effect'],
-                                                            effect)) as g:
-            song = next(g.subjects(L9['effect'], effect))
-
-        replaceObjects(self.settings.graph, song, effect, L9['code'], codeLines)
-
-        # right here we could tell if the code has a python error and return it
-        self.send_error(202)
-
-
-class EffectEval(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    @inlineCallbacks
-    def get(self):
-        # return dmx list for that effect
-        uri = URIRef(self.get_argument('uri'))
-        response = yield cyclone.httpclient.fetch(
-            networking.musicPlayer.path('time'))
-        songTime = json.loads(response.body)['t']
-
-        node = EffectNode(self.settings.graph, uri)
-        outSub = node.eval(songTime)
-        self.write(json.dumps(outSub.get_dmx_list()))
-
-
-# Completely not sure where the effect background loop should
-# go. Another process could own it, and get this request repeatedly:
-class SongEffectsEval(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        song = URIRef(self.get_argument('song'))
-        effects = effectsForSong(self.settings.graph, song)  # noqa
-        raise NotImplementedError
-        self.write(maxDict(effectDmxDict(e) for e in effects))  # noqa
-        # return dmx dict for all effects in the song, already combined
-
-
-class App(object):
-
-    def __init__(self, show, outputWhere):
-        self.show = show
-        self.outputWhere = outputWhere
-        self.graph = SyncedGraph(networking.rdfdb.url, "effectEval")
-        self.graph.initiallySynced.addCallback(self.launch).addErrback(
-            log.error)
-
-    def launch(self, *args):
-        log.info('launch')
-        if self.outputWhere:
-            self.loop = makeEffectLoop(self.graph, self.outputWhere)
-            self.loop.startLoop()
-
-        SFH = cyclone.web.StaticFileHandler
-        self.cycloneApp = cyclone.web.Application(handlers=[
-            (r'/()', SFH, {
-                'path': 'light9/effecteval',
-                'default_filename': 'index.html'
-            }),
-            (r'/effect', EffectEdit),
-            (r'/effect\.js', StaticCoffee, {
-                'src': 'light9/effecteval/effect.coffee'
-            }),
-            (r'/(effect-components\.html)', SFH, {
-                'path': 'light9/effecteval'
-            }),
-            (r'/effectUpdates', EffectUpdates),
-            (r'/code', Code),
-            (r'/songEffectsUpdates', SongEffectsUpdates),
-            (r'/effect/eval', EffectEval),
-            (r'/songEffects', SongEffects),
-            (r'/songEffects/eval', SongEffectsEval),
-            metricsRoute(),
-        ],
-                                                  debug=True,
-                                                  graph=self.graph)
-        reactor.listenTCP(networking.effectEval.port, self.cycloneApp)
-        log.info("listening on %s" % networking.effectEval.port)
-
-
-if __name__ == "__main__":
-    parser = optparse.OptionParser()
-    parser.add_option(
-        '--show',
-        help='show URI, like http://light9.bigasterisk.com/show/dance2008',
-        default=showconfig.showUri())
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    parser.add_option("--twistedlog",
-                      action="store_true",
-                      help="twisted logging")
-    parser.add_option("--output", metavar="WHERE", help="dmx or leds")
-    (options, args) = parser.parse_args()
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-    if not options.show:
-        raise ValueError("missing --show http://...")
-
-    app = App(URIRef(options.show), options.output)
-    if options.twistedlog:
-        from twisted.python import log as twlog
-        twlog.startLogging(sys.stderr)
-    reactor.run()
--- a/bin/fade	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-#!/bin/sh
-exec pnpm exec vite -c light9/fade/vite.config.ts
-
-
--- a/bin/gobutton	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-#!/bin/sh
-# uri should be set from $LIGHT9_SHOW/config.n3
-exec curl --silent -d '' http://localhost:8040/go
--- a/bin/gtk_dnd_demo.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#!bin/python
-import run_local
-import gtk
-import sys
-sys.path.append(".")
-from rdflib import URIRef
-from light9 import networking
-from light9.editchoicegtk import EditChoice, Local
-from light9.observable import Observable
-from rdfdb.syncedgraph import SyncedGraph
-
-win = gtk.Window()
-
-graph = SyncedGraph(networking.rdfdb.url, "gtkdnddemo")
-
-r1 = URIRef("http://example.com/interestingThing")
-v = Observable(r1)
-win.add(EditChoice(graph, v))
-win.show_all()
-gtk.main()
--- a/bin/inputdemo	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-#!bin/python
-import sys
-sys.path.append('/usr/lib/python2.7/dist-packages')  # For gtk
-from twisted.internet import gtk3reactor
-gtk3reactor.install()
-from twisted.internet import reactor
-from rdflib import URIRef
-import optparse, logging, time
-from gi.repository import Gtk
-from run_local import log
-from light9 import networking
-from light9 import clientsession
-from rdfdb.syncedgraph import SyncedGraph
-from light9.curvecalc.client import sendLiveInputPoint
-
-
-class App(object):
-
-    def __init__(self):
-        parser = optparse.OptionParser()
-        parser.set_usage("%prog [opts] [curve uri]")
-        parser.add_option("--debug", action="store_true", help="log at DEBUG")
-        clientsession.add_option(parser)
-        opts, args = parser.parse_args()
-
-        log.setLevel(logging.DEBUG if opts.debug else logging.INFO)
-
-        self.session = clientsession.getUri('inputdemo', opts)
-        self.graph = SyncedGraph(networking.rdfdb.url, "inputdemo")
-
-        self.graph.initiallySynced.addCallback(lambda _: self.launch())
-
-        self.curve = args[0] if args else URIRef(
-            'http://light9.bigasterisk.com/show/dance2014/song1/curve/c-1401259747.675542'
-        )
-        print("sending points on curve %s" % self.curve)
-
-        reactor.run()
-
-    def launch(self):
-        win = Gtk.Window()
-
-        slider = Gtk.Scale.new_with_range(orientation=Gtk.Orientation.VERTICAL,
-                                          min=0,
-                                          max=1,
-                                          step=.001)
-        slider.props.inverted = True
-        slider.connect('value-changed', self.onChanged)
-
-        win.add(slider)
-        win.parse_geometry('50x250')
-        win.connect("delete-event", lambda *a: reactor.crash())
-        win.connect("destroy", lambda *a: reactor.crash())
-        win.show_all()
-
-    def onChanged(self, scale):
-        t1 = time.time()
-        d = sendLiveInputPoint(self.curve, scale.get_value())
-
-        @d.addCallback
-        def done(result):
-            print("posted in %.1f ms" % (1000 * (time.time() - t1)))
-
-
-App()
--- a/bin/inputquneo	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,115 +0,0 @@
-#!bin/python
-"""
-read Quneo midi events, write to curvecalc and maybe to effects
-"""
-
-from run_local import log
-import logging, urllib.request, urllib.parse, urllib.error
-import cyclone.web, cyclone.httpclient
-from rdflib import URIRef
-from twisted.internet import reactor, task
-from light9.curvecalc.client import sendLiveInputPoint
-from light9.namespaces import L9, RDF
-from rdfdb.syncedgraph import SyncedGraph
-from light9 import networking
-
-import sys
-sys.path.append('/usr/lib/python2.7/dist-packages')  # For pygame
-import pygame.midi
-
-curves = {
-    23: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-2'),
-    24: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-3'),
-    25: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-4'),
-    6: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-5'),
-    18: URIRef('http://light9.bigasterisk.com/show/dance2014/song1/curve/c-6'),
-}
-
-
-class WatchMidi:
-
-    def __init__(self, graph):
-        self.graph = graph
-        pygame.midi.init()
-
-        dev = self.findQuneo()
-        self.inp = pygame.midi.Input(dev)
-        task.LoopingCall(self.step).start(.05)
-
-        self.noteIsOn = {}
-
-        self.effectMap = {}  # note: effect class uri
-        self.graph.addHandler(self.setupNotes)
-
-    def setupNotes(self):
-        for e in self.graph.subjects(RDF.type, L9['EffectClass']):
-            qn = self.graph.value(e, L9['quneoNote'])
-            if qn:
-                self.effectMap[int(qn)] = e
-        log.info("setup with %s effects", len(self.effectMap))
-
-    def findQuneo(self):
-        for dev in range(pygame.midi.get_count()):
-            interf, name, isInput, isOutput, opened = pygame.midi.get_device_info(
-                dev)
-            if 'QUNEO' in name and isInput:
-                return dev
-        raise ValueError("didn't find quneo input device")
-
-    def step(self):
-        if not self.inp.poll():
-            return
-        NOTEON, NOTEOFF = 144, 128
-        for ev in self.inp.read(999):
-            (status, d1, d2, _), _ = ev
-            if status in [NOTEON, NOTEOFF]:
-                print(status, d1, d2)
-
-            if status == NOTEON:
-                if not self.noteIsOn.get(d1):
-                    self.noteIsOn[d1] = True
-                    try:
-                        e = self.effectMap[d1]
-                        cyclone.httpclient.fetch(
-                            url=networking.effectEval.path('songEffects'),
-                            method='POST',
-                            headers={
-                                'Content-Type':
-                                ['application/x-www-form-urlencoded']
-                            },
-                            postdata=urllib.parse.urlencode([('drop', e)]),
-                        )
-                    except KeyError:
-                        pass
-
-            if status == NOTEOFF:
-                self.noteIsOn[d1] = False
-
-            if 0:
-                # curve editing mode, not done yet
-                for group in [(23, 24, 25), (6, 18)]:
-                    if d1 in group:
-                        if not self.noteIsOn.get(group):
-                            print("start zero")
-
-                            for d in group:
-                                sendLiveInputPoint(curves[d], 0)
-                            self.noteIsOn[group] = True
-                        else:  # miss first update
-                            sendLiveInputPoint(curves[d1], d2 / 127)
-
-                    if status == 128:  #noteoff
-                        for d in group:
-                            sendLiveInputPoint(curves[d], 0)
-                        self.noteIsOn[group] = False
-
-
-def main():
-    log.setLevel(logging.DEBUG)
-    graph = SyncedGraph(networking.rdfdb.url, "inputQuneo")
-    wm = WatchMidi(graph)
-    reactor.run()
-    del wm
-
-
-main()
--- a/bin/kcclient	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-#!/usr/bin/env python
-"""send KeyboardComposer a fade request, for use from the shell"""
-
-import sys
-import run_local
-from restclient import Resource
-from light9 import networking
-
-subname = sys.argv[1]
-level = sys.argv[2]
-fadesecs = '0'
-if len(sys.argv) > 3:
-    fadesecs = sys.argv[3]
-
-levelServer = Resource(networking.keyboardComposer.url)
-levelServer.post('fadesub', subname=subname, level=level, secs=fadesecs)
--- a/bin/keyboardcomposer	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,712 +0,0 @@
-#!bin/python
-
-from run_local import log
-
-from optparse import OptionParser
-from typing import Any, Dict, Tuple, List
-import cgi, time, logging
-import imp
-import tkinter.tix as tk
-
-from louie import dispatcher
-from rdflib import URIRef, Literal
-from twisted.internet import reactor, tksupport
-from twisted.web import resource
-import webcolors, colorsys
-
-from bcf2000 import BCF2000
-from light9 import clientsession
-from light9 import showconfig, networking, prof
-from light9.Fadable import Fadable
-from light9.effect.sequencer import CodeWatcher
-from light9.effect.settings import DeviceSettings
-from light9.effect.simple_outputs import SimpleOutputs
-from light9.namespaces import L9, RDF, RDFS
-from light9.subclient import SubClient
-from light9.tkdnd import initTkdnd, dragSourceRegister, dropTargetRegister
-from light9.uihelpers import toplevelat
-from rdfdb.patch import Patch
-from rdfdb.syncedgraph import SyncedGraph
-import light9.effect.effecteval
-
-nudge_keys = {'up': list('qwertyui'), 'down': list('asdfghjk')}
-
-
-class DummySliders:
-
-    def valueOut(self, name, value):
-        pass
-
-    def close(self):
-        pass
-
-    def reopen(self):
-        pass
-
-
-class SubScale(tk.Scale, Fadable):
-
-    def __init__(self, master, *args, **kw):
-        self.scale_var = kw.get('variable') or tk.DoubleVar()
-        kw.update({
-            'variable': self.scale_var,
-            'from': 1.,
-            'to': 0.,
-            'showvalue': 0,
-            'sliderlength': 15,
-            'res': 0.001,
-            'width': 40,
-            'troughcolor': 'black',
-            'bg': 'grey40',
-            'highlightthickness': 1,
-            'bd': 1,
-            'highlightcolor': 'red',
-            'highlightbackground': 'black',
-            'activebackground': 'red'
-        })
-        tk.Scale.__init__(self, master, *args, **kw)
-        Fadable.__init__(self, var=self.scale_var, wheel_step=0.05)
-        self.draw_indicator_colors()
-
-    def draw_indicator_colors(self):
-        if self.scale_var.get() == 0:
-            self['troughcolor'] = 'black'
-        else:
-            self['troughcolor'] = 'blue'
-
-
-class SubmasterBox(tk.Frame):
-    """
-    this object owns the level of the submaster (the rdf graph is the
-    real authority)
-
-    This leaks handlers or DoubleVars or something and tries to just
-    skip the obsolete ones. It'll get slower and bigger over
-    time. todo: make aa web version.
-    """
-
-    def __init__(self, master, graph, sub, session, col, row):
-        self.graph = graph
-        self.sub = sub
-        self.session = session
-        self.col, self.row = col, row
-        bg = self.graph.value(sub, L9['color'], default='#000000')
-        rgb = webcolors.hex_to_rgb(bg)
-        hsv = colorsys.rgb_to_hsv(*[x / 255 for x in rgb])
-        darkBg = webcolors.rgb_to_hex(
-            tuple([
-                int(x * 255) for x in colorsys.hsv_to_rgb(hsv[0], hsv[1], .2)
-            ]))
-        tk.Frame.__init__(self, master, bd=1, relief='raised', bg=bg)
-        self.name = self.graph.label(sub)
-        self._val = 0.0
-        self.slider_var = tk.DoubleVar()
-        self.pauseTrace = False
-        self.scale = SubScale(self, variable=self.slider_var, width=20)
-        self.dead = False
-
-        self.namelabel = tk.Label(self,
-                                  font="Arial 9",
-                                  bg=darkBg,
-                                  fg='white',
-                                  pady=0)
-        self.graph.addHandler(self.updateName)
-
-        self.namelabel.pack(side=tk.TOP)
-        self.levellabel = tk.Label(self,
-                                   textvariable=self.slider_var,
-                                   font="Arial 6",
-                                   bg='black',
-                                   fg='white',
-                                   pady=0)
-        self.levellabel.pack(side=tk.TOP)
-        self.scale.pack(side=tk.BOTTOM, expand=1, fill=tk.BOTH)
-
-        for w in [self, self.namelabel, self.levellabel]:
-            dragSourceRegister(w, 'copy', 'text/uri-list', sub)
-
-        self._slider_var_trace = self.slider_var.trace('w', self.slider_changed)
-
-        self.graph.addHandler(self.updateLevelFromGraph)
-
-        # initial position
-        # stil need? dispatcher.send("send_to_hw", sub=sub.uri, hwCol=col + 1)
-
-    def getVal(self) -> float:
-        return self._val
-
-    def setVal(self, newVal: float) -> None:
-        if self.dead:
-            return
-        try:
-            self.scale.set(newVal)
-            self.levellabel.config(text=str(newVal))
-        except Exception:
-            log.warning("disabling handlers on broken subbox")
-            self.dead = True
-
-    def cleanup(self):
-        self.slider_var.trace_vdelete('w', self._slider_var_trace)
-
-    def slider_changed(self, *args):
-        self._val = self.scale.get()
-        self.scale.draw_indicator_colors()
-
-        if self.pauseTrace:
-            return
-        self.updateGraphWithLevel(self.sub, self.getVal())
-
-        # needs fixing: plan is to use dispatcher or a method call to tell a hardware-mapping object who changed, and then it can make io if that's a current hw slider
-        dispatcher.send("send_to_hw",
-                        sub=self.sub,
-                        hwCol=self.col + 1,
-                        boxRow=self.row)
-
-    def updateGraphWithLevel(self, uri, level):
-        """in our per-session graph, we maintain SubSetting objects like this:
-
-           ?session :subSetting [a :SubSetting; :sub ?s; :level ?l]
-        """
-        # move to syncedgraph patchMapping
-
-        self.graph.patchMapping(context=self.session,
-                                subject=self.session,
-                                predicate=L9['subSetting'],
-                                nodeClass=L9['SubSetting'],
-                                keyPred=L9['sub'],
-                                newKey=uri,
-                                valuePred=L9['level'],
-                                newValue=Literal(level))
-
-    def updateLevelFromGraph(self):
-        """read rdf level, write it to subbox.slider_var"""
-        # move this to syncedgraph readMapping
-        graph = self.graph
-
-        for setting in graph.objects(self.session, L9['subSetting']):
-            if graph.value(setting, L9['sub']) == self.sub:
-                self.pauseTrace = True  # don't bounce this update back to server
-                try:
-                    self.setVal(graph.value(setting, L9['level']).toPython())
-                finally:
-                    self.pauseTrace = False
-
-    def updateName(self):
-        if self.scale is None:
-            return
-
-        def shortUri(u):
-            return '.../' + u.split('/')[-1]
-
-        try:
-            self.namelabel.config(
-                text=self.graph.label(self.sub) or shortUri(self.sub))
-        except Exception:
-            log.warn("disabling handlers on broken subbox")
-            self.scale = None
-
-
-class KeyboardComposer(tk.Frame, SubClient):
-
-    def __init__(self,
-                 root: tk.Tk,
-                 graph: SyncedGraph,
-                 session: URIRef,
-                 hw_sliders=True):
-        tk.Frame.__init__(self, root, bg='black')
-        SubClient.__init__(self)
-        self.graph = graph
-        self.session = session
-
-        self.subbox: Dict[URIRef, SubmasterBox] = {}  # sub uri : SubmasterBox
-        self.slider_table: Dict[Tuple[int, int], SubmasterBox] = {
-        }  # coords : SubmasterBox
-        self.rows: List[tk.Frame] = []  # this holds Tk Frames for each row
-
-        self.current_row = 0  # should come from session graph
-
-        self.use_hw_sliders = hw_sliders
-        self.connect_to_hw(hw_sliders)
-
-        self.make_key_hints()
-        self.make_buttons()
-
-        self.graph.addHandler(self.redraw_sliders)
-
-        self.codeWatcher = CodeWatcher(
-            onChange=lambda: self.graph.addHandler(self.redraw_sliders))
-
-        self.send_levels_loop(periodSec=.05)
-        self.graph.addHandler(self.rowFromGraph)
-
-    def make_buttons(self):
-        self.buttonframe = tk.Frame(self, bg='black')
-        self.buttonframe.pack(side=tk.BOTTOM)
-
-        self.sliders_status_var = tk.IntVar()
-        self.sliders_status_var.set(self.use_hw_sliders)
-        self.sliders_checkbutton = tk.Checkbutton(
-            self.buttonframe,
-            text="Sliders",
-            variable=self.sliders_status_var,
-            command=lambda: self.toggle_slider_connectedness(),
-            bg='black',
-            fg='white')
-        self.sliders_checkbutton.pack(side=tk.LEFT)
-
-        self.alltozerobutton = tk.Button(self.buttonframe,
-                                         text="All to Zero",
-                                         command=self.alltozero,
-                                         bg='black',
-                                         fg='white')
-        self.alltozerobutton.pack(side='left')
-
-        self.save_stage_button = tk.Button(
-            self.buttonframe,
-            text="Save",
-            command=lambda: self.save_current_stage(self.sub_name.get()),
-            bg='black',
-            fg='white')
-        self.save_stage_button.pack(side=tk.LEFT)
-        self.sub_name = tk.Entry(self.buttonframe, bg='black', fg='white')
-        self.sub_name.pack(side=tk.LEFT)
-
-    def redraw_sliders(self) -> None:
-        self.draw_sliders()
-        if len(self.rows):
-            self.change_row(self.current_row)
-            self.rows[self.current_row].focus()
-
-        self.stop_frequent_update_time = 0
-
-    def draw_sliders(self):
-        for r in self.rows:
-            r.destroy()
-        self.rows = []
-        for b in list(self.subbox.values()):
-            b.cleanup()
-        self.subbox.clear()
-        self.slider_table.clear()
-
-        self.tk_focusFollowsMouse()
-
-        rowcount = -1
-        col = 0
-        last_group = None
-
-        withgroups = []
-        for effect in self.graph.subjects(RDF.type, L9['Effect']):
-            withgroups.append((self.graph.value(effect, L9['group']),
-                               self.graph.value(effect, L9['order']),
-                               self.graph.label(effect), effect))
-        withgroups.sort()
-
-        log.debug("withgroups %s", withgroups)
-
-        self.effectEval: Dict[URIRef, light9.effect.effecteval.EffectEval] = {}
-        imp.reload(light9.effect.effecteval)
-        simpleOutputs = SimpleOutputs(self.graph)
-        for group, order, sortLabel, effect in withgroups:
-            if col == 0 or group != last_group:
-                row = self.make_row(group)
-                rowcount += 1
-                col = 0
-
-            subbox = SubmasterBox(row, self.graph, effect, self.session, col,
-                                  rowcount)
-            subbox.place(relx=col / 8, rely=0, relwidth=1 / 8, relheight=1)
-            self.subbox[effect] = self.slider_table[(rowcount, col)] = subbox
-
-            self.setup_key_nudgers(subbox.scale)
-
-            self.effectEval[effect] = light9.effect.effecteval.EffectEval(
-                self.graph, effect, simpleOutputs)
-
-            col = (col + 1) % 8
-            last_group = group
-
-    def toggle_slider_connectedness(self):
-        self.use_hw_sliders = not self.use_hw_sliders
-        if self.use_hw_sliders:
-            self.sliders.reopen()
-        else:
-            self.sliders.close()
-        self.change_row(self.current_row)
-        self.rows[self.current_row].focus()
-
-    def connect_to_hw(self, hw_sliders):
-        log.info('connect_to_hw')
-        if hw_sliders:
-            try:
-                self.sliders = Sliders(self)
-                log.info("connected to sliders")
-            except IOError as e:
-                log.info("no hardware sliders %r", e)
-                self.sliders = DummySliders()
-                self.use_hw_sliders = False
-            dispatcher.connect(self.send_to_hw, 'send_to_hw')
-        else:
-            self.sliders = DummySliders()
-
-    def make_key_hints(self):
-        keyhintrow = tk.Frame(self)
-
-        col = 0
-        for upkey, downkey in zip(nudge_keys['up'], nudge_keys['down']):
-            # what a hack!
-            downkey = downkey.replace('semicolon', ';')
-            upkey, downkey = (upkey.upper(), downkey.upper())
-
-            # another what a hack!
-            keylabel = tk.Label(keyhintrow,
-                                text='%s\n%s' % (upkey, downkey),
-                                width=1,
-                                font=('Arial', 10),
-                                bg='red',
-                                fg='white',
-                                anchor='c')
-            keylabel.pack(side=tk.LEFT, expand=1, fill=tk.X)
-            col += 1
-
-        keyhintrow.pack(fill=tk.X, expand=0)
-        self.keyhints = keyhintrow
-
-    def setup_key_nudgers(self, tkobject):
-        for d, keys in list(nudge_keys.items()):
-            for key in keys:
-                # lowercase makes full=0
-                keysym = "<KeyPress-%s>" % key
-                tkobject.bind(keysym,
-                              lambda evt, num=keys.index(key), d=d: self.
-                              got_nudger(num, d))
-
-                # uppercase makes full=1
-                keysym = "<KeyPress-%s>" % key.upper()
-                keysym = keysym.replace('SEMICOLON', 'colon')
-                tkobject.bind(keysym,
-                              lambda evt, num=keys.index(key), d=d: self.
-                              got_nudger(num, d, full=1))
-
-        # Row changing:
-        # Page dn, C-n, and ] do down
-        # Page up, C-p, and ' do up
-        for key in '<Prior> <Next> <Control-n> <Control-p> ' \
-                   '<Key-bracketright> <Key-apostrophe>'.split():
-            tkobject.bind(key, self.change_row_cb)
-
-    def change_row_cb(self, event):
-        diff = 1
-        if event.keysym in ('Prior', 'p', 'bracketright'):
-            diff = -1
-        self.change_row(self.current_row + diff)
-
-    def rowFromGraph(self):
-        self.change_row(int(
-            self.graph.value(self.session, L9['currentRow'], default=0)),
-                        fromGraph=True)
-
-    def change_row(self, row: int, fromGraph=False) -> None:
-        old_row = self.current_row
-        self.current_row = row
-        self.current_row = max(0, self.current_row)
-        self.current_row = min(len(self.rows) - 1, self.current_row)
-        try:
-            row = self.rows[self.current_row]
-        except IndexError:
-            # if we're mid-load, this row might still appear soon. If
-            # we changed interactively, the user is out of bounds and
-            # needs to be brought back in
-            if fromGraph:
-                return
-            raise
-
-        self.unhighlight_row(old_row)
-        self.highlight_row(self.current_row)
-        self.keyhints.pack_configure(before=row)
-
-        if not fromGraph:
-            self.graph.patchObject(self.session, self.session, L9['currentRow'],
-                                   Literal(self.current_row))
-
-        for col in range(1, 9):
-            try:
-                subbox = self.slider_table[(self.current_row, col - 1)]
-                self.sliders.valueOut("button-upper%d" % col, True)
-            except KeyError:
-                # unfilled bottom row has holes (plus rows with incomplete
-                # groups
-                self.sliders.valueOut("button-upper%d" % col, False)
-                self.sliders.valueOut("slider%d" % col, 0)
-                continue
-            self.send_to_hw(sub=subbox.sub, hwCol=col, boxRow=self.current_row)
-
-    def got_nudger(self, number, direction, full=0):
-        try:
-            subbox = self.slider_table[(self.current_row, number)]
-        except KeyError:
-            return
-
-        if direction == 'up':
-            if full:
-                subbox.scale.fade(1)
-            else:
-                subbox.scale.increase()
-        else:
-            if full:
-                subbox.scale.fade(0)
-            else:
-                subbox.scale.decrease()
-
-    def hw_slider_moved(self, col, value):
-        value = int(value * 100) / 100
-        try:
-            subbox = self.slider_table[(self.current_row, col)]
-        except KeyError:
-            return  # no slider assigned at that column
-
-        if hasattr(self, 'pendingHwSet'):
-            import twisted.internet.error
-            try:
-                self.pendingHwSet.cancel()
-            except twisted.internet.error.AlreadyCalled:
-                pass
-        self.pendingHwSet = reactor.callLater(.01, subbox.setVal, value)
-
-    def send_to_hw(self, sub, hwCol, boxRow):
-        if isinstance(self.sliders, DummySliders):
-            return
-
-        assert isinstance(sub, URIRef), repr(sub)
-
-        if boxRow != self.current_row:
-            return
-
-        try:
-            level = self.get_levels()[sub]
-        except KeyError:
-            log.warn("%r not in %r", sub, self.get_levels())
-            raise
-        v = round(127 * level)
-        chan = "slider%s" % hwCol
-
-        # workaround for some rounding issue, where we receive one
-        # value and then decide to send back a value that's one step
-        # lower.  -5 is a fallback for having no last value.  hopefully
-        # we won't really see it
-        if abs(v - self.sliders.lastValue.get(chan, -5)) <= 1:
-            return
-        self.sliders.valueOut(chan, v)
-
-    def make_row(self, group):
-        """group is a URI or None"""
-        row = tk.Frame(self, bd=2, bg='black')
-        row.subGroup = group
-
-        def onDrop(ev):
-            self.change_group(sub=URIRef(ev.data), row=row)
-            return "link"
-
-        dropTargetRegister(row,
-                           onDrop=onDrop,
-                           typeList=['*'],
-                           hoverStyle=dict(background="#555500"))
-
-        row.pack(expand=1, fill=tk.BOTH)
-        self.setup_key_nudgers(row)
-        self.rows.append(row)
-        return row
-
-    def change_group(self, sub, row):
-        """update this sub's group, and maybe other sub groups as needed, so
-        this sub displays in this row"""
-        group = row.subGroup
-        self.graph.patchObject(context=self.session,
-                               subject=sub,
-                               predicate=L9['group'],
-                               newObject=group)
-
-    def highlight_row(self, row):
-        row = self.rows[row]
-        row['bg'] = 'red'
-
-    def unhighlight_row(self, row):
-        row = self.rows[row]
-        row['bg'] = 'black'
-
-    def get_levels(self):
-        return dict([
-            (uri, box.getVal()) for uri, box in list(self.subbox.items())
-        ])
-
-    def get_output_settings(self, _graph=None):
-        _graph = _graph or self.graph
-        outputSettings = []
-        for setting in _graph.objects(self.session, L9['subSetting']):
-            effect = _graph.value(setting, L9['sub'])
-            strength = _graph.value(setting, L9['level'])
-            if strength:
-                now = time.time()
-                out, report = self.effectEval[effect].outputFromEffect(
-                    [(L9['strength'], strength)],
-                    songTime=now,
-                    # should be counting from when you bumped up from 0
-                    noteTime=now)
-                outputSettings.append(out)
-
-        return DeviceSettings.fromList(_graph, outputSettings)
-
-    def save_current_stage(self, subname):
-        log.info("saving current levels as %s", subname)
-        with self.graph.currentState() as g:
-            ds = self.get_output_settings(_graph=g)
-        effect = L9['effect/%s' % subname]
-        ctx = URIRef(showconfig.showUri() + '/effect/' + subname)
-        stmts = ds.statements(effect, ctx, effect + '/', set())
-        stmts.extend([
-            (effect, RDF.type, L9['Effect'], ctx),
-            (effect, RDFS.label, Literal(subname), ctx),
-            (effect, L9['publishAttr'], L9['strength'], ctx),
-        ])
-
-        self.graph.suggestPrefixes(ctx, {'eff': effect + '/'})
-        self.graph.patch(Patch(addQuads=stmts, delQuads=[]))
-
-        self.sub_name.delete(0, tk.END)
-
-    def alltozero(self):
-        for uri, subbox in list(self.subbox.items()):
-            if subbox.scale.scale_var.get() != 0:
-                subbox.scale.fade(value=0.0, length=0)
-
-
-# move to web lib
-def postArgGetter(request):
-    """return a function that takes arg names and returns string
-    values. Supports args encoded in the url or in postdata. No
-    support for repeated args."""
-    # this is something nevow normally does for me
-    request.content.seek(0)
-    fields = cgi.FieldStorage(request.content,
-                              request.received_headers,
-                              environ={'REQUEST_METHOD': 'POST'})
-
-    def getArg(n):
-        try:
-            return request.args[n][0]
-        except KeyError:
-            return fields[n].value
-
-    return getArg
-
-
-class LevelServerHttp(resource.Resource):
-    isLeaf = True
-
-    def __init__(self, name_to_subbox):
-        self.name_to_subbox = name_to_subbox
-
-    def render_POST(self, request):
-        arg = postArgGetter(request)
-
-        if request.path == '/fadesub':
-            # fadesub?subname=scoop&level=0&secs=.2
-            self.name_to_subbox[arg('subname')].scale.fade(
-                float(arg('level')), float(arg('secs')))
-            return "set %s to %s" % (arg('subname'), arg('level'))
-        else:
-            raise NotImplementedError(repr(request))
-
-
-class Sliders(BCF2000):
-
-    def __init__(self, kc):
-        devices = [
-            '/dev/snd/midiC3D0', '/dev/snd/midiC2D0', '/dev/snd/midiC1D0'
-        ]
-        for dev in devices:
-            try:
-                log.info('try sliders on %s', dev)
-                BCF2000.__init__(self, dev=dev)
-            except IOError:
-                if dev is devices[-1]:
-                    raise
-            else:
-                break
-
-        self.kc = kc
-        log.info('found sliders on %s', dev)
-
-    def valueIn(self, name, value):
-        kc = self.kc
-        if name.startswith("slider"):
-            kc.hw_slider_moved(int(name[6:]) - 1, value / 127)
-        elif name.startswith("button-upper"):
-            kc.change_row(kc.current_row)
-        elif name.startswith("button-lower"):
-            col = int(name[12:]) - 1
-            self.valueOut(name, 0)
-            try:
-                tkslider = kc.slider_table[(kc.current_row, col)]
-            except KeyError:
-                return
-
-            if tkslider.getVal() == 1.0:
-                tkslider.setVal(0.0)
-            else:
-                tkslider.setVal(1.0)
-        elif name.startswith("button-corner"):
-            button_num = int(name[13:]) - 1
-            if button_num == 1:
-                diff = -1
-            elif button_num == 3:
-                diff = 1
-            else:
-                return
-
-            kc.change_row(kc.current_row + diff)
-            self.valueOut(name, 0)
-
-
-def launch(opts: Any, root: tk.Tk, graph: SyncedGraph, session: URIRef):
-    tl = toplevelat("Keyboard Composer - %s" % opts.session,
-                    existingtoplevel=root,
-                    graph=graph,
-                    session=session)
-
-    kc = KeyboardComposer(tl, graph, session, hw_sliders=not opts.no_sliders)
-    kc.pack(fill=tk.BOTH, expand=1)
-
-    for helpline in ["Bindings: B3 mute"]:
-        tk.Label(root, text=helpline, font="Helvetica -12 italic",
-                 anchor='w').pack(side='top', fill='x')
-
-
-if __name__ == "__main__":
-    parser = OptionParser()
-    parser.add_option('--no-sliders',
-                      action='store_true',
-                      help="don't attach to hardware sliders")
-    clientsession.add_option(parser)
-    parser.add_option('-v', action='store_true', help="log info level")
-    opts, args = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
-    logging.getLogger('colormath').setLevel(logging.INFO)
-
-    graph = SyncedGraph(networking.rdfdb.url, "keyboardcomposer")
-
-    # i think this also needs delayed start (like subcomposer has), to have a valid graph
-    # before setting any stuff from the ui
-
-    root = tk.Tk()
-    initTkdnd(root.tk, 'tkdnd/trunk/')
-
-    session = clientsession.getUri('keyboardcomposer', opts)
-
-    graph.initiallySynced.addCallback(lambda _: launch(opts, root, graph,
-                                                       session))
-
-    root.protocol('WM_DELETE_WINDOW', reactor.stop)
-
-    tksupport.install(root, ms=20)
-    prof.run(reactor.run, profile=None)
--- a/bin/lightsim	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-#!bin/python
-
-import run_local
-import sys, logging
-
-sys.path.append("lib")
-import qt4reactor
-qt4reactor.install()
-
-from twisted.internet import reactor
-from twisted.internet.task import LoopingCall
-from twisted.web.xmlrpc import Proxy
-from louie import dispatcher
-from PyQt4.QtGui import QWidget, QLabel, QVBoxLayout, QHBoxLayout, QMainWindow
-from OpenGL.GL import *
-from OpenGL.GLU import *
-
-from light9 import networking, Patch, showconfig, dmxclient, updatefreq, prof
-from light9.namespaces import L9
-from lightsim.openglsim import Surface
-
-log = logging.getLogger()
-logging.basicConfig(
-    format=
-    "%(asctime)s %(levelname)-5s %(name)s %(filename)s:%(lineno)d: %(message)s")
-log.setLevel(logging.DEBUG)
-
-
-def filenamesForChan(graph, chan):
-    for lyr in graph.objects(chan, L9['previewLayer']):
-        for imgPath in graph.objects(lyr, L9['path']):
-            yield imgPath
-
-
-_lastLevels = None
-
-
-def poll(graph, serv, pollFreq, oglSurface):
-    pollFreq.update()
-    dispatcher.send("status", key="pollFreq", value=str(pollFreq))
-    d = serv.callRemote("currentlevels", dmxclient._id)
-
-    def received(dmxLevels):
-        global _lastLevels
-        if dmxLevels == _lastLevels:
-            return
-        _lastLevels = dmxLevels
-
-        level = {}  # filename : level
-        for i, lev in enumerate(dmxLevels):
-            if lev == 0:
-                continue
-
-            try:
-                chan = Patch.get_channel_uri(Patch.get_channel_name(i + 1))
-            except KeyError:
-                continue
-
-            for imgPath in filenamesForChan(graph, chan):
-                level[str(imgPath)] = lev
-
-        oglSurface.newLevels(levels=level)
-
-    d.addCallback(received)
-    return d
-
-
-class StatusKeys(QWidget):
-    """listens for dispatcher signal 'status' and displays the key/value args"""
-
-    def __init__(self, parent):
-        QWidget.__init__(self)
-        self.layout = QVBoxLayout()
-        self.setLayout(self.layout)
-        self.row = {}  # key name : (Frame, value Label)
-        dispatcher.connect(self.status, "status")
-
-    def status(self, key, value):
-        if key not in self.row:
-            row = QWidget()
-            self.layout.addWidget(row)
-            cols = QHBoxLayout()
-            row.setLayout(cols)
-            lab1 = QLabel(key)
-            lab2 = QLabel(value)
-            cols.addWidget(lab1)
-            cols.addWidget(lab2)
-            self.row[key] = lab2
-        else:
-            lab = self.row[key]
-            lab.setText(value)
-
-
-class Window(QMainWindow):
-
-    def __init__(self, filenames):
-        QMainWindow.__init__(self, None)
-        self.setWindowTitle(dmxclient._id)
-
-        w = QWidget()
-        self.setCentralWidget(w)
-        mainLayout = QVBoxLayout()
-        w.setLayout(mainLayout)
-
-        self.glWidget = Surface(self, filenames, imgRescaleTo=128 * 2)
-
-        mainLayout.addWidget(self.glWidget)
-
-        status = StatusKeys(mainLayout)
-        mainLayout.addWidget(status)
-
-
-def requiredImages(graph):
-    """filenames that we'll need to show, based on a config structure
-    like this:
-      ch:frontLeft a :Channel;
-         :previewLayer [ :path "lightsim/skyline/front-left.png" ] .
-    """
-    filenames = []
-    for lyr in graph.objects(None, L9['previewLayer']):
-        for p in graph.objects(lyr, L9['path']):
-            filenames.append(str(p))
-    return filenames
-
-
-if __name__ == '__main__':
-    app = reactor.qApp
-
-    graph = showconfig.getGraph()
-
-    window = Window(requiredImages(graph))
-    window.show()
-
-    serv = Proxy(networking.dmxServer.url)
-    pollFreq = updatefreq.Updatefreq()
-    LoopingCall(poll, graph, serv, pollFreq, window.glWidget).start(.05)
-
-    reactor.run()
--- a/bin/listsongs	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,28 +0,0 @@
-#!bin/python
-"""for completion, print the available song uris on stdout
-
-in .zshrc:
-
-function _songs { local expl;  _description files expl 'songs';  compadd "$expl[@]" - `${LIGHT9_SHOW}/../../bin/listsongs` }
-compdef _songs curvecalc
-"""
-
-from run_local import log  # noqa
-from twisted.internet import reactor
-from rdflib import RDF
-from light9 import networking
-from light9.namespaces import L9
-from rdfdb.syncedgraph import SyncedGraph
-
-graph = SyncedGraph(networking.rdfdb.url, "listsongs")
-
-
-@graph.initiallySynced.addCallback
-def printSongs(result):
-    with graph.currentState() as current:
-        for song in current.subjects(RDF.type, L9['Song']):
-            print(song)
-    reactor.stop()
-
-
-reactor.run()
--- a/bin/live	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-#!/bin/zsh
-exec pnpm exec vite -c light9/live/vite.config.ts
--- a/bin/load_test_rdfdb	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-#!bin/python
-from run_local import log
-from twisted.internet import reactor, task, defer
-from rdflib import URIRef, Literal
-from twisted.internet.defer import ensureDeferred
-from rdfdb.syncedgraph import SyncedGraph
-import time, logging
-
-from light9 import networking, showconfig
-from light9.namespaces import L9
-
-
-class BusyClient:
-
-    def __init__(self, subj, rate):
-        self.subj = subj
-        self.rate = rate
-
-        self.graph = SyncedGraph(networking.rdfdb.url, "collector")
-        self.graph.initiallySynced.addCallback(self.go)
-
-    def go(self, _):
-        task.LoopingCall(self.loop).start(1 / self.rate)
-
-    def loop(self):
-        self.graph.patchObject(showconfig.showUri() + '/loadTestContext',
-                               subject=self.subj,
-                               predicate=L9['time'],
-                               newObject=Literal(str(time.time())))
-
-
-def main():
-    log.setLevel(logging.INFO)
-
-    clients = [BusyClient(L9['loadTest_%d' % i], 20) for i in range(10)]
-    reactor.run()
-
-
-if __name__ == "__main__":
-    main()
--- a/bin/midifade	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-#!/bin/sh
-exec pdm run python light9/midifade/midifade.py
-
-
--- a/bin/movesinks	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,14 +0,0 @@
-#!/bin/bash 
-
-# from http://askubuntu.com/questions/71863/how-to-change-pulseaudio-sink-with-pacmd-set-default-sink-during-playback/113322#113322
-
-echo "Setting default sink to: $1";
-pacmd set-default-sink $1
-pacmd list-sink-inputs | grep index | while read line
-do
-echo "Moving input: ";
-echo $line | cut -f2 -d' ';
-echo "to sink: $1";
-pacmd move-sink-input `echo $line | cut -f2 -d' '` $1
-
-done
--- a/bin/mpd_timing_test	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#!/usr/bin/python
-"""
-records times coming out of ascoltami
-
-for example:
-
- % mpd_timing_test > timing
- # play some music in ascoltami, then ctrl-c
- % gnuplot
- > plot "timing" with lines
-
-"""
-
-import xmlrpc.client, time
-
-s = xmlrpc.client.ServerProxy("http://localhost:8040")
-start = time.time()
-while True:
-    print(time.time() - start, s.gettime())
-    time.sleep(.01)
--- a/bin/musictime	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-#!bin/python
-import run_local  # noqa
-import light9.networking
-
-import tkinter as tk
-from twisted.internet import reactor, tksupport, task
-
-from light9.ascoltami.musictime_client import MusicTime
-
-mt = MusicTime()
-
-
-class MusicTimeTk(tk.Frame, MusicTime):
-
-    def __init__(self, master, url):
-        tk.Frame.__init__(self)
-        MusicTime.__init__(self, url)
-        self.timevar = tk.DoubleVar()
-        self.timelabel = tk.Label(self,
-                                  textvariable=self.timevar,
-                                  bd=2,
-                                  relief='raised',
-                                  width=10,
-                                  padx=2,
-                                  pady=2,
-                                  anchor='w')
-        self.timelabel.pack(expand=1, fill='both')
-
-        def print_time(evt, *args):
-            self.timevar.set(mt.getLatest().get('t', 0))
-            print(self.timevar.get(), evt.keysym)
-
-        self.timelabel.bind('<KeyPress>', print_time)
-        self.timelabel.bind('<1>', print_time)
-        self.timelabel.focus()
-        task.LoopingCall(self.update_time).start(.1)
-
-    def update_time(self):
-        t = self.getLatest().get('t', 0)
-        self.timevar.set(t)
-
-
-if __name__ == "__main__":
-    from optparse import OptionParser
-    parser = OptionParser()
-    parser.add_option("-u", "--url", default=light9.networking.musicPlayer.url)
-    options, args = parser.parse_args()
-
-    root = tk.Tk()
-    root.title("Time")
-    MusicTimeTk(root, options.url).pack(expand=1, fill='both')
-    tksupport.install(root, ms=20)
-    reactor.run()
--- a/bin/paintserver	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,126 +0,0 @@
-#!bin/python
-
-from run_local import log
-import json
-from twisted.internet import reactor
-from rdfdb.syncedgraph import SyncedGraph
-from light9 import networking, showconfig
-import optparse, sys, logging
-import cyclone.web
-from rdflib import URIRef
-from light9 import clientsession
-import light9.paint.solve
-from cycloneerr import PrettyErrorHandler
-from light9.namespaces import L9, DEV
-from light9.metrics import metrics
-import imp
-
-
-class Solve(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def post(self):
-        painting = json.loads(self.request.body)
-        with metrics('solve').time():
-            img = self.settings.solver.draw(painting)
-            sample, sampleDist = self.settings.solver.bestMatch(
-                img, device=DEV['aura2'])
-            with self.settings.graph.currentState() as g:
-                bestPath = g.value(sample, L9['imagePath']).replace(L9[''], '')
-            #out = solver.solve(painting)
-            #layers = solver.simulationLayers(out)
-
-        self.write(
-            json.dumps({
-                'bestMatch': {
-                    'uri': sample,
-                    'path': bestPath,
-                    'dist': sampleDist
-                },
-                #    'layers': layers,
-                #    'out': out,
-            }))
-
-    def reloadSolver(self):
-        imp.reload(light9.paint.solve)
-        self.settings.solver = light9.paint.solve.Solver(self.settings.graph)
-        self.settings.solver.loadSamples()
-
-
-class BestMatches(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def post(self):
-        body = json.loads(self.request.body)
-        painting = body['painting']
-        devs = [URIRef(d) for d in body['devices']]
-        with metrics('solve').time():
-            img = self.settings.solver.draw(painting)
-            outSettings = self.settings.solver.bestMatches(img, devs)
-            self.write(json.dumps({'settings': outSettings.asList()}))
-
-
-class App:
-
-    def __init__(self, show, session):
-        self.show = show
-        self.session = session
-
-        self.graph = SyncedGraph(networking.rdfdb.url, "paintServer")
-        self.graph.initiallySynced.addCallback(self.launch).addErrback(
-            log.error)
-
-
-    def launch(self, *args):
-
-        self.solver = light9.paint.solve.Solver(
-            self.graph,
-            sessions=[
-                L9['show/dance2017/capture/aura1/cap1876596'],
-                L9['show/dance2017/capture/aura2/cap1876792'],
-                L9['show/dance2017/capture/aura3/cap1877057'],
-                L9['show/dance2017/capture/aura4/cap1877241'],
-                L9['show/dance2017/capture/aura5/cap1877406'],
-                L9['show/dance2017/capture/q1/cap1874255'],
-                L9['show/dance2017/capture/q2/cap1873665'],
-                L9['show/dance2017/capture/q3/cap1876223'],
-            ])
-        self.solver.loadSamples()
-
-        self.cycloneApp = cyclone.web.Application(handlers=[
-            (r'/solve', Solve),
-            (r'/bestMatches', BestMatches),
-            metricsRoute(),
-        ],
-                                                  debug=True,
-                                                  graph=self.graph,
-                                                  solver=self.solver)
-        reactor.listenTCP(networking.paintServer.port, self.cycloneApp)
-        log.info("listening on %s" % networking.paintServer.port)
-
-
-if __name__ == "__main__":
-    parser = optparse.OptionParser()
-    parser.add_option(
-        '--show',
-        help='show URI, like http://light9.bigasterisk.com/show/dance2008',
-        default=showconfig.showUri())
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    parser.add_option("--twistedlog",
-                      action="store_true",
-                      help="twisted logging")
-    clientsession.add_option(parser)
-    (options, args) = parser.parse_args()
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-    if not options.show:
-        raise ValueError("missing --show http://...")
-
-    session = clientsession.getUri('paint', options)
-
-    app = App(URIRef(options.show), session)
-    if options.twistedlog:
-        from twisted.python import log as twlog
-        twlog.startLogging(sys.stderr)
-    reactor.run()
--- a/bin/patchserver	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-#!bin/python
-
-from run_local import log
-
-from rdflib import URIRef
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks, Deferred
-
-import logging
-import optparse
-import os
-import time
-import treq
-import cyclone.web, cyclone.websocket, cyclone.httpclient
-
-from cycloneerr import PrettyErrorHandler
-
-from light9.namespaces import L9, RDF
-from light9 import networking, showconfig
-from rdfdb.syncedgraph import SyncedGraph
-
-from light9.effect.settings import DeviceSettings
-from rdfdb.patch import Patch
-from light9.metrics import metrics, metricsRoute
-
-
-
-def launch(graph):
-    if 0:
-        reactor.listenTCP(
-            networking.captureDevice.port,
-            cyclone.web.Application(handlers=[
-                (r'/()', cyclone.web.StaticFileHandler, {
-                    "path": "light9/web",
-                    "default_filename": "patchServer.html"
-                }),
-                metricsRoute(),
-            ]),
-            interface='::',
-        )
-        log.info('serving http on %s', networking.captureDevice.port)
-
-    def prn():
-        width = {}
-        for dc in graph.subjects(RDF.type, L9['DeviceClass']):
-            for attr in graph.objects(dc, L9['attr']):
-                width[dc] = max(
-                    width.get(dc, 0),
-                    graph.value(attr, L9['dmxOffset']).toPython() + 1)
-
-        user = {}  # chan: [dev]
-        for dev in set(graph.subjects(L9['dmxBase'], None)):
-            dc = graph.value(dev, RDF.type)
-            base = graph.value(dev, L9['dmxBase']).toPython()
-            for offset in range(0, width[dc]):
-                chan = base + offset
-                user.setdefault(chan, []).append(dev)
-
-        for chan in range(1, max(user) + 1):
-            dev = user.get(chan, None)
-            print(f'chan {chan} used by {dev}')
-
-    graph.addHandler(prn)
-
-
-def main():
-    parser = optparse.OptionParser()
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    (options, args) = parser.parse_args()
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-    graph = SyncedGraph(networking.rdfdb.url, "captureDevice")
-
-    graph.initiallySynced.addCallback(lambda _: launch(graph)).addErrback(
-        log.error)
-    reactor.run()
-
-
-if __name__ == '__main__':
-    main()
--- a/bin/picamserve	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,204 +0,0 @@
-#!env_pi/bin/python
-
-from run_local import log
-import sys
-sys.path.append('/usr/lib/python2.7/dist-packages/')
-import io, logging, traceback, time
-import cyclone.web
-from twisted.internet import reactor, threads
-from twisted.internet.defer import inlineCallbacks
-from light9 import prof
-
-try:
-    import picamera
-    cameraCls = picamera.PiCamera
-except ImportError:
-
-    class cameraCls(object):
-
-        def __enter__(self):
-            return self
-
-        def __exit__(self, *a):
-            pass
-
-        def capture(self, out, *a, **kw):
-            out.write(open('yuv.demo').read())
-
-        def capture_continuous(self, *a, **kw):
-            for i in range(1000):
-                time.sleep(1)
-                yield str(i)
-
-
-def setCameraParams(c, arg):
-    res = int(arg('res', 480))
-    c.resolution = {
-        480: (640, 480),
-        1080: (1920, 1080),
-        1944: (2592, 1944),
-    }[res]
-    c.shutter_speed = int(arg('shutter', 50000))
-    c.exposure_mode = arg('exposure_mode', 'fixedfps')
-    c.awb_mode = arg('awb_mode', 'off')
-    c.brightness = int(arg('brightness', 50))
-    c.exposure_compensation = int(arg('exposure_compensation', 0))
-    c.awb_gains = (float(arg('redgain', 1)), float(arg('bluegain', 1)))
-    c.ISO = int(arg('iso', 250))
-    c.rotation = int(arg('rotation', '0'))
-
-
-def setupCrop(c, arg):
-    c.crop = (float(arg('x', 0)), float(arg('y', 0)), float(arg('w', 1)),
-              float(arg('h', 1)))
-    rw = rh = int(arg('resize', 100))
-    # width 1920, showing w=.3 of image, resize=100 -> scale is 100/.3*1920
-    # scl is [ output px / camera px ]
-    scl1 = rw / (c.crop[2] * c.resolution[0])
-    scl2 = rh / (c.crop[3] * c.resolution[1])
-    if scl1 < scl2:
-        # width is the constraint; reduce height to the same scale
-        rh = int(scl1 * c.crop[3] * c.resolution[1])
-    else:
-        # height is the constraint
-        rw = int(scl2 * c.crop[2] * c.resolution[0])
-    return rw, rh
-
-
-@prof.logTime
-def getFrame(c, arg):
-    setCameraParams(c, arg)
-    resize = setupCrop(c, arg)
-    out = io.BytesIO('w')
-    prof.logTime(c.capture)(out, 'jpeg', use_video_port=True, resize=resize)
-    return out.getvalue()
-
-
-class Pic(cyclone.web.RequestHandler):
-
-    def get(self):
-        try:
-            self.set_header('Content-Type', 'image/jpeg')
-            self.write(getFrame(self.settings.camera, self.get_argument))
-        except Exception:
-            traceback.print_exc()
-
-
-def captureContinuousAsync(c, resize, onFrame):
-    """
-    Calls c.capture_continuous is called in another thread. onFrame is
-    called in this reactor thread with each (frameTime, frame)
-    result. Runs until onFrame raises StopIteration.
-    """
-
-    def runner(c, resize):
-        stream = io.BytesIO()
-        t = time.time()
-        for nextFrame in c.capture_continuous(stream,
-                                              'jpeg',
-                                              use_video_port=True,
-                                              resize=resize):
-            t2 = time.time()
-            log.debug(" - framecap got %s bytes in %.1f ms",
-                      len(stream.getvalue()), 1000 * (t2 - t))
-            try:
-                # This is slow, like 13ms. Hopefully
-                # capture_continuous is working on gathering the next
-                # pic during this time instead of pausing.
-                # Instead, we could be stashing frames onto a queue or
-                # something that the main thread can pull when
-                # possible (and toss if it gets behind).
-                threads.blockingCallFromThread(reactor, onFrame, t,
-                                               stream.getvalue())
-            except StopIteration:
-                break
-            t3 = time.time()
-            log.debug(" - sending to onFrame took %.1fms", 1000 * (t3 - t2))
-            stream.truncate()
-            stream.seek(0)
-            t = time.time()
-
-    return threads.deferToThread(runner, c, resize)
-
-
-class FpsReport(object):
-
-    def __init__(self):
-        self.frameTimes = []
-        self.lastFpsLog = 0
-
-    def frame(self):
-        now = time.time()
-
-        self.frameTimes.append(now)
-
-        if len(self.frameTimes) > 15:
-            del self.frameTimes[:5]
-
-        if now > self.lastFpsLog + 2 and len(self.frameTimes) > 5:
-            deltas = [(b - a)
-                      for a, b in zip(self.frameTimes[:-1], self.frameTimes[1:])
-                     ]
-            avg = sum(deltas) / len(deltas)
-            log.info("fps: %.1f", 1 / avg)
-            self.lastFpsLog = now
-
-
-class Pics(cyclone.web.RequestHandler):
-
-    @inlineCallbacks
-    def get(self):
-        try:
-            self.set_header('Content-Type', 'x-application/length-time-jpeg')
-            c = self.settings.camera
-            setCameraParams(c, self.get_argument)
-            resize = setupCrop(c, self.get_argument)
-
-            self.running = True
-            log.info("connection open from %s", self.request.remote_ip)
-            fpsReport = FpsReport()
-
-            def onFrame(frameTime, frame):
-                if not self.running:
-                    raise StopIteration
-
-                self.write("%s %s\n" % (len(frame), frameTime))
-                self.write(frame)
-                self.flush()
-
-                fpsReport.frame()
-
-            # another camera request coming in at the same time breaks
-            # the server. it would be nice if this request could
-            # let-go-and-reopen when it knows about another request
-            # coming in
-            yield captureContinuousAsync(c, resize, onFrame)
-        except Exception:
-            traceback.print_exc()
-
-    def on_connection_close(self, *a, **kw):
-        log.info("connection closed")
-        self.running = False
-
-
-log.setLevel(logging.INFO)
-
-with cameraCls() as camera:
-    port = 8208
-    reactor.listenTCP(
-        port,
-        cyclone.web.Application(handlers=[
-            (r'/pic', Pic),
-            (r'/pics', Pics),
-            (r'/static/(.*)', cyclone.web.StaticFileHandler, {
-                'path': 'light9/web/'
-            }),
-            (r'/(|gui.js)', cyclone.web.StaticFileHandler, {
-                'path': 'light9/vidref/',
-                'default_filename': 'index.html'
-            }),
-        ],
-                                debug=True,
-                                camera=camera))
-    log.info("serving on %s" % port)
-    reactor.run()
--- a/bin/pytest	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-#!/bin/sh
-exec pdm run pytest "$@"
--- a/bin/python	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-#!/bin/sh
-PYTHONPATH=. pdm run python3 "$@"
--- a/bin/run_local.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,8 +0,0 @@
-# this file can go away when all the bin/* are just launchers and everyone uses light9/run_local
-
-import sys
-
-# to support 'import light9'
-sys.path.append('.')
-
-from light9.run_local import log
--- a/bin/staticclient	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-#!bin/python
-"""
-push a dmx level forever
-"""
-
-import time, logging
-from optparse import OptionParser
-import logging, urllib.request, urllib.parse, urllib.error
-from twisted.internet import reactor, tksupport, task
-from rdflib import URIRef, RDF, RDFS, Literal
-
-from run_local import log
-log.setLevel(logging.DEBUG)
-
-from light9 import dmxclient, showconfig, networking
-
-if __name__ == "__main__":
-    parser = OptionParser(usage="%prog")
-    parser.add_option('--chan', help='channel number, starts at 1',
-                      type=int)  #todo: or name or uri
-    parser.add_option('--level', help='0..1', type=float)
-    parser.add_option('-v', action='store_true', help="log debug level")
-
-    opts, args = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
-
-    levels = [0] * (opts.chan - 1) + [opts.level]
-    log.info('staticclient will write this forever: %r', levels)
-
-    def write():
-        log.debug('writing %r', levels)
-        dmxclient.outputlevels(levels, twisted=1)
-
-    log.info('looping...')
-    task.LoopingCall(write).start(1)
-    reactor.run()
--- a/bin/subcomposer	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,314 +0,0 @@
-#!bin/python
-"""
-subcomposer
-  session
-  observable(currentSub), a Submaster which tracks the graph
-
-    EditChoice widget
-      can change currentSub to another sub
-
-    Levelbox widget
-      watch observable(currentSub) for a new sub, and also watch currentSub for edits to push to the OneLevel widgets
-
-        OneLevel widget
-          UI edits are caught here and go all the way back to currentSub
-
-
-"""
-
-from run_local import log
-import time, logging
-
-log.setLevel(logging.DEBUG)
-
-from optparse import OptionParser
-import logging, urllib.request, urllib.parse, urllib.error
-import tkinter as tk
-import louie as dispatcher
-from twisted.internet import reactor, tksupport, task
-from rdflib import URIRef, RDF, RDFS, Literal
-
-from light9.dmxchanedit import Levelbox
-from light9 import dmxclient, Submaster, prof, showconfig, networking
-from light9.Patch import get_channel_name
-from light9.uihelpers import toplevelat
-from rdfdb.syncedgraph import SyncedGraph
-from light9 import clientsession
-from light9.tkdnd import initTkdnd
-from light9.namespaces import L9
-from rdfdb.patch import Patch
-from light9.observable import Observable
-from light9.editchoice import EditChoice, Local
-from light9.subcomposer import subcomposerweb
-
-
-class Subcomposer(tk.Frame):
-    """
-    <session> l9:currentSub ?sub is the URI of the sub we're tied to for displaying and
-    editing. If we don't have a currentSub, then we're actually
-    editing a session-local sub called <session> l9:currentSub <sessionLocalSub>
-
-    I'm not sure that Locals should even be PersistentSubmaster with
-    uri and graph storage, but I think that way is making fewer
-    special cases.
-
-    Contains an EditChoice widget
-
-    Dependencies:
-
-      graph (?session :currentSub ?s) -> self.currentSub
-      self.currentSub -> graph
-      self.currentSub -> self._currentChoice (which might be Local)
-      self._currentChoice (which might be Local) -> self.currentSub
-
-      inside the current sub:
-      graph -> Submaster levels (handled in Submaster)
-      Submaster levels -> OneLevel widget
-      OneLevel widget -> Submaster.editLevel
-      Submaster.editLevel -> graph (handled in Submaster)
-
-    """
-
-    def __init__(self, master, graph, session):
-        tk.Frame.__init__(self, master, bg='black')
-        self.graph = graph
-        self.session = session
-        self.launchTime = time.time()
-        self.localSerial = 0
-
-        # this is a URIRef or Local. Strangely, the Local case still
-        # has a uri, which you can get from
-        # self.currentSub.uri. Probably that should be on the Local
-        # object too, or maybe Local should be a subclass of URIRef
-        self._currentChoice = Observable(Local)
-
-        # this is a PersistentSubmaster (even for local)
-        self.currentSub = Observable(
-            Submaster.PersistentSubmaster(graph, self.switchToLocal()))
-
-        def pc(val):
-            log.info("change viewed sub to %s", val)
-
-        self._currentChoice.subscribe(pc)
-
-        ec = self.editChoice = EditChoice(self, self.graph, self._currentChoice)
-        ec.frame.pack(side='top')
-
-        ec.subIcon.bind("<ButtonPress-1>", self.clickSubIcon)
-        self.setupSubChoiceLinks()
-        self.setupLevelboxUi()
-
-    def clickSubIcon(self, *args):
-        box = tk.Toplevel(self.editChoice.frame)
-        box.wm_transient(self.editChoice.frame)
-        tk.Label(box, text="Name this sub:").pack()
-        e = tk.Entry(box)
-        e.pack()
-        b = tk.Button(box, text="Make global")
-        b.pack()
-
-        def clicked(*args):
-            self.makeGlobal(newName=e.get())
-            box.destroy()
-
-        b.bind("<Button-1>", clicked)
-        e.focus()
-
-    def makeGlobal(self, newName):
-        """promote our local submaster into a non-local, named one"""
-        uri = self.currentSub().uri
-        newUri = showconfig.showUri() + ("/sub/%s" %
-                                         urllib.parse.quote(newName, safe=''))
-        with self.graph.currentState(tripleFilter=(uri, None, None)) as current:
-            if (uri, RDF.type, L9['LocalSubmaster']) not in current:
-                raise ValueError("%s is not a local submaster" % uri)
-            if (newUri, None, None) in current:
-                raise ValueError("new uri %s is in use" % newUri)
-
-        # the local submaster was storing in ctx=self.session, but now
-        # we want it to be in ctx=uri
-
-        self.relocateSub(newUri, newName)
-
-        # these are in separate patches for clarity as i'm debugging this
-        self.graph.patch(
-            Patch(addQuads=[
-                (newUri, RDFS.label, Literal(newName), newUri),
-            ],
-                  delQuads=[
-                      (newUri, RDF.type, L9['LocalSubmaster'], newUri),
-                  ]))
-        self.graph.patchObject(self.session, self.session, L9['currentSub'],
-                               newUri)
-
-    def relocateSub(self, newUri, newName):
-        # maybe this goes in Submaster
-        uri = self.currentSub().uri
-
-        def repl(u):
-            if u == uri:
-                return newUri
-            return u
-
-        delQuads = self.currentSub().allQuads()
-        addQuads = [(repl(s), p, repl(o), newUri) for s, p, o, c in delQuads]
-        # patch can't span contexts yet
-        self.graph.patch(Patch(addQuads=addQuads, delQuads=[]))
-        self.graph.patch(Patch(addQuads=[], delQuads=delQuads))
-
-    def setupSubChoiceLinks(self):
-        graph = self.graph
-
-        def ann():
-            print("currently: session=%s currentSub=%r _currentChoice=%r" %
-                  (self.session, self.currentSub(), self._currentChoice()))
-
-        @graph.addHandler
-        def graphChanged():
-            # some bug where SC is making tons of graph edits and many
-            # are failing. this calms things down.
-            log.warn('skip graphChanged')
-            return
-
-            s = graph.value(self.session, L9['currentSub'])
-            log.debug('HANDLER getting session currentSub from graph: %s', s)
-            if s is None:
-                s = self.switchToLocal()
-            self.currentSub(Submaster.PersistentSubmaster(graph, s))
-
-        @self.currentSub.subscribe
-        def subChanged(newSub):
-            log.debug('HANDLER currentSub changed to %s', newSub)
-            if newSub is None:
-                graph.patchObject(self.session, self.session, L9['currentSub'],
-                                  None)
-                return
-            self.sendupdate()
-            graph.patchObject(self.session, self.session, L9['currentSub'],
-                              newSub.uri)
-
-            localStmt = (newSub.uri, RDF.type, L9['LocalSubmaster'])
-            with graph.currentState(tripleFilter=localStmt) as current:
-                if newSub and localStmt in current:
-                    log.debug('  HANDLER set _currentChoice to Local')
-                    self._currentChoice(Local)
-                else:
-                    # i think right here is the point that the last local
-                    # becomes garbage, and we could clean it up.
-                    log.debug('  HANDLER set _currentChoice to newSub.uri')
-                    self._currentChoice(newSub.uri)
-
-        dispatcher.connect(self.levelsChanged, "sub levels changed")
-
-        @self._currentChoice.subscribe
-        def choiceChanged(newChoice):
-            log.debug('HANDLER choiceChanged to %s', newChoice)
-            if newChoice is Local:
-                newChoice = self.switchToLocal()
-            if newChoice is not None:
-                newSub = Submaster.PersistentSubmaster(graph, newChoice)
-                log.debug('write new choice to currentSub, from %r to %r',
-                          self.currentSub(), newSub)
-                self.currentSub(newSub)
-
-    def levelsChanged(self, sub):
-        if sub == self.currentSub():
-            self.sendupdate()
-
-    def switchToLocal(self):
-        """
-        change our display to a local submaster
-        """
-        # todo: where will these get stored, or are they local to this
-        # subcomposer process and don't use PersistentSubmaster at all?
-        localId = "%s-%s" % (self.launchTime, self.localSerial)
-        self.localSerial += 1
-        new = URIRef("http://light9.bigasterisk.com/sub/local/%s" % localId)
-        log.debug('making up a local sub %s', new)
-        self.graph.patch(
-            Patch(addQuads=[
-                (new, RDF.type, L9['Submaster'], self.session),
-                (new, RDF.type, L9['LocalSubmaster'], self.session),
-            ]))
-
-        return new
-
-    def setupLevelboxUi(self):
-        self.levelbox = Levelbox(self, self.graph, self.currentSub)
-        self.levelbox.pack(side='top')
-
-        tk.Button(
-            self,
-            text="All to zero",
-            command=lambda *args: self.currentSub().clear()).pack(side='top')
-
-    def savenewsub(self, subname):
-        leveldict = {}
-        for i, lev in zip(list(range(len(self.levels))), self.levels):
-            if lev != 0:
-                leveldict[get_channel_name(i + 1)] = lev
-
-        s = Submaster.Submaster(subname, levels=leveldict)
-        s.save()
-
-    def sendupdate(self):
-        d = self.currentSub().get_dmx_list()
-        dmxclient.outputlevels(d, twisted=True)
-
-
-def launch(opts, args, root, graph, session):
-    if not opts.no_geometry:
-        toplevelat("subcomposer - %s" % opts.session, root, graph, session)
-
-    sc = Subcomposer(root, graph, session)
-    sc.pack()
-
-    subcomposerweb.init(graph, session, sc.currentSub)
-
-    tk.Label(root,
-             text="Bindings: B1 adjust level; B2 set full; B3 instant bump",
-             font="Helvetica -12 italic",
-             anchor='w').pack(side='top', fill='x')
-
-    if len(args) == 1:
-        # it might be a little too weird that cmdline arg to this
-        # process changes anything else in the same session. But also
-        # I'm not sure who would ever make 2 subcomposers of the same
-        # session (except when quitting and restarting, to get the
-        # same window pos), so maybe it doesn't matter. But still,
-        # this tool should probably default to making new sessions
-        # usually instead of loading the same one
-        graph.patchObject(session, session, L9['currentSub'], URIRef(args[0]))
-
-    task.LoopingCall(sc.sendupdate).start(10)
-
-
-#############################
-
-if __name__ == "__main__":
-    parser = OptionParser(usage="%prog [suburi]")
-    parser.add_option('--no-geometry',
-                      action='store_true',
-                      help="don't save/restore window geometry")
-    parser.add_option('-v', action='store_true', help="log debug level")
-
-    clientsession.add_option(parser)
-    opts, args = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if opts.v else logging.INFO)
-
-    root = tk.Tk()
-    root.config(bg='black')
-    root.tk_setPalette("#004633")
-
-    initTkdnd(root.tk, 'tkdnd/trunk/')
-
-    graph = SyncedGraph(networking.rdfdb.url, "subcomposer")
-    session = clientsession.getUri('subcomposer', opts)
-
-    graph.initiallySynced.addCallback(lambda _: launch(opts, args, root, graph,
-                                                       session))
-
-    root.protocol('WM_DELETE_WINDOW', reactor.stop)
-    tksupport.install(root, ms=10)
-    prof.run(reactor.run, profile=False)
--- a/bin/subserver	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!bin/python
-"""
-live web display of all existing subs with pictures, mainly for
-dragging them into CC or Timeline
-"""
-from run_local import log
-import optparse, logging, json, subprocess, datetime
-from dateutil.tz import tzlocal
-from twisted.internet import reactor, defer
-import cyclone.web, cyclone.httpclient, cyclone.websocket
-from rdflib import URIRef, Literal
-import pyjade.utils
-from rdfdb.syncedgraph import SyncedGraph
-from rdfdb.patch import Patch
-from light9.namespaces import L9, DCTERMS
-from light9 import networking, showconfig
-
-from cycloneerr import PrettyErrorHandler
-
-
-class Static(PrettyErrorHandler, cyclone.web.StaticFileHandler):
-
-    def get(self, path, *args, **kw):
-        if path in ['', 'effects']:
-            return self.respondStaticJade("light9/subserver/%s.jade" %
-                                          (path or 'index'))
-
-        if path.endswith(".js"):
-            return self.responseStaticCoffee(
-                'light9/subserver/%s' %
-                path.replace(".js", ".coffee"))  # potential security hole
-
-        cyclone.web.StaticFileHandler.get(self, path, *args, **kw)
-
-    def respondStaticJade(self, src):
-        html = pyjade.utils.process(open(src).read())
-        self.write(html)
-
-    def responseStaticCoffee(self, src):
-        self.write(
-            subprocess.check_output(
-                ['/usr/bin/coffee', '--compile', '--print', src]))
-
-
-class Snapshot(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    @defer.inlineCallbacks
-    def post(self):
-        about = URIRef(self.get_argument("about"))
-        response = yield cyclone.httpclient.fetch(
-            networking.vidref.path("snapshot"), method="POST", timeout=1)
-
-        snapUri = URIRef(json.loads(response.body)['snapshot'])
-        # vidref could write about when it was taken, etc. would it be
-        # better for us to tell vidref where to attach the result in
-        # the graph, and then it doesn't even have to return anything?
-
-        ctx = showconfig.showUri() + "/snapshots"
-
-        self.settings.graph.patch(
-            Patch(addQuads=[
-                (about, L9['image'], snapUri, ctx),
-                (snapUri, DCTERMS['created'],
-                 Literal(datetime.datetime.now(tzlocal())), ctx),
-            ]))
-
-        self.write(json.dumps({'snapshot': snapUri}))
-
-
-def newestImage(subject):
-    newest = (None, None)
-    for img in graph.objects(subject, L9['image']):
-        created = graph.value(img, DCTERMS['created'])
-        if created > newest[0]:
-            newest = (created, img)
-    return newest[1]
-
-
-if __name__ == "__main__":
-    parser = optparse.OptionParser()
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    (options, args) = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-    graph = SyncedGraph(networking.rdfdb.url, "subServer")
-
-    port = networking.subServer.port
-    reactor.listenTCP(
-        port,
-        cyclone.web.Application(handlers=[
-            (r'/snapshot', Snapshot),
-            (r'/(.*)', Static, {
-                "path": "light9/subserver",
-                "default_filename": "index.jade"
-            }),
-        ],
-                                debug=True,
-                                graph=graph))
-    log.info("serving on %s" % port)
-    reactor.run()
--- a/bin/timeline	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-#!/bin/zsh
-pnpm exec vite -c light9/web/timeline/vite.config.ts &
-wait
--- a/bin/tkdnd_minimal_drop.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,56 +0,0 @@
-#!bin/python
-from run_local import log
-import tkinter as tk
-from light9.tkdnd import initTkdnd, dropTargetRegister
-from twisted.internet import reactor, tksupport
-
-root = tk.Tk()
-initTkdnd(root.tk, "tkdnd/trunk/")
-label = tk.Label(root, borderwidth=2, relief='groove', padx=10, pady=10)
-label.pack()
-label.config(text="drop target %s" % label._w)
-
-frame1 = tk.Frame()
-frame1.pack()
-
-labelInner = tk.Label(frame1, borderwidth=2, relief='groove', padx=10, pady=10)
-labelInner.pack(side='left')
-labelInner.config(text="drop target inner %s" % labelInner._w)
-tk.Label(frame1, text="not a target").pack(side='left')
-
-
-def onDrop(ev):
-    print("onDrop", ev)
-
-
-def enter(ev):
-    print('enter', ev)
-
-
-def leave(ev):
-    print('leave', ev)
-
-
-dropTargetRegister(label,
-                   onDrop=onDrop,
-                   onDropEnter=enter,
-                   onDropLeave=leave,
-                   hoverStyle=dict(background="yellow", relief='groove'))
-
-dropTargetRegister(labelInner,
-                   onDrop=onDrop,
-                   onDropEnter=enter,
-                   onDropLeave=leave,
-                   hoverStyle=dict(background="yellow", relief='groove'))
-
-
-def prn():
-    print("cont", root.winfo_containing(201, 151))
-
-
-b = tk.Button(root, text="coord", command=prn)
-b.pack()
-
-#tk.mainloop()
-tksupport.install(root, ms=10)
-reactor.run()
--- a/bin/tracker	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,311 +0,0 @@
-#!/usr/bin/python
-
-import sys
-sys.path.append("../../editor/pour")
-sys.path.append("../light8")
-
-from Submaster import Submaster
-from skim.zooming import Zooming, Pair
-from math import sqrt, sin, cos
-from pygame.rect import Rect
-from xmlnodebase import xmlnodeclass, collectiveelement, xmldocfile
-from dispatch import dispatcher
-
-import dmxclient
-
-import tkinter as tk
-
-defaultfont = "arial 8"
-
-
-def pairdist(pair1, pair2):
-    return pair1.dist(pair2)
-
-
-def canvashighlighter(canvas, obj, attribute, normalval, highlightval):
-    """creates bindings on a canvas obj that make attribute go
-    from normal to highlight when the mouse is over the obj"""
-    canvas.tag_bind(
-        obj, "<Enter>", lambda ev: canvas.itemconfig(
-            obj, **{attribute: highlightval}))
-    canvas.tag_bind(
-        obj,
-        "<Leave>", lambda ev: canvas.itemconfig(obj, **{attribute: normalval}))
-
-
-class Field(xmlnodeclass):
-    """one light has a field of influence. for any point on the
-    canvas, you can ask this field how strong it is. """
-
-    def name(self, newval=None):
-        """light/sub name"""
-        return self._getorsetattr("name", newval)
-
-    def center(self, x=None, y=None):
-        """x,y float coords for the center of this light in the field. returns
-        a Pair, although it accepts x,y"""
-        return Pair(self._getorsettypedattr("x", float, x),
-                    self._getorsettypedattr("y", float, y))
-
-    def falloff(self, dist=None):
-        """linear falloff from 1 at center, to 0 at dist pixels away
-        from center"""
-        return self._getorsettypedattr("falloff", float, dist)
-
-    def getdistforintensity(self, intens):
-        """returns the distance you'd have to be for the given intensity (0..1)"""
-        return (1 - intens) * self.falloff()
-
-    def calc(self, x, y):
-        """returns field strength at point x,y"""
-        dist = pairdist(Pair(x, y), self.center())
-        return max(0, (self.falloff() - dist) / self.falloff())
-
-
-class Fieldset(collectiveelement):
-    """group of fields. persistent."""
-
-    def childtype(self):
-        return Field
-
-    def version(self):
-        """read-only version attribute on fieldset tag"""
-        return self._getorsetattr("version", None)
-
-    def report(self, x, y):
-        """reports active fields and their intensities"""
-        active = 0
-        for f in self.getall():
-            name = f.name()
-            intens = f.calc(x, y)
-            if intens > 0:
-                print(name, intens, end=' ')
-                active += 1
-        if active > 0:
-            print()
-        self.dmxsend(x, y)
-
-    def dmxsend(self, x, y):
-        """output lights to dmx"""
-        levels = dict([(f.name(), f.calc(x, y)) for f in self.getall()])
-        dmxlist = Submaster(None, levels).get_dmx_list()
-        dmxclient.outputlevels(dmxlist)
-
-    def getbounds(self):
-        """returns xmin,xmax,ymin,ymax for the non-zero areas of this field"""
-        r = None
-        for f in self.getall():
-            rad = f.getdistforintensity(0)
-            fx, fy = f.center()
-            fieldrect = Rect(fx - rad, fy - rad, rad * 2, rad * 2)
-            if r is None:
-                r = fieldrect
-            else:
-                r = r.union(fieldrect)
-        return r.left, r.right, r.top, r.bottom
-
-
-class Fieldsetfile(xmldocfile):
-
-    def __init__(self, filename):
-        self._openornew(filename, topleveltype=Fieldset)
-
-    def fieldset(self):
-        return self._gettoplevel()
-
-
-########################################################################
-########################################################################
-
-
-class FieldDisplay:
-    """the view for a Field."""
-
-    def __init__(self, canvas, field):
-        self.canvas = canvas
-        self.field = field
-        self.tags = [str(id(self))]  # canvas tag to id our objects
-
-    def setcoords(self):
-        """adjust canvas obj coords to match the field"""
-        # this uses the canvas object ids saved by makeobjs
-        f = self.field
-        c = self.canvas
-        w2c = self.canvas.world2canvas
-
-        # rings
-        for intens, ring in list(self.rings.items()):
-            rad = f.getdistforintensity(intens)
-            p1 = w2c(*(f.center() - Pair(rad, rad)))
-            p2 = w2c(*(f.center() + Pair(rad, rad)))
-            c.coords(ring, p1[0], p1[1], p2[0], p2[1])
-
-        # text
-        p1 = w2c(*f.center())
-        c.coords(self.txt, *p1)
-
-    def makeobjs(self):
-        """(re)create the canvas objs (null coords) and make their bindings"""
-        c = self.canvas
-        f = self.field
-        c.delete(self.tags)
-
-        w2c = self.canvas.world2canvas
-
-        # make rings
-        self.rings = {}  # rad,canvasobj
-        for intens, color in (  #(1,'white'),
-            (.8, 'gray90'), (.6, 'gray80'), (.4, 'gray60'), (.2, 'gray50'),
-            (0, '#000080')):
-            self.rings[intens] = c.create_oval(0,
-                                               0,
-                                               0,
-                                               0,
-                                               outline=color,
-                                               width=2,
-                                               tags=self.tags,
-                                               outlinestipple='gray50')
-
-        # make text
-        self.txt = c.create_text(0,
-                                 0,
-                                 text=f.name(),
-                                 font=defaultfont + " bold",
-                                 fill='white',
-                                 anchor='c',
-                                 tags=self.tags)
-
-        # highlight text bindings
-        canvashighlighter(c,
-                          self.txt,
-                          'fill',
-                          normalval='white',
-                          highlightval='red')
-
-        # position drag bindings
-        def press(ev):
-            self._lastmouse = ev.x, ev.y
-
-        def motion(ev):
-            dcan = Pair(*[a - b for a, b in zip((ev.x, ev.y), self._lastmouse)])
-            dworld = c.canvas2world_vector(*dcan)
-            self.field.center(*(self.field.center() + dworld))
-            self._lastmouse = ev.x, ev.y
-            self.setcoords()  # redraw
-
-        def release(ev):
-            if hasattr(self, '_lastmouse'):
-                del self._lastmouse
-            dispatcher.send("field coord changed")  # updates bounds
-
-        c.tag_bind(self.txt, "<ButtonPress-1>", press)
-        c.tag_bind(self.txt, "<B1-Motion>", motion)
-        c.tag_bind(self.txt, "<B1-ButtonRelease>", release)
-
-        # radius drag bindings
-        outerring = self.rings[0]
-        canvashighlighter(c,
-                          outerring,
-                          'outline',
-                          normalval='#000080',
-                          highlightval='#4040ff')
-
-        def motion(ev):
-            worldmouse = self.canvas.canvas2world(ev.x, ev.y)
-            currentdist = pairdist(worldmouse, self.field.center())
-            self.field.falloff(currentdist)
-            self.setcoords()
-
-        c.tag_bind(outerring, "<B1-Motion>", motion)
-        c.tag_bind(outerring, "<B1-ButtonRelease>", release)  # from above
-
-        self.setcoords()
-
-
-class Tracker(tk.Frame):
-    """whole tracker widget, which is mostly a view for a
-    Fieldset. tracker makes its own fieldset"""
-
-    # world coords of the visible canvas (preserved even in window resizes)
-    xmin = 0
-    xmax = 100
-    ymin = 0
-    ymax = 100
-
-    fieldsetfile = None
-    displays = None  # Field : FieldDisplay. we keep these in sync with the fieldset
-
-    def __init__(self, master):
-        tk.Frame.__init__(self, master)
-
-        self.displays = {}
-
-        c = self.canvas = Zooming(self, bg='black', closeenough=5)
-        c.pack(fill='both', exp=1)
-
-        # preserve edge coords over window resize
-        c.bind("<Configure>", self.configcoords)
-
-        c.bind("<Motion>", lambda ev: self._fieldset().report(*c.canvas2world(
-            ev.x, ev.y)))
-
-        def save(ev):
-            print("saving")
-            self.fieldsetfile.save()
-
-        master.bind("<Key-s>", save)
-        dispatcher.connect(self.autobounds, "field coord changed")
-
-    def _fieldset(self):
-        return self.fieldsetfile.fieldset()
-
-    def load(self, filename):
-        self.fieldsetfile = Fieldsetfile(filename)
-        self.displays.clear()
-        for f in self.fieldsetfile.fieldset().getall():
-            self.displays[f] = FieldDisplay(self.canvas, f)
-            self.displays[f].makeobjs()
-        self.autobounds()
-
-    def configcoords(self, *args):
-        # force our canvas coords to stay at the edges of the window
-        c = self.canvas
-        cornerx, cornery = c.canvas2world(0, 0)
-        c.move(cornerx - self.xmin, cornery - self.ymin)
-        c.setscale(0, 0,
-                   c.winfo_width() / (self.xmax - self.xmin),
-                   c.winfo_height() / (self.ymax - self.ymin))
-
-    def autobounds(self):
-        """figure out our bounds from the fieldset, and adjust the display zooms.
-        writes the corner coords onto the canvas."""
-        self.xmin, self.xmax, self.ymin, self.ymax = self._fieldset().getbounds(
-        )
-
-        self.configcoords()
-
-        c = self.canvas
-        c.delete('cornercoords')
-        for x, anc2 in ((self.xmin, 'w'), (self.xmax, 'e')):
-            for y, anc1 in ((self.ymin, 'n'), (self.ymax, 's')):
-                pos = c.world2canvas(x, y)
-                c.create_text(pos[0],
-                              pos[1],
-                              text="%s,%s" % (x, y),
-                              fill='white',
-                              anchor=anc1 + anc2,
-                              tags='cornercoords')
-        [d.setcoords() for d in list(self.displays.values())]
-
-
-########################################################################
-########################################################################
-
-root = tk.Tk()
-root.wm_geometry('700x350')
-tra = Tracker(root)
-tra.pack(fill='both', exp=1)
-
-tra.load("fieldsets/demo")
-
-root.mainloop()
--- a/bin/vidref	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,188 +0,0 @@
-#!bin/python
-"""
-Camera images of the stage. View live on a web page and also save
-them to disk. Retrieve images based on the song and time that was
-playing when they were taken. Also, save snapshot images to a place
-they can be used again as thumbnails of effects.
-
-bin/vidref main
-light9/vidref/videorecorder.py capture frames and save them
-light9/vidref/replay.py backend for vidref.js playback element- figures out which frames go with the current song and time
-light9/vidref/index.html web ui for watching current stage and song playback
-light9/vidref/setup.html web ui for setup of camera params and frame crop
-light9/web/light9-vidref-live.js LitElement for live video frames
-light9/web/light9-vidref-playback.js LitElement for video playback
-
-"""
-from run_local import log
-
-from typing import cast
-import logging, optparse, json, base64, os, glob
-
-from light9.metrics import metrics, metricsRoute
-
-from rdflib import URIRef
-from twisted.internet import reactor, defer
-import cyclone.web, cyclone.httpclient, cyclone.websocket
-
-from cycloneerr import PrettyErrorHandler
-from light9 import networking, showconfig
-from light9.newtypes import Song
-from light9.vidref import videorecorder
-from rdfdb.syncedgraph import SyncedGraph
-
-parser = optparse.OptionParser()
-parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG")
-(options, args) = parser.parse_args()
-
-log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-
-
-class Snapshot(cyclone.web.RequestHandler):
-
-    @defer.inlineCallbacks
-    def post(self):
-        # save next pic
-        # return /snapshot/path
-        try:
-            snapshotDir = 'todo'
-            outputFilename = yield self.settings.gui.snapshot()
-
-            assert outputFilename.startswith(snapshotDir)
-            out = networking.vidref.path(
-                "snapshot/%s" % outputFilename[len(snapshotDir):].lstrip('/'))
-
-            self.write(json.dumps({'snapshot': out}))
-            self.set_header("Location", out)
-            self.set_status(303)
-        except Exception:
-            import traceback
-            traceback.print_exc()
-            raise
-
-
-pipeline = videorecorder.GstSource(
-    #'/dev/v4l/by-id/usb-Bison_HD_Webcam_200901010001-video-index0'
-    '/dev/v4l/by-id/usb-Generic_FULL_HD_1080P_Webcam_200901010001-video-index0')
-
-
-class Live(cyclone.websocket.WebSocketHandler):
-
-    def connectionMade(self, *args, **kwargs):
-        pipeline.liveImages.subscribe(on_next=self.onFrame)
-        metrics('live_clients').offset(1)
-
-    def connectionLost(self, reason):
-        #self.subj.dispose()
-        metrics('live_clients').offset(-1)
-
-    def onFrame(self, cf: videorecorder.CaptureFrame):
-        if cf is None: return
-
-        with metrics('live_websocket_frame_fps').time():
-            self.sendMessage(
-                json.dumps({
-                    'jpeg': base64.b64encode(cf.asJpeg()).decode('ascii'),
-                    'description': f't={cf.t}',
-                }))
-
-
-class SnapshotPic(cyclone.web.StaticFileHandler):
-    pass
-
-
-class Time(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def put(self):
-        body = json.loads(self.request.body)
-        t = body['t']
-        for listener in TimeStream.time_stream_listeners:
-            listener.sendMessage(json.dumps({
-                'st': t,
-                'song': body['song'],
-            }))
-        self.set_status(202)
-
-
-class TimeStream(cyclone.websocket.WebSocketHandler):
-    time_stream_listeners = []
-
-    def connectionMade(self, *args, **kwargs):
-        TimeStream.time_stream_listeners.append(self)
-
-    def connectionLost(self, reason):
-        TimeStream.time_stream_listeners.remove(self)
-
-
-class Clips(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def delete(self):
-        clip = URIRef(self.get_argument('uri'))
-        videorecorder.deleteClip(clip)
-
-
-class ReplayMap(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        song = Song(self.get_argument('song'))
-        clips = []
-        videoPaths = glob.glob(
-            os.path.join(videorecorder.songDir(song), b'*.mp4'))
-        for vid in videoPaths:
-            pts = []
-            for line in open(vid.replace(b'.mp4', b'.timing'), 'rb'):
-                _v, vt, _eq, _song, st = line.split()
-                pts.append([float(st), float(vt)])
-
-            url = vid[len(os.path.dirname(os.path.dirname(showconfig.root()))
-                         ):].decode('ascii')
-
-            clips.append({
-                'uri': videorecorder.takeUri(vid),
-                'videoUrl': url,
-                'songToVideo': pts
-            })
-
-        clips.sort(key=lambda c: len(cast(list, c['songToVideo'])))
-        clips = clips[-int(self.get_argument('maxClips', '3')):]
-        clips.sort(key=lambda c: c['uri'], reverse=True)
-
-        ret = json.dumps(clips)
-        log.info('replayMap had %s videos; json is %s bytes', len(clips),
-                 len(ret))
-        self.write(ret)
-
-
-graph = SyncedGraph(networking.rdfdb.url, "vidref")
-outVideos = videorecorder.FramesToVideoFiles(
-    pipeline.liveImages, os.path.join(showconfig.root(), b'video'))
-
-port = networking.vidref.port
-reactor.listenTCP(
-    port,
-    cyclone.web.Application(
-        handlers=[
-            (r'/()', cyclone.web.StaticFileHandler, {
-                'path': 'light9/vidref',
-                'default_filename': 'index.html'
-            }),
-            (r'/setup/()', cyclone.web.StaticFileHandler, {
-                'path': 'light9/vidref',
-                'default_filename': 'setup.html'
-            }),
-            (r'/live', Live),
-            (r'/clips', Clips),
-            (r'/replayMap', ReplayMap),
-            (r'/snapshot', Snapshot),
-            (r'/snapshot/(.*)', SnapshotPic, {
-                "path": 'todo',
-            }),
-            (r'/time', Time),
-            (r'/time/stream', TimeStream),
-            metricsRoute(),
-        ],
-        debug=True,
-    ))
-log.info("serving on %s" % port)
-
-reactor.run()
--- a/bin/vidrefsetup	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-#!bin/python
-""" this should be part of vidref, but I haven't worked out sharing
-camera captures with a continuous camera capture yet """
-
-from run_local import log
-import optparse, logging
-from twisted.internet import reactor
-import cyclone.web, cyclone.httpclient, cyclone.websocket
-from rdflib import URIRef
-from rdfdb.syncedgraph import SyncedGraph
-from light9.namespaces import L9
-from light9 import networking, showconfig
-
-from cycloneerr import PrettyErrorHandler
-
-
-class RedirToCamera(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        return self.redirect(
-            networking.picamserve.path('pic?' + self.request.query))
-
-
-class UrlToCamera(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        self.set_header('Content-Type', 'text/plain')
-        self.write(networking.picamserve.path('pic'))
-
-
-class VidrefCamRequest(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def get(self):
-        graph = self.settings.graph
-        show = showconfig.showUri()
-        with graph.currentState(tripleFilter=(show, None, None)) as g:
-            ret = g.value(show, L9['vidrefCamRequest'])
-            if ret is None:
-                self.send_error(404)
-            self.redirect(ret)
-
-    def put(self):
-        graph = self.settings.graph
-        show = showconfig.showUri()
-        graph.patchObject(context=URIRef(show + '/vidrefConfig'),
-                          subject=show,
-                          predicate=L9['vidrefCamRequest'],
-                          newObject=URIRef(self.get_argument('uri')))
-        self.send_error(202)
-
-
-def main():
-    parser = optparse.OptionParser()
-    parser.add_option("-v",
-                      "--verbose",
-                      action="store_true",
-                      help="logging.DEBUG")
-    (options, args) = parser.parse_args()
-
-    log.setLevel(logging.DEBUG if options.verbose else logging.INFO)
-    graph = SyncedGraph(networking.rdfdb.url, "vidrefsetup")
-
-    # deliberately conflict with vidref since they can't talk at once to cam
-    port = networking.vidref.port
-
-    reactor.listenTCP(
-        port,
-        cyclone.web.Application(handlers=[
-            (r'/pic', RedirToCamera),
-            (r'/picUrl', UrlToCamera),
-            (r'/vidrefCamRequest', VidrefCamRequest),
-            (r'/()', cyclone.web.StaticFileHandler, {
-                'path': 'light9/vidref/',
-                'default_filename': 'vidref.html'
-            }),
-        ],
-                                debug=True,
-                                graph=graph))
-    log.info("serving on %s" % port)
-    reactor.run()
-
-
-main()
--- a/bin/wavecurve	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-#!bin/python
-import optparse
-from run_local import log
-from light9.wavepoints import simp
-
-
-def createCurve(inpath, outpath, t):
-    print("reading %s, writing %s" % (inpath, outpath))
-    points = simp(inpath.replace('.ogg', '.wav'), seconds_per_average=t)
-
-    f = open(outpath, 'w')
-    for time_val in points:
-        print("%s %s" % time_val, file=f)
-    log.info(r'Wrote {outpath}')
-
-
-parser = optparse.OptionParser(usage="""%prog inputSong.wav outputCurve
-
-You probably just want -a
-
-""")
-parser.add_option("-t",
-                  type="float",
-                  default=.01,
-                  help="seconds per sample (default .01, .07 is smooth)")
-parser.add_option("-a",
-                  "--all",
-                  action="store_true",
-                  help="make standard curves for all songs")
-options, args = parser.parse_args()
-
-if options.all:
-    from light9 import showconfig
-    from light9.ascoltami.playlist import Playlist
-    graph = showconfig.getGraph()
-
-    playlist = Playlist.fromShow(showconfig.getGraph(), showconfig.showUri())
-    for song in playlist.allSongs():
-        inpath = showconfig.songOnDisk(song)
-        for curveName, t in [('music', .01), ('smooth_music', .07)]:
-            outpath = showconfig.curvesDir() + "/%s-%s" % (
-                showconfig.songFilenameFromURI(song), curveName)
-            createCurve(inpath, outpath, t)
-else:
-    inpath, outpath = args
-    createCurve(inpath, outpath, options.t)
--- a/bin/webcontrol	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,122 +0,0 @@
-#!bin/python
-"""
-web UI for various commands that we might want to run from remote
-computers and phones
-
-todo:
-disable buttons that don't make sense
-"""
-import sys, xmlrpc.client, traceback
-from twisted.internet import reactor
-from twisted.python import log
-from twisted.python.util import sibpath
-from twisted.internet.defer import inlineCallbacks, returnValue
-from twisted.web.client import getPage
-from nevow.appserver import NevowSite
-from nevow import rend, static, loaders, inevow, url, tags as T
-from rdflib import URIRef
-from louie.robustapply import robust_apply
-sys.path.append(".")
-from light9 import showconfig, networking
-from light9.namespaces import L9
-from urllib.parse import urlencode
-
-
-# move to web lib
-def post(url, **args):
-    return getPage(url, method='POST', postdata=urlencode(args))
-
-
-class Commands(object):
-
-    @staticmethod
-    def playSong(graph, songUri):
-        s = xmlrpc.client.ServerProxy(networking.musicPlayer.url)
-        songPath = graph.value(URIRef(songUri), L9.showPath)
-        if songPath is None:
-            raise ValueError("unknown song %s" % songUri)
-        return s.playfile(songPath.encode('ascii'))
-
-    @staticmethod
-    def stopMusic(graph):
-        s = xmlrpc.client.ServerProxy(networking.musicPlayer.url)
-        return s.stop()
-
-    @staticmethod
-    def worklightsOn(graph):
-        return post(networking.keyboardComposer.path('fadesub'),
-                    subname='scoop',
-                    level=.5,
-                    secs=.5)
-
-    @staticmethod
-    def worklightsOff(graph):
-        return post(networking.keyboardComposer.path('fadesub'),
-                    subname='scoop',
-                    level=0,
-                    secs=.5)
-
-    @staticmethod
-    def dimmerSet(graph, dimmer, value):
-        raise NotImplementedError("subcomposer doesnt have an http port yet")
-
-
-class Main(rend.Page):
-    docFactory = loaders.xmlfile(sibpath(__file__, "../light9/webcontrol.html"))
-
-    def __init__(self, graph):
-        self.graph = graph
-        rend.Page.__init__(self)
-
-    def render_status(self, ctx, data):
-        pic = T.img(src="icon/enabled.png")
-        if ctx.arg('error'):
-            pic = T.img(src="icon/warning.png")
-        return [pic, ctx.arg('status') or 'ready']
-
-    def render_songButtons(self, ctx, data):
-        playList = graph.value(show, L9['playList'])
-        songs = list(graph.items(playList))
-        out = []
-        for song in songs:
-            out.append(
-                T.form(method="post", action="playSong")
-                [T.input(type='hidden', name='songUri', value=song),
-                 T.button(type='submit')[graph.label(song)]])
-        return out
-
-    @inlineCallbacks
-    def locateChild(self, ctx, segments):
-        try:
-            func = getattr(Commands, segments[0])
-            req = inevow.IRequest(ctx)
-            simpleArgDict = dict((k, v[0]) for k, v in list(req.args.items()))
-            try:
-                ret = yield robust_apply(func, func, self.graph,
-                                         **simpleArgDict)
-            except KeyboardInterrupt:
-                raise
-            except Exception as e:
-                print("Error on command %s" % segments[0])
-                traceback.print_exc()
-                returnValue((url.here.up().add('status',
-                                               str(e)).add('error',
-                                                           1), segments[1:]))
-
-            returnValue((url.here.up().add('status', ret), segments[1:]))
-            #actually return the orig page, with a status message from the func
-        except AttributeError:
-            pass
-        returnValue(rend.Page.locateChild(self, ctx, segments))
-
-    def child_icon(self, ctx):
-        return static.File("/usr/share/pyshared/elisa/plugins/poblesec/tango")
-
-
-graph = showconfig.getGraph()
-show = showconfig.showUri()
-
-log.startLogging(sys.stdout)
-
-reactor.listenTCP(9000, NevowSite(Main(graph)))
-reactor.run()
--- a/light9/Effects.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,179 +0,0 @@
-import random as random_mod
-import math
-import logging, colorsys
-import light9.Submaster as Submaster
-from .chase import chase as chase_logic
-from . import showconfig
-from rdflib import RDF
-from light9 import Patch
-from light9.namespaces import L9
-log = logging.getLogger()
-
-registered = []
-
-
-def register(f):
-    registered.append(f)
-    return f
-
-
-@register
-class Strip:
-    """list of r,g,b tuples for sending to an LED strip"""
-    which = 'L'  # LR means both. W is the wide one
-    pixels = []
-
-    def __repr__(self):
-        return '<Strip which=%r px0=%r>' % (self.which, self.pixels[0])
-
-    @classmethod
-    def solid(cls, which='L', color=(1, 1, 1), hsv=None):
-        """hsv overrides color"""
-        if hsv is not None:
-            color = colorsys.hsv_to_rgb(hsv[0] % 1.0, hsv[1], hsv[2])
-        x = cls()
-        x.which = which
-        x.pixels = [tuple(color)] * 50
-        return x
-
-    def __mul__(self, f):
-        if not isinstance(f, (int, float)):
-            raise TypeError
-
-        s = Strip()
-        s.which = self.which
-        s.pixels = [(r * f, g * f, b * f) for r, g, b in self.pixels]
-        return s
-
-    __rmul__ = __mul__
-
-
-@register
-class Blacklight(float):
-    """a level for the blacklight PWM output"""
-
-    def __mul__(self, f):
-        return Blacklight(float(self) * f)
-
-    __rmul__ = __mul__
-
-
-@register
-def chase(t,
-          ontime=0.5,
-          offset=0.2,
-          onval=1.0,
-          offval=0.0,
-          names=None,
-          combiner=max,
-          random=False):
-    """names is list of URIs. returns a submaster that chases through
-    the inputs"""
-    if random:
-        r = random_mod.Random(random)
-        names = names[:]
-        r.shuffle(names)
-
-    chase_vals = chase_logic(t, ontime, offset, onval, offval, names, combiner)
-    lev = {}
-    for uri, value in list(chase_vals.items()):
-        try:
-            dmx = Patch.dmx_from_uri(uri)
-        except KeyError:
-            log.info(("chase includes %r, which doesn't resolve to a dmx chan" %
-                      uri))
-            continue
-        lev[dmx] = value
-
-    return Submaster.Submaster(name="chase", levels=lev)
-
-
-@register
-def hsv(h, s, v, light='all', centerScale=.5):
-    r, g, b = colorsys.hsv_to_rgb(h % 1.0, s, v)
-    lev = {}
-    if light in ['left', 'all']:
-        lev[73], lev[74], lev[75] = r, g, b
-    if light in ['right', 'all']:
-        lev[80], lev[81], lev[82] = r, g, b
-    if light in ['center', 'all']:
-        lev[88], lev[89], lev[
-            90] = r * centerScale, g * centerScale, b * centerScale
-    return Submaster.Submaster(name='hsv', levels=lev)
-
-
-@register
-def stack(t, names=None, fade=0):
-    """names is list of URIs. returns a submaster that stacks the the inputs
-
-    fade=0 makes steps, fade=1 means each one gets its full fraction
-    of the time to fade in. Fades never...
-    """
-    frac = 1.0 / len(names)
-
-    lev = {}
-    for i, uri in enumerate(names):
-        if t >= (i + 1) * frac:
-            try:
-                dmx = Patch.dmx_from_uri(uri)
-            except KeyError:
-                log.info(
-                    ("stack includes %r, which doesn't resolve to a dmx chan" %
-                     uri))
-                continue
-            lev[dmx] = 1
-        else:
-            break
-
-    return Submaster.Submaster(name="stack", levels=lev)
-
-
-@register
-def smoove(x):
-    return -2 * (x**3) + 3 * (x**2)
-
-
-def configExprGlobals():
-    graph = showconfig.getGraph()
-    ret = {}
-
-    for chaseUri in graph.subjects(RDF.type, L9['Chase']):
-        shortName = chaseUri.rsplit('/')[-1]
-        chans = graph.value(chaseUri, L9['channels'])
-        ret[shortName] = list(graph.items(chans))
-        print("%r is a chase" % shortName)
-
-    for f in registered:
-        ret[f.__name__] = f
-
-    ret['nsin'] = lambda x: (math.sin(x * (2 * math.pi)) + 1) / 2
-    ret['ncos'] = lambda x: (math.cos(x * (2 * math.pi)) + 1) / 2
-
-    def nsquare(t, on=.5):
-        return (t % 1.0) < on
-
-    ret['nsquare'] = nsquare
-
-    _smooth_random_items = [random_mod.random() for x in range(100)]
-
-    # suffix '2' to keep backcompat with the versions that magically knew time
-    def smooth_random2(t, speed=1):
-        """1 = new stuff each second, <1 is slower, fade-ier"""
-        x = (t * speed) % len(_smooth_random_items)
-        x1 = int(x)
-        x2 = (int(x) + 1) % len(_smooth_random_items)
-        y1 = _smooth_random_items[x1]
-        y2 = _smooth_random_items[x2]
-        return y1 + (y2 - y1) * ((x - x1))
-
-    def notch_random2(t, speed=1):
-        """1 = new stuff each second, <1 is slower, notch-ier"""
-        x = (t * speed) % len(_smooth_random_items)
-        x1 = int(x)
-        y1 = _smooth_random_items[x1]
-        return y1
-
-    ret['noise2'] = smooth_random2
-    ret['notch2'] = notch_random2
-
-    return ret
--- a/light9/Fadable.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,165 +0,0 @@
-# taken from SnackMix -- now that's reusable code
-import time
-
-
-class Fadable:
-    """Fading mixin: must mix in with a Tk widget (or something that has
-    'after' at least) This is currently used by VolumeBox and MixerTk.
-    It's probably too specialized to be used elsewhere, but could possibly
-    work with an Entry or a Meter, I guess.  (Actually, this is used by
-    KeyboardComposer and KeyboardRecorder now too.)
-
-    var is a Tk variable that should be used to set and get the levels.
-    If use_fades is true, it will use fades to move between levels.
-    If key_bindings is true, it will install these keybindings:
-
-    Press a number to fade to that amount (e.g. '5' = 50%).  Also,
-    '`' (grave) will fade to 0 and '0' will fade to 100%.
-
-    If mouse_bindings is true, the following mouse bindings will be
-    installed: Right clicking toggles muting.  The mouse wheel will
-    raise or lower the volume.  Shift-mouse wheeling will cause a more
-    precise volume adjustment.  Control-mouse wheeling will cause a
-    longer fade."""
-
-    def __init__(self,
-                 var,
-                 wheel_step=5,
-                 use_fades=1,
-                 key_bindings=1,
-                 mouse_bindings=1):
-        self.use_fades = use_fades  # whether increase and decrease should fade
-        self.wheel_step = wheel_step  # amount that increase and descrease should
-        # change volume (by default)
-
-        self.fade_start_level = 0
-        self.fade_end_level = 0
-        self.fade_start_time = 0
-        self.fade_length = 1
-        self.fade_step_time = 10
-        self.fade_var = var
-        self.fading = 0  # whether a fade is in progress
-
-        if key_bindings:
-            for k in range(1, 10):
-                self.bind("<Key-%d>" % k, lambda evt, k=k: self.fade(k / 10.0))
-            self.bind("<Key-0>", lambda evt: self.fade(1.0))
-            self.bind("<grave>", lambda evt: self.fade(0))
-
-            # up / down arrows
-            self.bind("<Key-Up>", lambda evt: self.increase())
-            self.bind("<Key-Down>", lambda evt: self.decrease())
-
-        if mouse_bindings:
-            # right mouse button toggles muting
-            self.bind('<3>', lambda evt: self.toggle_mute())
-            # not "NOT ANY MORE!" - homer (i.e. it works again)
-
-            # mouse wheel
-            self.bind('<4>', lambda evt: self.increase())
-            self.bind('<5>', lambda evt: self.decrease())
-
-            # modified mouse wheel
-            self.bind('<Shift-4>', lambda evt: self.increase(multiplier=0.2))
-            self.bind('<Shift-5>', lambda evt: self.decrease(multiplier=0.2))
-            self.bind('<Control-4>', lambda evt: self.increase(length=1))
-            self.bind('<Control-5>', lambda evt: self.decrease(length=1))
-
-        self.last_level = None  # used for muting
-
-    def set_var_rounded(self, value):
-        """use this instead of just self.fade_var.set(value) so we can
-        control the precision"""
-        # this was just to make the display not look so weird, but it
-        # could actually affect the speed of really slow fades. If
-        # that's a problem, do a real trace_write hook for the
-        # variable's display instead of using Label(textvariable=var)
-        # and format it there.
-        self.fade_var.set(round(value, 7))
-        if self.fade_var.get() != value:
-            self.fade_var.set(value)
-        if abs(self.fade_var.get() - value) > .1:
-            raise ValueError(
-                "doublevar won't set- trying %r but it stays at %r" %
-                (value, self.fade_var.get()))
-
-    def fade(self, value, length=0.5, step_time=10):
-        """Fade to value in length seconds with steps every step_time
-        milliseconds"""
-        if length == 0:  # 0 seconds fades happen right away and prevents
-            # and prevents us from entering the fade loop,
-            # which would cause a divide by zero
-            self.set_var_rounded(value)
-            self.fading = 0  # we stop all fades
-        else:  # the general case
-            self.fade_start_time = time.time()
-            self.fade_length = length
-
-            self.fade_start_level = self.fade_var.get()
-            self.fade_end_level = value
-
-            self.fade_step_time = step_time
-            if not self.fading:
-                self.fading = 1
-                self.do_fade()
-
-    def do_fade(self):
-        """Actually performs the fade for Fadable.fade.  Shouldn't be called
-        directly."""
-        now = time.time()
-        elapsed = now - self.fade_start_time
-        complete = elapsed / self.fade_length
-        complete = min(1.0, complete)
-        diff = self.fade_end_level - self.fade_start_level
-        newlevel = (complete * diff) + self.fade_start_level
-        self.set_var_rounded(newlevel)
-        if complete < 1:
-            self.after(self.fade_step_time, self.do_fade)
-        else:
-            self.fading = 0
-
-    def increase(self, multiplier=1, length=0.3):
-        """Increases the volume by multiplier * wheel_step.  If use_fades is
-        true, it do this as a fade over length time."""
-        amount = self.wheel_step * multiplier
-        if self.fading:
-            newlevel = self.fade_end_level + amount
-        else:
-            newlevel = self.fade_var.get() + amount
-        newlevel = min(100, newlevel)
-        self.set_volume(newlevel, length)
-
-    def decrease(self, multiplier=1, length=0.3):
-        """Descreases the volume by multiplier * wheel_step.  If use_fades
-        is true, it do this as a fade over length time."""
-        amount = self.wheel_step * multiplier
-        if self.fading:
-            newlevel = self.fade_end_level - amount
-        else:
-            newlevel = self.fade_var.get() - amount
-        newlevel = max(0., newlevel)
-        self.set_volume(newlevel, length)
-
-    def set_volume(self, newlevel, length=0.3):
-        """Sets the volume to newlevel, performing a fade of length if
-        use_fades is true."""
-        if self.use_fades:
-            self.fade(newlevel, length=length)
-        else:
-            self.set_var_rounded(newlevel)
-
-    def toggle_mute(self):
-        """Toggles whether the volume is being muted."""
-        if self.last_level is None:
-            self.last_level = self.fade_var.get()
-            if self.last_level == 0.:  # we don't want last_level to be zero,
-                # since it will make us toggle between 0
-                # and 0
-                newlevel = 1.
-            else:
-                newlevel = 0.
-        else:
-            newlevel = self.last_level
-            self.last_level = None
-
-        self.set_var_rounded(newlevel)
--- a/light9/FlyingFader.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,220 +0,0 @@
-from tkinter import tix
-from time import time
-
-
-class Mass:
-
-    def __init__(self):
-        self.x = 0  # position
-        self.xgoal = 0  # position goal
-
-        self.v = 0  # velocity
-        self.maxspeed = .8  # maximum speed, in position/second
-        self.maxaccel = 3  # maximum acceleration, in position/second^2
-        self.eps = .03  # epsilon - numbers within this much are considered the same
-
-        self._lastupdate = time()
-        self._stopped = 1
-
-    def equal(self, a, b):
-        return abs(a - b) < self.eps
-
-    def stop(self):
-        self.v = 0
-        self.xgoal = self.x
-        self._stopped = 1
-
-    def update(self):
-        t0 = self._lastupdate
-        tnow = time()
-        self._lastupdate = tnow
-
-        dt = tnow - t0
-
-        self.x += self.v * dt
-        # hitting the ends stops the slider
-        if self.x > 1:
-            self.v = max(self.v, 0)
-            self.x = 1
-        if self.x < 0:
-            self.v = min(self.v, 0)
-            self.x = 0
-
-        if self.equal(self.x, self.xgoal):
-            self.x = self.xgoal  # clean up value
-            self.stop()
-            return
-
-        self._stopped = 0
-        dir = (-1.0, 1, 0)[self.xgoal > self.x]
-
-        if abs(self.xgoal - self.x) < abs(self.v * 5 * dt):
-            # apply the brakes on the last 5 steps
-            dir *= -.5
-
-        self.v += dir * self.maxaccel * dt  # velocity changes with acceleration in the right direction
-        self.v = min(max(self.v, -self.maxspeed),
-                     self.maxspeed)  # clamp velocity
-
-        #print "x=%+.03f v=%+.03f a=%+.03f %f" % (self.x,self.v,self.maxaccel,self.xgoal)
-
-    def goto(self, newx):
-        self.xgoal = newx
-
-    def ismoving(self):
-        return not self._stopped
-
-
-class FlyingFader(tix.Frame):
-
-    def __init__(self,
-                 master,
-                 variable,
-                 label,
-                 fadedur=1.5,
-                 font=('Arial', 8),
-                 labelwidth=12,
-                 **kw):
-        tix.Frame.__init__(self, master)
-        self.name = label
-        self.variable = variable
-
-        self.mass = Mass()
-
-        self.config({'bd': 1, 'relief': 'raised'})
-        scaleopts = {
-            'variable': variable,
-            'showvalue': 0,
-            'from': 1.0,
-            'to': 0,
-            'res': 0.001,
-            'width': 20,
-            'length': 200,
-            'orient': 'vert'
-        }
-        scaleopts.update(kw)
-        if scaleopts['orient'] == 'vert':
-            side2 = tix.BOTTOM
-        else:
-            side2 = tix.LEFT
-
-        self.scale = tix.Scale(self, **scaleopts)
-        self.vlabel = tix.Label(self, text="0.0", width=6, font=font)
-        self.label = tix.Label(self,
-                               text=label,
-                               font=font,
-                               anchor='w',
-                               width=labelwidth)  #wraplength=40, )
-
-        self.oldtrough = self.scale['troughcolor']
-
-        self.scale.pack(side=side2, expand=1, fill=tix.BOTH, anchor='c')
-        self.vlabel.pack(side=side2, expand=0, fill=tix.X)
-        self.label.pack(side=side2, expand=0, fill=tix.X)
-
-        for k in range(1, 10):
-            self.scale.bind(
-                "<Key-%d>" % k, lambda evt, k=k: self.newfade(k / 10.0, evt))
-
-        self.scale.bind("<Key-0>", lambda evt: self.newfade(1.0, evt))
-        self.scale.bind("<grave>", lambda evt: self.newfade(0, evt))
-
-        self.scale.bind("<1>", self.cancelfade)
-        self.scale.bind("<2>", self.cancelfade)
-        self.scale.bind("<3>", self.mousefade)
-
-        self.trace_ret = self.variable.trace('w', self.updatelabel)
-        self.bind("<Destroy>", self.ondestroy)
-
-    def ondestroy(self, *ev):
-        self.variable.trace_vdelete('w', self.trace_ret)
-
-    def cancelfade(self, evt):
-        self.fadegoal = self.variable.get()
-        self.fadevel = self.fadeacc = 0
-
-        self.scale['troughcolor'] = self.oldtrough
-
-    def mousefade(self, evt):
-        target = float(self.tk.call(self.scale, 'get', evt.x, evt.y))
-        self.newfade(target, evt)
-
-    def ismoving(self):
-        return self.fadevel != 0 or self.fadeacc != 0
-
-    def newfade(self, newlevel, evt=None, length=None):
-
-        # these are currently unused-- Mass needs to accept a speed input
-        mult = 1
-        if evt.state & 8 and evt.state & 4: mult = 0.25  # both
-        elif evt.state & 8: mult = 0.5  # alt
-        elif evt.state & 4: mult = 2  # control # noqa
-
-        self.mass.x = self.variable.get()
-        self.mass.goto(newlevel)
-
-        self.gofade()
-
-    def gofade(self):
-        self.mass.update()
-        self.variable.set(self.mass.x)
-
-        if not self.mass.ismoving():
-            self.scale['troughcolor'] = self.oldtrough
-            return
-
-        # blink the trough while the thing's moving
-        if time() % .4 > .2:
-            # self.scale.config(troughcolor=self.oldtrough)
-            self.scale.config(troughcolor='orange')
-        else:
-            # self.scale.config(troughcolor='white')
-            self.scale.config(troughcolor='yellow')
-
-#        colorfade(self.scale, percent)
-        self.after(30, self.gofade)
-
-    def updatelabel(self, *args):
-        if self.variable:
-            self.vlabel['text'] = "%.3f" % self.variable.get()
-
-
-#        if self.fadetimes[1] == 0: # no fade
-#            self.vlabel['fg'] = 'black'
-#        elif self.curfade[1] > self.curfade[0]:
-#            self.vlabel['fg'] = 'red'
-#        else:
-#            self.vlabel['fg'] = 'blue'
-
-    def get(self):
-        return self.scale.get()
-
-    def set(self, val):
-        self.scale.set(val)
-
-
-def colorfade(scale, lev):
-    low = (255, 255, 255)
-    high = (0, 0, 0)
-    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
-    col = "#%02X%02X%02X" % tuple(out)
-    scale.config(troughcolor=col)
-
-
-if __name__ == '__main__':
-    root = tix.Tk()
-    root.tk_focusFollowsMouse()
-
-    FlyingFader(root, variable=tix.DoubleVar(),
-                label="suck").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
-    FlyingFader(root, variable=tix.DoubleVar(),
-                label="moof").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
-    FlyingFader(root, variable=tix.DoubleVar(),
-                label="zarf").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
-    FlyingFader(root,
-                variable=tix.DoubleVar(),
-                label="long name goes here.  got it?").pack(side=tix.LEFT,
-                                                            expand=1,
-                                                            fill=tix.BOTH)
-
-    root.mainloop()
--- a/light9/Patch.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,76 +0,0 @@
-from rdflib import RDF
-from light9.namespaces import L9
-from light9 import showconfig
-
-
-def resolve_name(channelname):
-    "Ensure that we're talking about the primary name of the light."
-    return get_channel_name(get_dmx_channel(channelname))
-
-
-def get_all_channels():
-    """returns primary names for all channels (sorted)"""
-    prinames = sorted(list(reverse_patch.values())[:])
-    return prinames
-
-
-def get_dmx_channel(name):
-    if str(name) in patch:
-        return patch[str(name)]
-
-    try:
-        i = int(name)
-        return i
-    except ValueError:
-        raise ValueError("Invalid channel name: %r" % name)
-
-
-def get_channel_name(dmxnum):
-    """if you pass a name, it will get normalized"""
-    try:
-        return reverse_patch[dmxnum]
-    except KeyError:
-        return str(dmxnum)
-
-
-def get_channel_uri(name):
-    return uri_map[name]
-
-
-def dmx_from_uri(uri):
-    return uri_patch[uri]
-
-
-def reload_data():
-    global patch, reverse_patch, uri_map, uri_patch
-    patch = {}
-    reverse_patch = {}
-    uri_map = {}
-    uri_patch = {}
-
-    graph = showconfig.getGraph()
-
-    for chan in graph.subjects(RDF.type, L9['Channel']):
-        for which, name in enumerate([graph.label(chan)] +
-                                     list(graph.objects(chan, L9['altName']))):
-            name = str(name)
-            uri_map[name] = chan
-
-            if name in patch:
-                raise ValueError("channel name %r used multiple times" % name)
-            for output in graph.objects(chan, L9['output']):
-                for addr in graph.objects(output, L9['dmxAddress']):
-                    addrInt = int(addr)
-                    patch[name] = addrInt
-                    uri_patch[chan] = addrInt
-
-                    if which == 0:
-                        reverse_patch[addrInt] = name
-                        reverse_patch[addr] = name
-                        norm_name = name
-                    else:
-                        reverse_patch[name] = norm_name
-
-
-# importing patch will load initial data
-reload_data()
--- a/light9/Submaster.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,391 +0,0 @@
-import logging
-from rdflib import Graph, RDF
-from rdflib import RDFS, Literal, BNode
-from light9.namespaces import L9, XSD
-from light9.TLUtility import dict_scale, dict_max
-from light9 import showconfig
-from light9.Patch import resolve_name, get_dmx_channel, get_channel_uri
-from louie import dispatcher
-from rdfdb.patch import Patch
-log = logging.getLogger('submaster')
-
-
-class Submaster(object):
-    """mapping of channels to levels"""
-
-    def __init__(self, name, levels):
-        """this sub has a name just for debugging. It doesn't get persisted.
-        See PersistentSubmaster.
-
-        levels is a dict
-        """
-        self.name = name
-        self.levels = levels
-
-        self.temporary = True
-
-        if not self.temporary:
-            # obsolete
-            dispatcher.connect(log.error, 'reload all subs')
-
-        #log.debug("%s initial levels %s", self.name, self.levels)
-
-    def _editedLevels(self):
-        pass
-
-    def set_level(self, channelname, level, save=True):
-        self.levels[resolve_name(channelname)] = level
-        self._editedLevels()
-
-    def set_all_levels(self, leveldict):
-        self.levels.clear()
-        for k, v in list(leveldict.items()):
-            # this may call _editedLevels too many times
-            self.set_level(k, v, save=0)
-
-    def get_levels(self):
-        return self.levels
-
-    def no_nonzero(self):
-        return all(v == 0 for v in self.levels.values())
-
-    def __mul__(self, scalar):
-        return Submaster("%s*%s" % (self.name, scalar),
-                         levels=dict_scale(self.levels, scalar))
-
-    __rmul__ = __mul__
-
-    def max(self, *othersubs):
-        return sub_maxes(self, *othersubs)
-
-    def __add__(self, other):
-        return self.max(other)
-
-    def ident(self):
-        return (self.name, tuple(sorted(self.levels.items())))
-
-    def __repr__(self):
-        items = sorted(list(getattr(self, 'levels', {}).items()))
-        levels = ' '.join(["%s:%.2f" % item for item in items])
-        return "<'%s': [%s]>" % (getattr(self, 'name', 'no name yet'), levels)
-
-    def __cmp__(self, other):
-        # not sure how useful this is
-        if not isinstance(other, Submaster):
-            return -1
-        return cmp(self.ident(), other.ident())  # noqa
-
-    def __hash__(self):
-        return hash(self.ident())
-
-    def get_dmx_list(self):
-        leveldict = self.get_levels()  # gets levels of sub contents
-
-        levels = []
-        for k, v in list(leveldict.items()):
-            if v == 0:
-                continue
-            try:
-                dmxchan = get_dmx_channel(k) - 1
-            except ValueError:
-                log.error(
-                    "error trying to compute dmx levels for submaster %s" %
-                    self.name)
-                raise
-            if dmxchan >= len(levels):
-                levels.extend([0] * (dmxchan - len(levels) + 1))
-            levels[dmxchan] = max(v, levels[dmxchan])
-
-        return levels
-
-    def normalize_patch_names(self):
-        """Use only the primary patch names."""
-        # possibly busted -- don't use unless you know what you're doing
-        self.set_all_levels(self.levels.copy())
-
-    def get_normalized_copy(self):
-        """Get a copy of this sumbaster that only uses the primary patch
-        names.  The levels will be the same."""
-        newsub = Submaster("%s (normalized)" % self.name, {})
-        newsub.set_all_levels(self.levels)
-        return newsub
-
-    def crossfade(self, othersub, amount):
-        """Returns a new sub that is a crossfade between this sub and
-        another submaster.
-
-        NOTE: You should only crossfade between normalized submasters."""
-        otherlevels = othersub.get_levels()
-        keys_set = {}
-        for k in list(self.levels.keys()) + list(otherlevels.keys()):
-            keys_set[k] = 1
-        all_keys = list(keys_set.keys())
-
-        xfaded_sub = Submaster("xfade", {})
-        for k in all_keys:
-            xfaded_sub.set_level(
-                k,
-                linear_fade(self.levels.get(k, 0), otherlevels.get(k, 0),
-                            amount))
-
-        return xfaded_sub
-
-
-class PersistentSubmaster(Submaster):
-
-    def __init__(self, graph, uri):
-        if uri is None:
-            raise TypeError("uri must be URIRef")
-        self.graph, self.uri = graph, uri
-        self.graph.addHandler(self.setName)
-        self.graph.addHandler(self.setLevels)
-        Submaster.__init__(self, self.name, self.levels)
-        self.temporary = False
-
-    def ident(self):
-        return self.uri
-
-    def _editedLevels(self):
-        self.save()
-
-    def changeName(self, newName):
-        self.graph.patchObject(self.uri, self.uri, RDFS.label, Literal(newName))
-
-    def setName(self):
-        log.info("sub update name %s %s", self.uri, self.graph.label(self.uri))
-        self.name = self.graph.label(self.uri)
-
-    def setLevels(self):
-        log.debug("sub update levels")
-        oldLevels = getattr(self, 'levels', {}).copy()
-        self.setLevelsFromGraph()
-        if oldLevels != self.levels:
-            log.debug("sub %s changed" % self.name)
-            # dispatcher too? this would help subcomposer
-            dispatcher.send("sub levels changed", sub=self)
-
-    def setLevelsFromGraph(self):
-        if hasattr(self, 'levels'):
-            self.levels.clear()
-        else:
-            self.levels = {}
-        for lev in self.graph.objects(self.uri, L9['lightLevel']):
-            log.debug(" lightLevel %s %s", self.uri, lev)
-            chan = self.graph.value(lev, L9['channel'])
-
-            val = self.graph.value(lev, L9['level'])
-            if val is None:
-                # broken lightLevel link- may be from someone deleting channels
-                log.warn("sub %r has lightLevel %r with channel %r "
-                         "and level %r" % (self.uri, lev, chan, val))
-                continue
-            log.debug("   new val %r", val)
-            if val == 0:
-                continue
-            name = self.graph.label(chan)
-            if not name:
-                log.error("sub %r has channel %r with no name- "
-                          "leaving out that channel" % (self.name, chan))
-                continue
-            try:
-                self.levels[name] = float(val)
-            except Exception:
-                log.error("name %r val %r" % (name, val))
-                raise
-
-    def _saveContext(self):
-        """the context in which we should save all the lightLevel triples for
-        this sub"""
-        typeStmt = (self.uri, RDF.type, L9['Submaster'])
-        with self.graph.currentState(tripleFilter=typeStmt) as current:
-            try:
-                log.debug(
-                    "submaster's type statement is in %r so we save there" %
-                    list(current.contextsForStatement(typeStmt)))
-                ctx = current.contextsForStatement(typeStmt)[0]
-            except IndexError:
-                log.info("declaring %s to be a submaster" % self.uri)
-                ctx = self.uri
-                self.graph.patch(
-                    Patch(addQuads=[
-                        (self.uri, RDF.type, L9['Submaster'], ctx),
-                    ]))
-
-        return ctx
-
-    def editLevel(self, chan, newLevel):
-        self.graph.patchMapping(self._saveContext(),
-                                subject=self.uri,
-                                predicate=L9['lightLevel'],
-                                nodeClass=L9['ChannelSetting'],
-                                keyPred=L9['channel'],
-                                newKey=chan,
-                                valuePred=L9['level'],
-                                newValue=Literal(newLevel))
-
-    def clear(self):
-        """set all levels to 0"""
-        with self.graph.currentState() as g:
-            levs = list(g.objects(self.uri, L9['lightLevel']))
-        for lev in levs:
-            self.graph.removeMappingNode(self._saveContext(), lev)
-
-    def allQuads(self):
-        """all the quads for this sub"""
-        quads = []
-        with self.graph.currentState() as current:
-            quads.extend(current.quads((self.uri, None, None)))
-            for s, p, o, c in quads:
-                if p == L9['lightLevel']:
-                    quads.extend(current.quads((o, None, None)))
-        return quads
-
-    def save(self):
-        raise NotImplementedError("obsolete?")
-        if self.temporary:
-            log.info("not saving temporary sub named %s", self.name)
-            return
-
-        graph = Graph()
-        subUri = L9['sub/%s' % self.name]
-        graph.add((subUri, RDFS.label, Literal(self.name)))
-        for chan in list(self.levels.keys()):
-            try:
-                chanUri = get_channel_uri(chan)
-            except KeyError:
-                log.error("saving dmx channels with no :Channel node "
-                          "is not supported yet. Give channel %s a URI "
-                          "for it to be saved. Omitting this channel "
-                          "from the sub." % chan)
-                continue
-            lev = BNode()
-            graph.add((subUri, L9['lightLevel'], lev))
-            graph.add((lev, L9['channel'], chanUri))
-            graph.add((lev, L9['level'],
-                       Literal(self.levels[chan], datatype=XSD['decimal'])))
-
-        graph.serialize(showconfig.subFile(self.name), format="nt")
-
-
-def linear_fade(start, end, amount):
-    """Fades between two floats by an amount.  amount is a float between
-    0 and 1.  If amount is 0, it will return the start value.  If it is 1,
-    the end value will be returned."""
-    level = start + (amount * (end - start))
-    return level
-
-
-def sub_maxes(*subs):
-    nonzero_subs = [s for s in subs if not s.no_nonzero()]
-    name = "max(%s)" % ", ".join([repr(s) for s in nonzero_subs])
-    return Submaster(name,
-                     levels=dict_max(*[sub.levels for sub in nonzero_subs]))
-
-
-def combine_subdict(subdict, name=None, permanent=False):
-    """A subdict is { Submaster objects : levels }.  We combine all
-    submasters first by multiplying the submasters by their corresponding
-    levels and then max()ing them together.  Returns a new Submaster
-    object.  You can give it a better name than the computed one that it
-    will get or make it permanent if you'd like it to be saved to disk.
-    Serves 8."""
-    scaledsubs = [sub * level for sub, level in list(subdict.items())]
-    maxes = sub_maxes(*scaledsubs)
-    if name:
-        maxes.name = name
-    if permanent:
-        maxes.temporary = False
-
-    return maxes
-
-
-class Submasters(object):
-    "Collection o' Submaster objects"
-
-    def __init__(self, graph):
-        self.submasters = {}  # uri : Submaster
-        self.graph = graph
-
-        graph.addHandler(self.findSubs)
-
-    def findSubs(self):
-        current = set()
-
-        for s in self.graph.subjects(RDF.type, L9['Submaster']):
-            if self.graph.contains((s, RDF.type, L9['LocalSubmaster'])):
-                continue
-            log.debug("found sub %s", s)
-            if s not in self.submasters:
-                sub = self.submasters[s] = PersistentSubmaster(self.graph, s)
-                dispatcher.send("new submaster", sub=sub)
-            current.add(s)
-        for s in set(self.submasters.keys()) - current:
-            del self.submasters[s]
-            dispatcher.send("lost submaster", subUri=s)
-        log.info("findSubs finished, %s subs", len(self.submasters))
-
-    def get_all_subs(self):
-        "All Submaster objects"
-        v = sorted(list(self.submasters.items()))
-        v = [x[1] for x in v]
-        songs = []
-        notsongs = []
-        for s in v:
-            if s.name and s.name.startswith('song'):
-                songs.append(s)
-            else:
-                notsongs.append(s)
-        combined = notsongs + songs
-
-        return combined
-
-    def get_sub_by_uri(self, uri):
-        return self.submasters[uri]
-
-    def get_sub_by_name(self, name):
-        return get_sub_by_name(name, self)
-
-
-# a global instance of Submasters, created on demand
-_submasters = None
-
-
-def get_global_submasters(graph):
-    """
-    Get (and make on demand) the global instance of
-    Submasters. Cached, but the cache is not correctly using the graph
-    argument. The first graph you pass will stick in the cache.
-    """
-    global _submasters
-    if _submasters is None:
-        _submasters = Submasters(graph)
-    return _submasters
-
-
-def get_sub_by_name(name, submasters=None):
-    """name is a channel or sub nama, submasters is a Submasters object.
-    If you leave submasters empty, it will use the global instance of
-    Submasters."""
-    if not submasters:
-        submasters = get_global_submasters()
-
-    # get_all_sub_names went missing. needs rework
-    #if name in submasters.get_all_sub_names():
-    #    return submasters.get_sub_by_name(name)
-
-    try:
-        val = int(name)
-        s = Submaster("#%d" % val, levels={val: 1.0})
-        return s
-    except ValueError:
-        pass
-
-    try:
-        subnum = get_dmx_channel(name)
-        s = Submaster("'%s'" % name, levels={subnum: 1.0})
-        return s
-    except ValueError:
-        pass
-
-    # make an error sub
-    return Submaster('%s' % name, levels=ValueError)
--- a/light9/TLUtility.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,229 +0,0 @@
-"""Collected utility functions, many are taken from Drew's utils.py in
-Cuisine CVS and Hiss's Utility.py."""
-
-import sys
-
-__author__ = "David McClosky <dmcc@bigasterisk.com>, " + \
-             "Drew Perttula <drewp@bigasterisk.com>"
-__cvsid__ = "$Id: TLUtility.py,v 1.1 2003/05/25 08:25:35 dmcc Exp $"
-__version__ = "$Revision: 1.1 $" [11:-2]
-
-
-def make_attributes_from_args(*argnames):
-    """
-    This function simulates the effect of running
-      self.foo=foo
-    for each of the given argument names ('foo' in the example just
-    now). Now you can write:
-        def __init__(self,foo,bar,baz):
-            copy_to_attributes('foo','bar','baz')
-            ...
-    instead of:
-        def __init__(self,foo,bar,baz):
-            self.foo=foo
-            self.bar=bar
-            self.baz=baz
-            ... 
-    """
-
-    callerlocals = sys._getframe(1).f_locals
-    callerself = callerlocals['self']
-    for a in argnames:
-        try:
-            setattr(callerself, a, callerlocals[a])
-        except KeyError:
-            raise KeyError("Function has no argument '%s'" % a)
-
-
-def enumerate(*collections):
-    """Generates an indexed series:  (0,coll[0]), (1,coll[1]) ...
-    
-    this is a multi-list version of the code from the PEP:
-    enumerate(a,b) gives (0,a[0],b[0]), (1,a[1],b[1]) ...
-    """
-    i = 0
-    iters = [iter(collection) for collection in collections]
-    while True:
-        yield [
-            i,
-        ] + [next(iterator) for iterator in iters]
-        i += 1
-
-
-def dumpobj(o):
-    """Prints all the object's non-callable attributes"""
-    print(repr(o))
-    for a in [x for x in dir(o) if not callable(getattr(o, x))]:
-        try:
-            print("  %20s: %s " % (a, getattr(o, a)))
-        except Exception:
-            pass
-    print("")
-
-
-def dict_filter_update(d, **newitems):
-    """Adds a set of new keys and values to dictionary 'd' if the values are
-    true:
-
-    >>> some_dict = {}
-    >>> dict_filter_update(some_dict, a=None, b=0, c=1, e={}, s='hello')
-    >>> some_dict
-    {'c': 1, 's': 'hello'}
-    """
-    for k, v in list(newitems.items()):
-        if v: d[k] = v
-
-
-def try_get_logger(channel):
-    """Tries to get a logger with the channel 'channel'.  Will return a
-    silent DummyClass if logging is not available."""
-    try:
-        import logging
-        log = logging.getLogger(channel)
-    except ImportError:
-        log = DummyClass()
-    return log
-
-
-class DummyClass:
-    """A class that can be instantiated but never used.  It is intended to
-    be replaced when information is available.
-    
-    Usage:
-    >>> d = DummyClass(1, 2, x="xyzzy")
-    >>> d.someattr
-    Traceback (most recent call last):
-      File "<stdin>", line 1, in ?
-      File "Utility.py", line 33, in __getattr__
-        raise AttributeError, "Attempted usage of a DummyClass: %s" % key
-    AttributeError: Attempted usage of a DummyClass: someattr
-    >>> d.somefunction()
-    Traceback (most recent call last):
-      File "<stdin>", line 1, in ?
-      File "Utility.py", line 33, in __getattr__
-        raise AttributeError, "Attempted usage of a DummyClass: %s" % key
-    AttributeError: Attempted usage of a DummyClass: somefunction"""
-
-    def __init__(self, use_warnings=1, raise_exceptions=0, **kw):
-        """Constructs a DummyClass"""
-        make_attributes_from_args('use_warnings', 'raise_exceptions')
-
-    def __getattr__(self, key):
-        """Raises an exception to warn the user that a Dummy is not being
-        replaced in time."""
-        if key == "__del__":
-            return
-        msg = "Attempted usage of '%s' on a DummyClass" % key
-        if self.use_warnings:
-            import warnings
-            warnings.warn(msg)
-        if self.raise_exceptions:
-            raise AttributeError(msg)
-        return lambda *args, **kw: self.bogus_function()
-
-    def bogus_function(self):
-        pass
-
-
-class ClassyDict(dict):
-    """A dict that accepts attribute-style access as well (for keys
-    that are legal names, obviously). I used to call this Struct, but
-    chose the more colorful name to avoid confusion with the struct
-    module."""
-
-    def __getattr__(self, a):
-        return self[a]
-
-    def __setattr__(self, a, v):
-        self[a] = v
-
-    def __delattr__(self, a):
-        del self[a]
-
-
-def trace(func):
-    """Good old fashioned Lisp-style tracing.  Example usage:
-    
-    >>> def f(a, b, c=3):
-    >>>     print a, b, c
-    >>>     return a + b
-    >>>
-    >>>
-    >>> f = trace(f)
-    >>> f(1, 2)
-    |>> f called args: [1, 2]
-    1 2 3
-    <<| f returned 3
-    3
-
-    TODO: print out default keywords (maybe)
-          indent for recursive call like the lisp version (possible use of 
-              generators?)"""
-    name = func.__name__
-
-    def tracer(*args, **kw):
-        s = '|>> %s called' % name
-        if args:
-            s += ' args: %r' % list(args)
-        if kw:
-            s += ' kw: %r' % kw
-        print(s)
-        ret = func(*args, **kw)
-        print('<<| %s returned %s' % (name, ret))
-        return ret
-
-    return tracer
-
-
-# these functions taken from old light8 code
-def dict_max(*dicts):
-    """
-    ({'a' : 5, 'b' : 9}, {'a' : 10, 'b' : 4})
-      returns ==> {'a' : 10, 'b' : 9}
-    """
-    newdict = {}
-    for d in dicts:
-        for k, v in list(d.items()):
-            newdict[k] = max(v, newdict.get(k, 0))
-    return newdict
-
-
-def dict_scale(d, scl):
-    """scales all values in dict and returns a new dict"""
-    return dict([(k, v * scl) for k, v in d.items()])
-
-
-def dict_subset(d, dkeys, default=0):
-    """Subset of dictionary d: only the keys in dkeys.  If you plan on omitting
-    keys, make sure you like the default."""
-    newd = {}  # dirty variables!
-    for k in dkeys:
-        newd[k] = d.get(k, default)
-    return newd
-
-
-# functions specific to Timeline
-# TBD
-def last_less_than(array, x):
-    """array must be sorted"""
-    best = None
-    for elt in array:
-        if elt <= x:
-            best = elt
-        elif best is not None:
-            return best
-    return best
-
-
-# TBD
-def first_greater_than(array, x):
-    """array must be sorted"""
-    array_rev = array[:]
-    array_rev.reverse()
-    best = None
-    for elt in array_rev:
-        if elt >= x:
-            best = elt
-        elif best is not None:
-            return best
-    return best
--- a/light9/ascoltami/main.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,84 +0,0 @@
-#!bin/python
-import logging
-import optparse
-import sys
-from typing import cast
-
-import gi
-from rdflib import URIRef
-from starlette.applications import Starlette
-from starlette.routing import Route
-from starlette_exporter import PrometheusMiddleware, handle_metrics
-from twisted.internet import reactor
-from twisted.internet.interfaces import IReactorCore
-
-from light9.run_local import log
-
-gi.require_version('Gst', '1.0')
-gi.require_version('Gtk', '3.0')
-
-from gi.repository import Gst  # type: ignore
-
-from light9 import networking, showconfig
-from light9.ascoltami import webapp
-from light9.ascoltami.player import Player
-from light9.ascoltami.playlist import NoSuchSong, Playlist
-
-reactor = cast(IReactorCore, reactor)
-
-
-class Ascoltami:
-
-    def __init__(self, graph, show):
-        self.graph = graph
-        self.player = Player(onEOS=self.onEOS, autoStopOffset=0)
-        self.show = show
-        self.playlist = Playlist.fromShow(graph, show)
-
-    def onEOS(self, song):
-        self.player.pause()
-        self.player.seek(0)
-
-        thisSongUri = webapp.songUri(self.graph, URIRef(song))
-
-        try:
-            nextSong = self.playlist.nextSong(thisSongUri)
-        except NoSuchSong:  # we're at the end of the playlist
-            return
-
-        self.player.setSong(webapp.songLocation(self.graph, nextSong), play=False)
-
-
-def main():
-    logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
-    Gst.init(None)
-
-    graph = showconfig.getGraph()
-    asco = Ascoltami(graph, showconfig.showUri())
-
-    app = Starlette(
-        debug=True,
-        routes=[
-            Route("/config", webapp.get_config),
-            Route("/time", webapp.get_time, methods=["GET"]),
-            Route("/time", webapp.post_time, methods=["POST"]),
-            Route("/time/stream", webapp.timeStream),
-            Route("/song", webapp.post_song, methods=["POST"]),
-            Route("/songs", webapp.get_songs),
-            Route("/seekPlayOrPause", webapp.post_seekPlayOrPause),
-            Route("/output", webapp.post_output, methods=["POST"]),
-            Route("/go", webapp.post_goButton, methods=["POST"]),
-        ],
-    )
-
-    app.add_middleware(PrometheusMiddleware)
-    app.add_route("/metrics", handle_metrics)
-
-    app.state.graph = graph
-    app.state.show = asco.show
-    app.state.player = asco.player
-
-    return app
-
-
-app = main()
--- a/light9/ascoltami/main_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-
-from light9.run_local import log
-
-
-def test_import():
-    import light9.ascoltami.main
-    
\ No newline at end of file
--- a/light9/ascoltami/musictime_client.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,98 +0,0 @@
-import time, json, logging
-from typing import Dict, cast
-from twisted.internet.interfaces import IReactorTime
-
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks
-import treq
-
-from light9 import networking
-
-log = logging.getLogger()
-
-
-class MusicTime:
-    """
-    fetch times from ascoltami in a background thread; return times
-    upon request, adjusted to be more precise with the system clock
-    """
-
-    def __init__(self, period=.2, onChange=lambda position: None, pollCurvecalc='ignored'):
-        """period is the seconds between
-        http time requests.
-
-        We call onChange with the time in seconds and the total time
-
-        The choice of period doesn't need to be tied to framerate,
-        it's more the size of the error you can tolerate (since we
-        make up times between the samples, and we'll just run off the
-        end of a song)
-        """
-        self.positionFetchTime = 0
-        self.period = period
-        self.hoverPeriod = .05
-        self.onChange = onChange
-
-        self.position: Dict[str, float] = {}
-        # driven by our pollCurvecalcTime and also by Gui.incomingTime
-        self.lastHoverTime = None  # None means "no recent value"
-        self.pollMusicTime()
-
-    def getLatest(self, frameTime=None) -> Dict:
-        """
-        dict with 't' and 'song', etc.
-
-        frameTime is the timestamp from the camera, which will be used
-        instead of now.
-
-        Note that this may be called in a gst camera capture thread. Very often.
-        """
-        if not hasattr(self, 'position'):
-            return {'t': 0, 'song': None}
-        pos = self.position.copy()
-        now = frameTime or time.time()
-        if pos.get('playing'):
-            pos['t'] = pos['t'] + (now - self.positionFetchTime)
-        else:
-            if self.lastHoverTime is not None:
-                pos['hoverTime'] = self.lastHoverTime
-        return pos
-
-    def pollMusicTime(self):
-
-        @inlineCallbacks
-        def cb(response):
-
-            if response.code != 200:
-                raise ValueError("%s %s", response.code, (yield response.content()))
-
-            position = yield response.json()
-
-            # this is meant to be the time when the server gave me its
-            # report, and I don't know if that's closer to the
-            # beginning of my request or the end of it (or some
-            # fraction of the way through)
-            self.positionFetchTime = time.time()
-
-            self.position = position
-            self.onChange(position)
-
-            cast(IReactorTime, reactor).callLater(self.period, self.pollMusicTime) # type: ignore
-
-        def eb(err):
-            log.warn("talking to ascoltami: %s", err.getErrorMessage())
-            cast(IReactorTime, reactor).callLater(2, self.pollMusicTime) # type: ignore
-
-        d = treq.get(networking.musicPlayer.path("time").toPython())
-        d.addCallback(cb)
-        d.addErrback(eb)  # note this includes errors in cb()
-
-    def sendTime(self, t):
-        """request that the player go to this time"""
-        treq.post(
-            networking.musicPlayer.path('time'),
-            data=json.dumps({
-                "t": time
-            }).encode('utf8'),
-            headers={b"content-type": [b"application/json"]},
-        )
--- a/light9/ascoltami/player.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-#!/usr/bin/python
-"""
-alternate to the mpd music player, for ascoltami
-"""
-
-import time, logging, traceback
-from gi.repository import Gst # type: ignore
-from twisted.internet import task
-from light9.metrics import metrics
-log = logging.getLogger()
-
-
-
-class Player:
-
-    def __init__(self, autoStopOffset=4, onEOS=None):
-        """autoStopOffset is the number of seconds before the end of
-        song before automatically stopping (which is really pausing).
-        onEOS is an optional function to be called when we reach the
-        end of a stream (for example, can be used to advance the song).
-        It is called with one argument which is the URI of the song that
-        just finished."""
-        self.autoStopOffset = autoStopOffset
-        self.playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
-
-        self.playStartTime = 0
-        self.lastWatchTime = 0
-        self.autoStopTime = 0
-        self.lastSetSongUri = None
-        self.onEOS = onEOS
-
-        task.LoopingCall(self.watchTime).start(.050)
-
-        #bus = self.pipeline.get_bus()
-        # not working- see notes in pollForMessages
-        #self.watchForMessages(bus)
-
-    def watchTime(self):
-        try:
-            self.pollForMessages()
-
-            t = self.currentTime()
-            log.debug("watch %s < %s < %s", self.lastWatchTime,
-                      self.autoStopTime, t)
-            if self.lastWatchTime < self.autoStopTime < t:
-                log.info("autostop")
-                self.pause()
-
-            self.lastWatchTime = t
-        except Exception:
-            traceback.print_exc()
-
-    def watchForMessages(self, bus):
-        """this would be nicer than pollForMessages but it's not working for
-        me. It's like add_signal_watch isn't running."""
-        bus.add_signal_watch()
-
-        def onEos(*args):
-            print("onEos", args)
-            if self.onEOS is not None:
-                self.onEOS(self.getSong())
-
-        bus.connect('message::eos', onEos)
-
-        def onStreamStatus(bus, message):
-            print("streamstatus", bus, message)
-            (statusType, _elem) = message.parse_stream_status()
-            if statusType == Gst.StreamStatusType.ENTER:
-                self.setupAutostop()
-
-        bus.connect('message::stream-status', onStreamStatus)
-
-    def pollForMessages(self):
-        """bus.add_signal_watch seems to be having no effect, but this works"""
-        bus = self.pipeline.get_bus()
-        mt = Gst.MessageType
-        msg = bus.poll(
-            mt.EOS | mt.STREAM_STATUS | mt.ERROR,  # | mt.ANY,
-            0)
-        if msg is not None:
-            log.debug("bus message: %r %r", msg.src, msg.type)
-            # i'm trying to catch here a case where the pulseaudio
-            # output has an error, since that's otherwise kind of
-            # mysterious to diagnose. I don't think this is exactly
-            # working.
-            if msg.type == mt.ERROR:
-                log.error(repr(msg.parse_error()))
-            if msg.type == mt.EOS:
-                if self.onEOS is not None:
-                    self.onEOS(self.getSong())
-            if msg.type == mt.STREAM_STATUS:
-                (statusType, _elem) = msg.parse_stream_status()
-                if statusType == Gst.StreamStatusType.ENTER:
-                    self.setupAutostop()
-
-    def seek(self, t):
-        isSeekable = self.playbin.seek_simple(
-            Gst.Format.TIME,
-            Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE | Gst.SeekFlags.SKIP,
-            t * Gst.SECOND)
-        if not isSeekable:
-            raise ValueError('seek_simple failed')
-        self.playStartTime = time.time()
-
-    def setSong(self, songLoc, play=True):
-        """
-        uri like file:///my/proj/light9/show/dance2010/music/07.wav
-        """
-        log.info("set song to %r" % songLoc)
-        self.pipeline.set_state(Gst.State.READY)
-        self.preload(songLoc)
-        self.pipeline.set_property("uri", songLoc)
-        self.lastSetSongUri = songLoc
-        # todo: don't have any error report yet if the uri can't be read
-        if play:
-            self.pipeline.set_state(Gst.State.PLAYING)
-            self.playStartTime = time.time()
-
-    def getSong(self):
-        """Returns the URI of the current song."""
-        # even the 'uri' that I just set isn't readable yet
-        return self.playbin.get_property("uri") or self.lastSetSongUri
-
-    def preload(self, songPath):
-        """
-        to avoid disk seek stutters, which happened sometimes (in 2007) with the
-        non-gst version of this program, we read the whole file to get
-        more OS caching.
-
-        i don't care that it's blocking.
-        """
-        log.info("preloading %s", songPath)
-        assert songPath.startswith("file://"), songPath
-        try:
-            open(songPath[len("file://"):], 'rb').read()
-        except IOError as e:
-            log.error("couldn't preload %s, %r", songPath, e)
-            raise
-
-    @metrics('current_time').time()
-    def currentTime(self):
-        success, cur = self.playbin.query_position(Gst.Format.TIME)
-        if not success:
-            return 0
-        return cur / Gst.SECOND
-
-    def duration(self):
-        success, dur = self.playbin.query_duration(Gst.Format.TIME)
-        if not success:
-            return 0
-        return dur / Gst.SECOND
-
-    def states(self):
-        """json-friendly object describing the interesting states of
-        the player nodes"""
-        success, state, pending = self.playbin.get_state(timeout=0)
-        return {
-            "current": {
-                "name": state.value_nick
-            },
-            "pending": {
-                "name": state.value_nick
-            }
-        }
-
-    def pause(self):
-        self.pipeline.set_state(Gst.State.PAUSED)
-
-    def isAutostopped(self):
-        """
-        are we stopped at the autostop time?
-        """
-        if self.autoStopOffset < .01:
-            return False
-        pos = self.currentTime()
-        autoStop = self.duration() - self.autoStopOffset
-        return not self.isPlaying() and abs(
-            pos - autoStop) < 1  # i've seen .4 difference here
-
-    def resume(self):
-        self.pipeline.set_state(Gst.State.PLAYING)
-
-    def setupAutostop(self):
-        dur = self.duration()
-        if dur == 0:
-            raise ValueError("duration=0, can't set autostop")
-        self.autoStopTime = (dur - self.autoStopOffset)
-        log.info("autostop will be at %s", self.autoStopTime)
-        # pipeline.seek can take a stop time, but using that wasn't
-        # working out well. I'd get pauses at other times that were
-        # hard to remove.
-
-    def isPlaying(self):
-        _, state, _ = self.pipeline.get_state(timeout=0)
-        return state == Gst.State.PLAYING
--- a/light9/ascoltami/playlist.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-from light9.showconfig import songOnDisk
-from light9.namespaces import L9
-
-
-class NoSuchSong(ValueError):
-    """Raised when a song is requested that doesn't exist (e.g. one
-    after the last song in the playlist)."""
-
-
-class Playlist:
-
-    def __init__(self, graph, playlistUri):
-        self.graph = graph
-        self.playlistUri = playlistUri
-        self.songs = list(graph.items(playlistUri))
-
-    def nextSong(self, currentSong):
-        """Returns the next song in the playlist or raises NoSuchSong if 
-        we are at the end of the playlist."""
-        try:
-            currentIndex = self.songs.index(currentSong)
-        except IndexError:
-            raise ValueError("%r is not in the current playlist (%r)." %
-                             (currentSong, self.playlistUri))
-
-        try:
-            nextSong = self.songs[currentIndex + 1]
-        except IndexError:
-            raise NoSuchSong("%r is the last item in the playlist." %
-                             currentSong)
-
-        return nextSong
-
-    def allSongs(self):
-        """Returns a list of all song URIs in order."""
-        return self.songs
-
-    def allSongPaths(self):
-        """Returns a list of the filesystem paths to all songs in order."""
-        paths = []
-        for song in self.songs:
-            paths.append(songOnDisk(song))
-        return paths
-
-    def songPath(self, uri):
-        """filesystem path to a song"""
-        raise NotImplementedError("see showconfig.songOnDisk")
-        # maybe that function should be moved to this method
-
-    @classmethod
-    def fromShow(cls, graph, show):
-        playlistUri = graph.value(show, L9['playList'])
-        if not playlistUri:
-            raise ValueError("%r has no l9:playList" % show)
-        return cls(graph, playlistUri)
--- a/light9/ascoltami/webapp.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,188 +0,0 @@
-import asyncio
-import json
-import logging
-import socket
-import subprocess
-import time
-from typing import cast
-
-from rdflib import RDFS, Graph, URIRef
-from light9.ascoltami.player import Player
-from sse_starlette.sse import EventSourceResponse
-from starlette.requests import Request
-from starlette.responses import JSONResponse, PlainTextResponse
-
-from light9.namespaces import L9
-from light9.showconfig import getSongsFromShow, showUri, songOnDisk
-
-log = logging.getLogger()
-_songUris = {}  # locationUri : song
-
-
-def songLocation(graph, songUri):
-    loc = URIRef("file://%s" % songOnDisk(songUri))
-    _songUris[loc] = songUri
-    return loc
-
-
-def songUri(graph, locationUri):
-    return _songUris[locationUri]
-
-
-async def get_config(request: Request) -> JSONResponse:
-    return JSONResponse(
-        dict(
-            host=socket.gethostname(),
-            show=str(showUri()),
-            times={
-                # these are just for the web display. True values are on Player.__init__
-                'intro': 4,
-                'post': 0
-            }))
-
-
-def playerSongUri(graph, player):
-    """or None"""
-
-    playingLocation = player.getSong()
-    if playingLocation:
-        return songUri(graph, URIRef(playingLocation))
-    else:
-        return None
-
-
-def currentState(graph, player):
-    if player.isAutostopped():
-        nextAction = 'finish'
-    elif player.isPlaying():
-        nextAction = 'disabled'
-    else:
-        nextAction = 'play'
-
-    return {
-        "song": playerSongUri(graph, player),
-        "started": player.playStartTime,
-        "duration": player.duration(),
-        "playing": player.isPlaying(),
-        "t": player.currentTime(),
-        "state": player.states(),
-        "next": nextAction,
-    }
-
-
-async def get_time(request: Request) -> JSONResponse:
-    player = cast(Player, request.app.state.player)
-    graph = cast(Graph, request.app.state.graph)
-    return JSONResponse(currentState(graph, player))
-
-
-async def post_time(request: Request) -> PlainTextResponse:
-    """
-    post a json object with {pause: true} or {resume: true} if you
-    want those actions. Use {t: <seconds>} to seek, optionally
-    with a pause/resume command too.
-    """
-    params = await request.json()
-    player = cast(Player, request.app.state.player)
-    if params.get('pause', False):
-        player.pause()
-    if params.get('resume', False):
-        player.resume()
-    if 't' in params:
-        player.seek(params['t'])
-    return PlainTextResponse("ok")
-
-
-async def timeStream(request: Request):
-    graph = cast(Graph, request.app.state.graph)
-    player = cast(Player, request.app.state.player)
-    async def event_generator():
-        last_sent = None
-        last_sent_time = 0.0
-
-        while True:
-            now = time.time()
-            msg = currentState(graph, player)
-            if msg != last_sent or now > last_sent_time + 2:
-                event_data = json.dumps(msg)
-                yield event_data
-                last_sent = msg
-                last_sent_time = now
-
-            await asyncio.sleep(0.1)
-
-    return EventSourceResponse(event_generator())
-
-
-async def get_songs(request: Request) -> JSONResponse:
-    graph = cast(Graph, request.app.state.graph)
-
-    songs = getSongsFromShow(graph, request.app.state.show)
-
-    songs_data = [
-        {  #
-            "uri": s,
-            "path": graph.value(s, L9['songFilename']),
-            "label": graph.value(s, RDFS.label)
-        } for s in songs
-    ]
-
-    return JSONResponse({"songs": songs_data})
-
-
-async def post_song(request: Request) -> PlainTextResponse:
-    """post a uri of song to switch to (and start playing)"""
-    graph = cast(Graph, request.app.state.graph)
-    player = cast(Player, request.app.state.player)
-
-    song_uri = URIRef((await request.body()).decode('utf8'))
-    player.setSong(songLocation(graph, song_uri))
-
-    return PlainTextResponse("ok")
-
-
-async def post_seekPlayOrPause(request: Request) -> PlainTextResponse:
-    """curveCalc's ctrl-p or a vidref scrub"""
-    player = cast(Player, request.app.state.player)
-
-    data = await request.json()
-    if 'scrub' in data:
-        player.pause()
-        player.seek(data['scrub'])
-        return PlainTextResponse("ok")
-    if 'action' in data:
-        if data['action'] == 'play':
-            player.resume()
-        elif data['action'] == 'pause':
-            player.pause()
-        else:
-            raise NotImplementedError
-        return PlainTextResponse("ok")
-    if player.isPlaying():
-        player.pause()
-    else:
-        player.seek(data['t'])
-        player.resume()
-
-    return PlainTextResponse("ok")
-
-
-async def post_output(request: Request) -> PlainTextResponse:
-    d = await request.json()
-    subprocess.check_call(["bin/movesinks", str(d['sink'])])
-    return PlainTextResponse("ok")
-
-
-async def post_goButton(request: Request) -> PlainTextResponse:
-    """
-    if music is playing, this silently does nothing.
-    """
-    player = cast(Player, request.app.state.player)
-
-    if player.isAutostopped():
-        player.resume()
-    elif player.isPlaying():
-        pass
-    else:
-        player.resume()
-    return PlainTextResponse("ok")
--- a/light9/ascoltami/webapp_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-# todo
-# test that GET /songs doesn't break, etc
--- a/light9/chase.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,44 +0,0 @@
-def chase(t,
-          ontime=0.5,
-          offset=0.2,
-          onval=1.0,
-          offval=0.0,
-          names=None,
-          combiner=max):
-    names = names or []
-    # maybe this is better:
-    # period = ontime + ((offset + ontime) * (len(names) - 1))
-    period = (offset + ontime) * len(names)
-    outputs = {}
-    for index, name in enumerate(names):
-        # normalize our time
-        local_offset = (offset + ontime) * index
-        local_t = t - local_offset
-        local_t %= period
-
-        # see if we're still in the on part
-        if local_t <= ontime:
-            value = onval
-        else:
-            value = offval
-
-        # it could be in there twice (in a bounce like (1, 2, 3, 2)
-        if name in outputs:
-            outputs[name] = combiner(value, outputs[name])
-        else:
-            outputs[name] = value
-    return outputs
-
-
-if __name__ == "__main__":
-    # a little testing
-    for x in range(80):
-        x /= 20.0
-        output = chase(x,
-                       onval='x',
-                       offval=' ',
-                       ontime=0.1,
-                       offset=0.2,
-                       names=('a', 'b', 'c', 'd'))
-        output = sorted(list(output.items()))
-        print("%.2f\t%s" % (x, ' '.join([str(x) for x in output])))
--- a/light9/clientsession.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-"""
-some clients will support the concept of a named session that keeps
-multiple instances of that client separate
-"""
-from rdflib import URIRef
-from urllib.parse import quote
-from light9 import showconfig
-
-
-def add_option(parser):
-    parser.add_option(
-        '-s',
-        '--session',
-        help="name of session used for levels and window position",
-        default='default')
-
-
-def getUri(appName, opts):
-    return URIRef("%s/sessions/%s/%s" %
-                  (showconfig.showUri(), appName, quote(opts.session, safe='')))
--- a/light9/coffee.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-from cycloneerr import PrettyErrorHandler
-import cyclone.web
-import subprocess
-
-
-class StaticCoffee(PrettyErrorHandler, cyclone.web.RequestHandler):
-    """
-    e.g.
-
-            (r'/effect\.js', StaticCoffee, {
-                'src': 'light9/effecteval/effect.coffee'
-            }),
-    """ # noqa
-
-    def initialize(self, src):
-        super(StaticCoffee, self).initialize()
-        self.src = src
-
-    def get(self):
-        self.set_header('Content-Type', 'application/javascript')
-        self.write(
-            subprocess.check_output(
-                ['/usr/bin/coffee', '--compile', '--print', self.src]))
--- a/light9/collector/collector.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,221 +0,0 @@
-import logging
-import time
-from typing import Dict, List, Set, Tuple, cast
-from light9.typedgraph import typedValue
-
-from prometheus_client import Summary
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import URIRef
-
-from light9.collector.device import resolve, toOutputAttrs
-from light9.collector.output import Output as OutputInstance
-from light9.collector.weblisteners import WebListeners
-from light9.effect.settings import DeviceSettings
-from light9.namespaces import L9, RDF
-from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceClass, DeviceSetting, DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr,
-                             OutputRange, OutputUri, OutputValue, UnixTime, VTUnion, uriTail)
-
-log = logging.getLogger('collector')
-
-STAT_SETATTR = Summary('set_attr', 'setAttr calls')
-
-def makeDmxMessageIndex(base: DmxIndex, offset: DmxIndex) -> DmxMessageIndex:
-    return DmxMessageIndex(base + offset - 1)
-
-
-def _outputMap(graph: SyncedGraph, outputs: Set[OutputUri]) -> Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]:
-    """From rdf config graph, compute a map of
-       (device, outputattr) : (output, index)
-    that explains which output index to set for any device update.
-    """
-    ret = cast(Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]], {})
-
-    for dc in graph.subjects(RDF.type, L9['DeviceClass']):
-        log.info('  mapping devices of class %s', dc)
-        for dev in graph.subjects(RDF.type, dc):
-            dev = cast(DeviceUri, dev)
-            log.info('    💡 mapping device %s', dev)
-            universe = typedValue(OutputUri, graph, dev, L9['dmxUniverse'])
-            if universe not in outputs:
-                raise ValueError(f'{dev=} is configured to be in {universe=}, but we have no Output for that universe')
-            try:
-                dmxBase = typedValue(DmxIndex, graph, dev, L9['dmxBase'])
-            except ValueError:
-                raise ValueError('no :dmxBase for %s' % dev)
-
-            for row in sorted(graph.objects(dc, L9['attr']), key=str):
-                outputAttr = typedValue(OutputAttr, graph, row, L9['outputAttr'])
-                offset = typedValue(DmxIndex, graph, row, L9['dmxOffset'])
-                index = makeDmxMessageIndex(dmxBase, offset)
-                ret[(dev, outputAttr)] = (universe, index)
-                log.info(f'      {uriTail(outputAttr):15} maps to {uriTail(universe)} index {index}')
-    return ret
-
-
-class Collector:
-    """receives setAttrs calls; combines settings; renders them into what outputs like; calls Output.update"""
-
-    def __init__(self, graph: SyncedGraph, outputs: List[OutputInstance], listeners: WebListeners, clientTimeoutSec: float = 10):
-        self.graph = graph
-        self.outputs = outputs
-        self.listeners = listeners
-        self.clientTimeoutSec = clientTimeoutSec
-
-        self._initTime = time.time()
-        self._outputByUri: Dict[OutputUri, OutputInstance] = {}
-        self._deviceType: Dict[DeviceUri, DeviceClass] = {}
-        self.remapOut: Dict[Tuple[DeviceUri, OutputAttr], OutputRange] = {}
-
-        self.graph.addHandler(self._compile)
-
-        # rename to activeSessons ?
-        self.lastRequest: Dict[Tuple[ClientType, ClientSessionType], Tuple[UnixTime, Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]]] = {}
-
-        # (dev, devAttr): value to use instead of 0
-        self.stickyAttrs: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
-
-    def _compile(self):
-        log.info('Collector._compile:')
-        self._outputByUri = self._compileOutputByUri()
-        self._outputMap = _outputMap(self.graph, set(self._outputByUri.keys()))
-
-        self._deviceType.clear()
-        self.remapOut.clear()
-        for dc in self.graph.subjects(RDF.type, L9['DeviceClass']):
-            dc = cast(DeviceClass, dc)
-            for dev in self.graph.subjects(RDF.type, dc):
-                dev = cast(DeviceUri, dev)
-                self._deviceType[dev] = dc
-                self._compileRemapForDevice(dev)
-
-    def _compileOutputByUri(self) -> Dict[OutputUri, OutputInstance]:
-        ret = {}
-        for output in self.outputs:
-            ret[OutputUri(output.uri)] = output
-        return ret
-
-    def _compileRemapForDevice(self, dev: DeviceUri):
-        for remap in self.graph.objects(dev, L9['outputAttrRange']):
-            attr = typedValue(OutputAttr, self.graph, remap, L9['outputAttr'])
-            start = typedValue(float, self.graph, remap, L9['start'])
-            end = typedValue(float, self.graph, remap, L9['end'])
-            self.remapOut[(dev, attr)] = OutputRange((start, end))
-
-    @STAT_SETATTR.time()
-    def setAttrs(self, client: ClientType, clientSession: ClientSessionType, settings: DeviceSettings, sendTime: UnixTime):
-        """
-        Given DeviceSettings, we resolve conflicting values,
-        process them into output attrs, and call Output.update
-        to send the new outputs.
-
-        client is a string naming the type of client.
-        (client, clientSession) is a unique client instance.
-        clientSession is deprecated.
-
-        Each client session's last settings will be forgotten
-        after clientTimeoutSec.
-        """
-        # todo: cleanup session code if we really don't want to be able to run multiple sessions of one client
-        clientSession = ClientSessionType("no_longer_used")
-
-        now = UnixTime(time.time())
-        self._warnOnLateRequests(client, now, sendTime)
-
-        self._forgetStaleClients(now)
-
-        self.lastRequest[(client, clientSession)] = (now, self._resolvedSettingsDict(settings))
-
-        deviceAttrs = self._merge(iter(self.lastRequest.values()))
-
-        outputAttrsByDevice = self._convertToOutputAttrsPerDevice(deviceAttrs)
-        pendingOut = self._flattenDmxOutput(outputAttrsByDevice)
-
-        t2 = time.time()
-
-        self._updateOutputs(pendingOut)
-
-        t3 = time.time()
-        if t2 - now > .030 or t3 - t2 > .030:
-            log.warning("slow setAttrs: prepare %.1fms -> updateOutputs %.1fms" % ((t2 - now) * 1000, (t3 - t2) * 1000))
-
-    def _warnOnLateRequests(self, client, now, sendTime):
-        requestLag = now - sendTime
-        if requestLag > .1 and now > self._initTime + 10 and getattr(self, '_lastWarnTime', 0) < now - 3:
-            self._lastWarnTime = now
-            log.warning('collector.setAttrs from %s is running %.1fms after the request was made', client, requestLag * 1000)
-
-    def _forgetStaleClients(self, now):
-        staleClientSessions = []
-        for clientSession, (reqTime, _) in self.lastRequest.items():
-            if reqTime < now - self.clientTimeoutSec:
-                staleClientSessions.append(clientSession)
-        for clientSession in staleClientSessions:
-            log.info('forgetting stale client %r', clientSession)
-            del self.lastRequest[clientSession]
-
-    # todo: move to settings.py
-    def _resolvedSettingsDict(self, settingsList: DeviceSettings) -> Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]:
-        out: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
-        for devUri, devAttr, val in settingsList.asList():
-            if (devUri, devAttr) in out:
-                existingVal = out[(devUri, devAttr)]
-                out[(devUri, devAttr)] = resolve(self._deviceType[devUri], devAttr, [existingVal, val])
-            else:
-                out[(devUri, devAttr)] = val
-        return out
-
-    def _merge(self, lastRequests):
-        deviceAttrs: Dict[DeviceUri, Dict[DeviceAttr, VTUnion]] = {}  # device: {deviceAttr: value}
-        for _, lastSettings in lastRequests:
-            for (device, deviceAttr), value in lastSettings.items():
-                if (device, deviceAttr) in self.remapOut:
-                    start, end = self.remapOut[(device, deviceAttr)]
-                    value = start + float(value) * (end - start)
-
-                attrs = deviceAttrs.setdefault(device, {})
-                if deviceAttr in attrs:
-                    value = resolve(device, deviceAttr, [attrs[deviceAttr], value])
-                attrs[deviceAttr] = value
-                # list should come from the graph. these are attrs
-                # that should default to holding the last position,
-                # not going to 0.
-                if deviceAttr in [L9['rx'], L9['ry'], L9['zoom'], L9['focus']]:
-                    self.stickyAttrs[(device, deviceAttr)] = cast(float, value)
-
-        # e.g. don't let an unspecified rotation go to 0
-        for (d, da), v in self.stickyAttrs.items():
-            daDict = deviceAttrs.setdefault(d, {})
-            if da not in daDict:
-                daDict[da] = v
-
-        return deviceAttrs
-
-    def _convertToOutputAttrsPerDevice(self, deviceAttrs):
-        ret: Dict[DeviceUri, Dict[OutputAttr, OutputValue]] = {}
-        for d, devType in self._deviceType.items():
-            try:
-                ret[d] = toOutputAttrs(devType, deviceAttrs.get(d, {}))
-                self.listeners.outputAttrsSet(d, ret[d], self._outputMap)
-            except Exception as e:
-                log.error('failing toOutputAttrs on %s: %r', d, e)
-        return ret
-
-    def _flattenDmxOutput(self, outputAttrs: Dict[DeviceUri, Dict[OutputAttr, OutputValue]]) -> Dict[OutputUri, bytearray]:
-        pendingOut = cast(Dict[OutputUri, bytearray], {})
-        for outUri in self._outputByUri.keys():
-            pendingOut[outUri] = bytearray(512)
-
-        for device, attrs in outputAttrs.items():
-            for outputAttr, value in attrs.items():
-                outputUri, _index = self._outputMap[(device, outputAttr)]
-                index = DmxMessageIndex(_index)
-                outArray = pendingOut[outputUri]
-                if outArray[index] != 0:
-                    log.warning(f'conflict: {outputUri} output array was already nonzero at 0-based index {index}')
-                    raise ValueError(f"someone already wrote to index {index}")
-                outArray[index] = value
-        return pendingOut
-
-    def _updateOutputs(self, pendingOut: Dict[OutputUri, bytearray]):
-        for uri, buf in pendingOut.items():
-            self._outputByUri[uri].update(bytes(buf))
--- a/light9/collector/collector_client.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-from light9 import networking
-from light9.effect.settings import DeviceSettings
-from light9.metrics import metrics
-from twisted.internet import defer
-from txzmq import ZmqEndpoint, ZmqFactory, ZmqPushConnection
-import json, time, logging
-import treq
-
-log = logging.getLogger('coll_client')
-
-
-        # d = treq.put(networking.collector.path('attrs'), data=msg, timeout=1)
--- a/light9/collector/collector_client_asyncio.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,42 +0,0 @@
-import asyncio
-import json
-import logging
-import time
-from light9 import networking
-from light9.effect.settings import DeviceSettings
-import zmq.asyncio
-from prometheus_client import Summary
-
-log = logging.getLogger('coll_client')
-
-ZMQ_SEND = Summary('zmq_send', 'calls')
-
-
-def toCollectorJson(client, session, settings: DeviceSettings) -> str:
-    assert isinstance(settings, DeviceSettings)
-    return json.dumps({
-        'settings': settings.asList(),
-        'client': client,
-        'clientSession': session,
-        'sendTime': time.time(),
-    })
-
-
-class _Sender:
-
-    def __init__(self):
-        self.context = zmq.asyncio.Context()
-        self.socket = self.context.socket(zmq.PUB)
-        self.socket.connect('tcp://127.0.0.1:9203')  #todo: tie to :collectorZmq in graph
-        # old version used: 'tcp://%s:%s' % (service.host, service.port)
-
-    @ZMQ_SEND.time()
-    async def send(self, client: str, session: str, settings: DeviceSettings):
-        msg = toCollectorJson(client, session, settings).encode('utf8')
-        # log.info(f'zmq send {len(msg)}')
-        await self.socket.send_multipart([b'setAttr', msg])
-
-
-_sender = _Sender()
-
-sendToCollector = _sender.send
--- a/light9/collector/collector_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,204 +0,0 @@
-import datetime
-import time
-import unittest
-
-from freezegun import freeze_time
-from light9.effect.settings import DeviceSettings
-from rdflib import Namespace
-
-from light9.collector.collector import Collector
-from light9.collector.output import Output
-from light9.collector.weblisteners import WebListeners
-from light9.mock_syncedgraph import MockSyncedGraph
-from light9.namespaces import DEV, L9
-from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceUri, HexColor, UnixTime)
-
-UDMX = Namespace('http://light9.bigasterisk.com/output/udmx/')
-DMX0 = Namespace('http://light9.bigasterisk.com/output/dmx0/')
-
-PREFIX = '''
-   @prefix : <http://light9.bigasterisk.com/> .
-        @prefix dev: <http://light9.bigasterisk.com/device/> .
-        @prefix udmx: <http://light9.bigasterisk.com/output/udmx/> .
-        @prefix dmx0: <http://light9.bigasterisk.com/output/dmx0/> .
-'''
-
-THEATER = '''
-        :brightness         a :DeviceAttr; :dataType :scalar .
-
-        :SimpleDimmer a :DeviceClass;
-          :deviceAttr :brightness;
-          :attr
-            [ :outputAttr :level; :dmxOffset 0 ] .
-
-        :ChauvetColorStrip a :DeviceClass;
-          :deviceAttr :color;
-          :attr
-            [ :outputAttr :mode;  :dmxOffset 0 ],
-            [ :outputAttr :red;   :dmxOffset 1 ],
-            [ :outputAttr :green; :dmxOffset 2 ],
-            [ :outputAttr :blue;  :dmxOffset 3 ] .
-
-'''
-
-t0 = UnixTime(0)
-client1 = ClientType('client1')
-client2 = ClientType('client2')
-session1 = ClientSessionType('sess1')
-session2 = ClientSessionType('sess2')
-colorStrip = DeviceUri(DEV['colorStrip'])
-inst1 = DeviceUri(DEV['inst1'])
-brightness = DeviceAttr(L9['brightness'])
-color = DeviceAttr(L9['color'])
-
-
-class MockOutput(Output):
-
-    def __init__(self, uri, connections):
-        self.connections = connections
-        self.updates = []
-        self.uri = uri
-        self.numChannels = 4
-
-    def update(self, values):
-        self.updates.append(list(values[:self.numChannels]))
-
-
-class MockWebListeners(WebListeners):
-
-    def __init__(self):
-        "do not init"
-
-    def outputAttrsSet(self, *a, **kw):
-        pass
-
-
-class TestCollector(unittest.TestCase):
-
-    def setUp(self):
-        self.graph = MockSyncedGraph(PREFIX + THEATER + '''
-
-        dev:colorStrip a :Device, :ChauvetColorStrip;
-          :dmxUniverse udmx:; :dmxBase 1;
-          :red dev:colorStripRed;
-          :green dev:colorStripGreen;
-          :blue dev:colorStripBlue;
-          :mode dev:colorStripMode .
-
-        dev:inst1 a :Device, :SimpleDimmer;
-          :dmxUniverse dmx0:; :dmxBase 1;
-          :level dev:inst1Brightness .
-        ''')
-
-        self.dmx0 = MockOutput(DMX0, [(0, DMX0['c1'])])
-        self.udmx = MockOutput(UDMX, [(0, UDMX['c1']), (1, UDMX['c2']), (2, UDMX['c3']), (3, UDMX['c4'])])
-
-    def testRoutesColorOutput(self):
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
-
-        self.assertEqual([
-            [215, 0, 255, 0],
-        ], self.udmx.updates)
-        self.assertEqual([
-            [0, 0, 0, 0],
-        ], self.dmx0.updates)
-
-    def testOutputMaxOfTwoClients(self):
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#ff0000'))]), t0)
-        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#333333'))]), t0)
-
-        self.assertEqual([[215, 255, 0, 0], [215, 255, 51, 51]], self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
-
-    def testClientOnSameOutputIsRememberedOverCalls(self):
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#080000'))]), t0)
-        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#060000'))]), t0)
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#050000'))]), t0)
-
-        self.assertEqual([[215, 8, 0, 0], [215, 8, 0, 0], [215, 6, 0, 0]], self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
-
-    def testClientsOnDifferentOutputs(self):
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#aa0000'))]), t0)
-        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
-
-        # ok that udmx is flushed twice- it can screen out its own duplicates
-        self.assertEqual([[215, 170, 0, 0], [215, 170, 0, 0]], self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
-
-    def testNewSessionReplacesPreviousOutput(self):
-        # ..as opposed to getting max'd with it
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .8)]), t0)
-        c.setAttrs(client1, session2, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
-
-        self.assertEqual([[204, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
-
-    def testNewSessionDropsPreviousSettingsOfOtherAttrs(self):
-        c = Collector(MockSyncedGraph(PREFIX + THEATER + '''
-
-        dev:colorStrip a :Device, :ChauvetColorStrip;
-          :dmxUniverse udmx:; :dmxBase 1;
-          :red dev:colorStripRed;
-          :green dev:colorStripGreen;
-          :blue dev:colorStripBlue;
-          :mode dev:colorStripMode .
-
-        dev:inst1 a :Device, :SimpleDimmer;
-          :dmxUniverse dmx0:; :dmxBase 0;
-          :level dev:inst1Brightness .
-        '''),
-                      outputs=[self.dmx0, self.udmx],
-                      listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#ff0000'))]), t0)
-        c.setAttrs(client1, session2, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
-
-        self.assertEqual([[215, 255, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
-
-    def testClientIsForgottenAfterAWhile(self):
-        with freeze_time(datetime.datetime.now()) as ft:
-            c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-            c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), UnixTime(time.time()))
-            ft.tick(delta=datetime.timedelta(seconds=1))
-            # this max's with client1's value so we still see .5
-            c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .2)]), UnixTime(time.time()))
-            ft.tick(delta=datetime.timedelta(seconds=9.1))
-            # now client1 is forgotten, so our value appears
-            c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .4)]), UnixTime(time.time()))
-            self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0], [102, 0, 0, 0]], self.dmx0.updates)
-
-    def testClientUpdatesAreNotMerged(self):
-        # second call to setAttrs forgets the first
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-        t0 = UnixTime(time.time())
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, 1)]), t0)
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
-
-        self.assertEqual([[215, 0, 0, 0], [215, 0, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
-        self.assertEqual([[127, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
-
-    def testRepeatedAttributesInOneRequestGetResolved(self):
-        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [
-            (inst1, brightness, .5),
-            (inst1, brightness, .3),
-        ]), t0)
-        self.assertEqual([[127, 0, 0, 0]], self.dmx0.updates)
-
-        c.setAttrs(client1, session1, DeviceSettings(self.graph, [
-            (inst1, brightness, .3),
-            (inst1, brightness, .5),
-        ]), t0)
-        self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
--- a/light9/collector/device.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,260 +0,0 @@
-import logging
-from typing import Dict, List, Any, TypeVar, cast
-from light9.namespaces import L9
-from rdflib import Literal, URIRef
-from webcolors import hex_to_rgb, rgb_to_hex
-from colormath.color_objects import sRGBColor, CMYColor
-import colormath.color_conversions
-from light9.newtypes import VT, DeviceClass, HexColor, OutputAttr, OutputValue, DeviceUri, DeviceAttr, VTUnion
-
-log = logging.getLogger('device')
-
-
-class Device:
-    pass
-
-
-class ChauvetColorStrip(Device):
-    """
-     device attrs:
-       color
-    """
-
-
-class Mini15(Device):
-    """
-    plan:
-
-      device attrs
-        rx, ry
-        color
-        gobo
-        goboShake
-        imageAim (configured with a file of calibration data)
-    """
-
-
-def clamp255(x):
-    return min(255, max(0, x))
-
-
-def _8bit(f):
-    if not isinstance(f, (int, float)):
-        raise TypeError(repr(f))
-    return clamp255(int(f * 255))
-
-
-def _maxColor(values: List[HexColor]) -> HexColor:
-    rgbs = [hex_to_rgb(v) for v in values]
-    maxes = [max(component) for component in zip(*rgbs)]
-    return cast(HexColor, rgb_to_hex(tuple(maxes)))
-
-
-def resolve(
-        deviceType: DeviceClass,
-        deviceAttr: DeviceAttr,
-        values: List[VTUnion]) -> VTUnion:  # todo: return should be VT
-    """
-    return one value to use for this attr, given a set of them that
-    have come in simultaneously. len(values) >= 1.
-
-    bug: some callers are passing a device instance for 1st arg
-    """
-    if len(values) == 1:
-        return values[0]
-    if deviceAttr == DeviceAttr(L9['color']):
-        return _maxColor(cast(List[HexColor], values))
-    # incomplete. how-to-resolve should be on the DeviceAttr defs in the graph.
-    if deviceAttr in map(DeviceAttr, [L9['rx'], L9['ry'], L9['zoom'], L9['focus'], L9['iris']]):
-        floatVals = []
-        for v in values:
-            if isinstance(v, Literal):
-                floatVals.append(float(v.toPython()))
-            elif isinstance(v, (int, float)):
-                floatVals.append(float(v))
-            else:
-                raise TypeError(repr(v))
-
-        # averaging with zeros? not so good
-        return sum(floatVals) / len(floatVals)
-    return max(values)
-
-
-def toOutputAttrs(
-        deviceType: DeviceClass,
-        deviceAttrSettings: Dict[DeviceAttr, VTUnion  # TODO
-                                ]) -> Dict[OutputAttr, OutputValue]:
-    return dict((OutputAttr(u), OutputValue(v)) for u, v in untype_toOutputAttrs(deviceType, deviceAttrSettings).items())
-
-
-def untype_toOutputAttrs(deviceType, deviceAttrSettings) -> Dict[URIRef, int]:
-    """
-    Given device attr settings like {L9['color']: Literal('#ff0000')},
-    return a similar dict where the keys are output attrs (like
-    L9['red']) and the values are suitable for Collector.setAttr
-
-    :outputAttrRange happens before we get here.
-    """
-
-    def floatAttr(attr, default=0):
-        out = deviceAttrSettings.get(attr)
-        if out is None:
-            return default
-        return float(out.toPython()) if isinstance(out, Literal) else out
-
-    def rgbAttr(attr):
-        color = deviceAttrSettings.get(attr, '#000000')
-        r, g, b = hex_to_rgb(color)
-        return r, g, b
-
-    def cmyAttr(attr):
-        rgb = sRGBColor.new_from_rgb_hex(deviceAttrSettings.get(attr, '#000000'))
-        out = colormath.color_conversions.convert_color(rgb, CMYColor)
-        return (_8bit(out.cmy_c), _8bit(out.cmy_m), _8bit(out.cmy_y))
-
-    def fine16Attr(attr, scale=1.0):
-        x = floatAttr(attr) * scale
-        hi = _8bit(x)
-        lo = _8bit((x * 255) % 1.0)
-        return hi, lo
-
-    def choiceAttr(attr):
-        # todo
-        if deviceAttrSettings.get(attr) == L9['g1']:
-            return 3
-        if deviceAttrSettings.get(attr) == L9['g2']:
-            return 10
-        return 0
-
-    if deviceType == L9['ChauvetColorStrip']:
-        r, g, b = rgbAttr(L9['color'])
-        return {L9['mode']: 215, L9['red']: r, L9['green']: g, L9['blue']: b}
-    elif deviceType == L9['Bar612601d']:
-        r, g, b = rgbAttr(L9['color'])
-        return {L9['red']: r, L9['green']: g, L9['blue']: b}
-    elif deviceType == L9['LedPar90']:
-        r, g, b = rgbAttr(L9['color'])
-        return {L9['master']: 255, L9['red']: r, L9['green']: g, L9['blue']: b, L9['white']: 0}
-    elif deviceType == L9['LedPar54']:
-        r, g, b = rgbAttr(L9['color'])
-        return {L9['master']: 255, L9['red']: r, L9['green']: g, L9['blue']: b, L9['white']: 0, L9['strobe']: 0}
-    elif deviceType == L9['SimpleDimmer']:
-        return {L9['level']: _8bit(floatAttr(L9['brightness']))}
-    elif deviceType == L9['MegaFlash']:
-        return {
-            L9['brightness']: _8bit(floatAttr(L9['brightness'])),
-            L9['strobeSpeed']: _8bit(floatAttr(L9['strobeSpeed'])),
-        }
-    elif deviceType == L9['Mini15']:
-        out = {
-            L9['rotationSpeed']: 0,  # seems to have no effect
-            L9['dimmer']: 255,
-            L9['colorChange']: 0,
-            L9['colorSpeed']: 0,
-            L9['goboShake']: _8bit(floatAttr(L9['goboShake'])),
-        }
-
-        out[L9['goboChoose']] = {
-            L9['open']: 0,
-            L9['mini15Gobo1']: 10,
-            L9['mini15Gobo2']: 20,
-            L9['mini15Gobo3']: 30,
-        }[deviceAttrSettings.get(L9['mini15GoboChoice'], L9['open'])]
-
-        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
-        out[L9['xRotation']], out[L9['xFine']] = fine16Attr(L9['rx'], 1 / 540)
-        out[L9['yRotation']], out[L9['yFine']] = fine16Attr(L9['ry'], 1 / 240)
-        # didn't find docs on this, but from tests it looks like 64 fine steps takes you to the next coarse step
-
-        return out
-    elif deviceType == L9['ChauvetHex12']:
-        out = {}
-        out[L9['red']], out[L9['green']], out[L9['blue']] = r, g, b = rgbAttr(L9['color'])
-        out[L9['amber']] = 0
-        out[L9['white']] = min(r, g, b)
-        out[L9['uv']] = _8bit(floatAttr(L9['uv']))
-        return out
-    elif deviceType == L9['Source4LedSeries2']:
-        out = {}
-        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
-        out[L9['strobe']] = 0
-        out[L9['fixed255']] = 255
-        for num in range(7):
-            out[L9['fixed128_%s' % num]] = 128
-        return out
-    elif deviceType == L9['MacAura']:
-        out = {
-            L9['shutter']: 22,
-            L9['dimmer']: 255,
-            L9['zoom']: _8bit(floatAttr(L9['zoom'])),
-            L9['fixtureControl']: 0,
-            L9['colorWheel']: 0,
-            L9['colorTemperature']: 128,
-            L9['fx1Select']: 0,
-            L9['fx1Adjust']: 0,
-            L9['fx2Select']: 0,
-            L9['fx2Adjust']: 0,
-            L9['fxSync']: 0,
-            L9['auraShutter']: 22,
-            L9['auraDimmer']: 0,
-            L9['auraColorWheel']: 0,
-            L9['auraRed']: 0,
-            L9['auraGreen']: 0,
-            L9['auraBlue']: 0,
-        }
-        out[L9['pan']], out[L9['panFine']] = fine16Attr(L9['rx'])
-        out[L9['tilt']], out[L9['tiltFine']] = fine16Attr(L9['ry'])
-        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
-        out[L9['white']] = 0
-
-        return out
-    elif deviceType == L9['MacQuantum']:
-        out = {
-            L9['dimmerFadeLo']: 0,
-            L9['fixtureControl']: 0,
-            L9['fx1Select']: 0,
-            L9['fx1Adjust']: 0,
-            L9['fx2Select']: 0,
-            L9['fx2Adjust']: 0,
-            L9['fxSync']: 0,
-        }
-
-        # note these values are set to 'fade', so they update slowly. Haven't found where to turn that off.
-        out[L9['cyan']], out[L9['magenta']], out[L9['yellow']] = cmyAttr(L9['color'])
-
-        out[L9['focusHi']], out[L9['focusLo']] = fine16Attr(L9['focus'])
-        out[L9['panHi']], out[L9['panLo']] = fine16Attr(L9['rx'])
-        out[L9['tiltHi']], out[L9['tiltLo']] = fine16Attr(L9['ry'])
-        out[L9['zoomHi']], out[L9['zoomLo']] = fine16Attr(L9['zoom'])
-        out[L9['dimmerFadeHi']] = 0 if deviceAttrSettings.get(L9['color'], '#000000') == '#000000' else 255
-
-        out[L9['goboChoice']] = {
-            L9['open']: 0,
-            L9['spider']: 36,
-            L9['windmill']: 41,
-            L9['limbo']: 46,
-            L9['brush']: 51,
-            L9['whirlpool']: 56,
-            L9['stars']: 61,
-        }[deviceAttrSettings.get(L9['quantumGoboChoice'], L9['open'])]
-
-        # my goboSpeed deviceAttr goes 0=stopped to 1=fastest (using one direction only)
-        x = .5 + .5 * floatAttr(L9['goboSpeed'])
-        out[L9['goboSpeedHi']] = _8bit(x)
-        out[L9['goboSpeedLo']] = _8bit((x * 255) % 1.0)
-
-        strobe = floatAttr(L9['strobe'])
-        if strobe < .1:
-            out[L9['shutter']] = 30
-        else:
-            out[L9['shutter']] = 50 + int(150 * (strobe - .1) / .9)
-
-        out.update({
-            L9['colorWheel']: 0,
-            L9['goboStaticRotate']: 0,
-            L9['prismRotation']: _8bit(floatAttr(L9['prism'])),
-            L9['iris']: _8bit(floatAttr(L9['iris']) * (200 / 255)),
-        })
-        return out
-    else:
-        raise NotImplementedError('device %r' % deviceType)
--- a/light9/collector/device_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,59 +0,0 @@
-import unittest
-from light9.newtypes import DeviceAttr, DeviceClass, HexColor, OutputAttr
-from rdflib import Literal
-from light9.namespaces import L9
-
-from light9.collector.device import toOutputAttrs, resolve
-
-
-class TestUnknownDevice(unittest.TestCase):
-
-    def testFails(self):
-        self.assertRaises(NotImplementedError, toOutputAttrs, L9['bogus'], {})
-
-
-class TestColorStrip(unittest.TestCase):
-
-    def testConvertDeviceToOutputAttrs(self):
-        out = toOutputAttrs(DeviceClass(L9['ChauvetColorStrip']), {DeviceAttr(L9['color']): HexColor('#ff0000')})
-        self.assertEqual({L9['mode']: 215, L9['red']: 255, L9['green']: 0, L9['blue']: 0}, out)
-
-
-class TestDimmer(unittest.TestCase):
-
-    def testConvert(self):
-        self.assertEqual({L9['level']: 127}, toOutputAttrs(DeviceClass(L9['SimpleDimmer']), {DeviceAttr(L9['brightness']): .5}))
-
-
-class TestMini15(unittest.TestCase):
-
-    def testConvertColor(self):
-        out = toOutputAttrs(DeviceClass(L9['Mini15']), {DeviceAttr(L9['color']): HexColor('#010203')})
-        self.assertEqual(255, out[OutputAttr(L9['dimmer'])])
-        self.assertEqual(1, out[OutputAttr(L9['red'])])
-        self.assertEqual(2, out[OutputAttr(L9['green'])])
-        self.assertEqual(3, out[OutputAttr(L9['blue'])])
-
-    def testConvertRotation(self):
-        out = toOutputAttrs(DeviceClass(L9['Mini15']), {DeviceAttr(L9['rx']): 90, DeviceAttr(L9['ry']): 45})
-        self.assertEqual(42, out[OutputAttr(L9['xRotation'])])
-        self.assertEqual(127, out[OutputAttr(L9['xFine'])])
-        self.assertEqual(47, out[OutputAttr(L9['yRotation'])])
-        self.assertEqual(207, out[OutputAttr(L9['yFine'])])
-        self.assertEqual(0, out[OutputAttr(L9['rotationSpeed'])])
-
-
-DC = DeviceClass(L9['someDev'])
-
-
-class TestResolve(unittest.TestCase):
-
-    def testMaxes1Color(self):
-        # do not delete - this one catches a bug in the rgb_to_hex(...) lines
-        self.assertEqual(HexColor('#ff0300'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#ff0300')]))
-
-    def testMaxes2Colors(self):
-        self.assertEqual(HexColor('#ff0400'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#ff0300'), HexColor('#000400')]))
-
-    def testMaxes3Colors(self):
-        self.assertEqual(HexColor('#112233'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#110000'), HexColor('#002200'), HexColor('#000033')]))
--- a/light9/collector/dmx_controller_output.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,73 +0,0 @@
-#######################################################
-# DMX Controller
-# See <TBD>
-# Copyright (C) Jonathan Brogdon <jlbrogdon@gmail.com>
-# This program is published under a GPLv2 license
-#
-# This code implements a DMX controller with UI provided
-# by LCDproc
-#
-#######################################################
-from pyftdi import ftdi
-
-#FTDI device info
-vendor = 0x0403
-product = 0x6001
-
-
-#####################
-# DMX USB controller
-#####################
-class OpenDmxUsb():
-
-    def __init__(self):
-        self.baud_rate = 250000
-        self.data_bits = 8
-        self.stop_bits = 2
-        self.parity = 'N'
-        self.flow_ctrl = ''
-        self.rts_state = False
-        self._init_dmx()
-
-    #Initialize the controller
-    def _init_dmx(self):
-        self.ftdi = ftdi.Ftdi()
-        self.ftdi.open(vendor, product, 0)
-        self.ftdi.set_baudrate(self.baud_rate)
-        self.ftdi.set_line_property(self.data_bits,
-                                    self.stop_bits,
-                                    self.parity,
-                                    break_=False)
-        self.ftdi.set_flowctrl(self.flow_ctrl)
-        self.ftdi.purge_rx_buffer()
-        self.ftdi.purge_tx_buffer()
-        self.ftdi.set_rts(self.rts_state)
-
-    #Send DMX data
-    def send_dmx(self, channelVals):
-        assert self.ftdi.write_data(channelVals) == 513
-        # Need to generate two bits for break
-        self.ftdi.set_line_property(self.data_bits,
-                                    self.stop_bits,
-                                    self.parity,
-                                    break_=True)
-        self.ftdi.set_line_property(self.data_bits,
-                                    self.stop_bits,
-                                    self.parity,
-                                    break_=True)
-        self.ftdi.set_line_property(self.data_bits,
-                                    self.stop_bits,
-                                    self.parity,
-                                    break_=False)
-
-
-if __name__ == "__main__":
-    dmxUsb = OpenDmxUsb()
-
-    channelVals = bytearray([0] * 513)
-    channelVals[0] = 0  # dummy channel 0
-    while (True):
-        for x in range(1, 468 + 1):
-            channelVals[x] = 255
-
-        dmxUsb.send_dmx(channelVals)
--- a/light9/collector/output.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,310 +0,0 @@
-import asyncio
-import logging
-import socket
-import struct
-import time
-from typing import cast
-from light9.newtypes import uriTail
-
-import usb.core
-from rdflib import URIRef
-from twisted.internet import reactor, task
-from twisted.internet.interfaces import IReactorCore
-
-from light9.metrics import metrics
-
-log = logging.getLogger('output')
-logAllDmx = logging.getLogger('output.allDmx')
-
-
-class Output:
-    """
-    send a binary buffer of values to some output device. Call update
-    as often as you want- the result will be sent as soon as possible,
-    and with repeats as needed to outlast hardware timeouts.
-
-    This base class doesn't ever call _write. Subclasses below have
-    strategies for that.
-    """
-    uri: URIRef
-
-    def __init__(self, uri: URIRef):
-        self.uri = uri
-
-        self._currentBuffer = b''
-
-        if log.isEnabledFor(logging.DEBUG):
-            self._lastLoggedMsg = ''
-            task.LoopingCall(self._periodicLog).start(1)
-
-    def reconnect(self):
-        pass
-
-    def shortId(self) -> str:
-        """short string to distinguish outputs"""
-        return uriTail(self.uri)
-
-    def update(self, buf: bytes) -> None:
-        """caller asks for the output to be this buffer"""
-        self._currentBuffer = buf
-
-    def _periodicLog(self):
-        msg = '%s: %s' % (self.shortId(), ' '.join(map(str, self._currentBuffer)))
-        if msg != self._lastLoggedMsg:
-            log.debug(msg)
-            self._lastLoggedMsg = msg
-
-    def _write(self, buf: bytes) -> None:
-        """
-        write buffer to output hardware (may be throttled if updates are
-        too fast, or repeated if they are too slow)
-        """
-        pass
-
-    def crash(self):
-        log.error('unrecoverable- exiting')
-        cast(IReactorCore, reactor).crash()
-
-
-class DummyOutput(Output):
-
-    def __init__(self, uri, **kw):
-        super().__init__(uri)
-
-    def update(self, buf: bytes):
-        log.info(f'dummy update {list(map(int,buf[:80]))}')
-
-
-class BackgroundLoopOutput(Output):
-    """Call _write forever at 20hz in background threads"""
-
-    rate: float
-
-    def __init__(self, uri, rate=22):
-        super().__init__(uri)
-        self.rate = rate
-        self._currentBuffer = b''
-
-        self._task = asyncio.create_task(self._loop())
-
-    async def _loop(self):
-        while True:
-            t1 = time.time()
-            self._loop_one()
-            remain = max(0, 1 / self.rate - (time.time() - t1))
-            await asyncio.sleep(remain)
-
-    def _loop_one(self):
-        start = time.time()
-        sendingBuffer = self._currentBuffer
-        #tenacity retry
-        self._write(sendingBuffer)
-
-
-class FtdiDmx(BackgroundLoopOutput):
-
-    def __init__(self, uri, lastDmxChannel, rate=22):
-        super().__init__(uri)
-        self.lastDmxChannel = lastDmxChannel
-        from .dmx_controller_output import OpenDmxUsb
-        self.dmx = OpenDmxUsb()
-
-    def _write(self, buf):
-        with metrics('write', output=self.shortId()).time():
-            if not buf:
-                logAllDmx.debug('%s: empty buf- no output', self.shortId())
-                return
-
-            # ok to truncate the last channels if they just went
-            # to 0? No it is not. DMX receivers don't add implicit
-            # zeros there.
-            buf = bytes([0]) + buf[:self.lastDmxChannel]
-
-            if logAllDmx.isEnabledFor(logging.DEBUG):
-                # for testing fps, smooth fades, etc
-                logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
-
-            self.dmx.send_dmx(buf)
-
-
-class ArtnetDmx(BackgroundLoopOutput):
-    # adapted from https://github.com/spacemanspiff2007/PyArtNet/blob/master/pyartnet/artnet_node.py (gpl3)
-    def __init__(self, uri, host, port, rate):
-        """sends UDP messages to the given host/port"""
-        super().__init__(uri, rate)
-        packet = bytearray()
-        packet.extend(map(ord, "Art-Net"))
-        packet.append(0x00)  # Null terminate Art-Net
-        packet.extend([0x00, 0x50])  # Opcode ArtDMX 0x5000 (Little endian)
-        packet.extend([0x00, 0x0e])  # Protocol version 14
-        self.base_packet = packet
-        self.sequence_counter = 255
-        self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-
-    def _write(self, buf):
-        with metrics('write', output=self.shortId()).time():
-            if not buf:
-                logAllDmx.debug('%s: empty buf- no output', self.shortId())
-                return
-
-            if logAllDmx.isEnabledFor(logging.DEBUG):
-                # for testing fps, smooth fades, etc
-                logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
-
-            if self.sequence_counter:
-                self.sequence_counter += 1
-                if self.sequence_counter > 255:
-                    self.sequence_counter = 1
-            packet = self.base_packet[:]
-            packet.append(self.sequence_counter)  # Sequence,
-            packet.append(0x00)  # Physical
-            universe_nr = 0
-            packet.append(universe_nr & 0xFF)  # Universe LowByte
-            packet.append(universe_nr >> 8 & 0xFF)  # Universe HighByte
-
-            packet.extend(struct.pack('>h', len(buf)))  # Pack the number of channels Big endian
-            packet.extend(buf)
-
-            self._socket.sendto(packet, ('127.0.0.1', 6454))
-
-
-class Udmx(BackgroundLoopOutput):
-    """alternate lib:
-
-    from PyDMXControl.controllers import uDMXController
-    u = uDMXController(autostart=False)
-    u._connect()
-    u._transmit([255, 0, 0, ...
-    """
-
-    def __init__(self, uri: URIRef, bus: int, address: int, lastDmxChannel: int, rate: float):
-        self.bus = bus
-        self.address = address
-        self.lastDmxChannel = lastDmxChannel
-        self.dev = None
-        super().__init__(uri, rate=rate)
-
-        self.reconnect()
-
-    def shortId(self) -> str:
-        return super().shortId() + f'_bus={self.bus}'
-
-    def reconnect(self):
-        metrics('connected', output=self.shortId()).set(0)
-        from pyudmx import pyudmx
-        self.dev = pyudmx.uDMXDevice()
-        if not self.dev.open(bus=self.bus, address=self.address):
-            raise ValueError("dmx open failed")
-        log.info(f'opened {self.dev}')
-        metrics('connected', output=self.shortId()).set(1)
-        metrics('reconnections', output=self.shortId()).inc()
-
-    #def update(self, buf:bytes):
-    #    self._write(buf)
-
-    #def _loop(self):
-    #    pass
-    def _write(self, buf):
-        if not self.dev:
-            log.info('%s: trying to connect', self.shortId())
-            raise ValueError()
-
-        with metrics('write', output=self.shortId()).time():
-            try:
-                if not buf:
-                    logAllDmx.debug('%s: empty buf- no output', self.shortId())
-                    return
-
-                # ok to truncate the last channels if they just went
-                # to 0? No it is not. DMX receivers don't add implicit
-                # zeros there.
-                buf = buf[:self.lastDmxChannel]
-
-                if logAllDmx.isEnabledFor(logging.DEBUG):
-                    # for testing fps, smooth fades, etc
-                    logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
-                t1 = time.time()
-                sent = self.dev.send_multi_value(1, bytearray(buf))
-                if sent != len(buf):
-                    raise ValueError("incomplete send")
-            except ValueError:
-                self.reconnect()
-                raise
-            except usb.core.USBError as e:
-                # not in main thread
-                if e.errno == 75:
-                    metrics('write_overflow', output=self.shortId()).inc()
-                    return
-
-                if e.errno == 5:  # i/o err
-                    metrics('write_io_error', output=self.shortId()).inc()
-                    return
-
-                if e.errno == 32:  # pipe err
-                    metrics('write_pipe_error', output=self.shortId()).inc()
-                    return
-
-                msg = 'usb: sending %s bytes to %r; error %r' % (len(buf), self.uri, e)
-                log.warn(msg)
-
-                if e.errno == 13:  # permissions
-                    return self.crash()
-
-                if e.errno == 19:  # no such dev; usb hw restarted
-                    self.reconnect()
-                    return
-
-                raise
-            dt = time.time() - t1
-            if dt > 1/self.rate*1.5:
-                log.warning(f'usb stall- took {(dt*1000):.2f}ms')
-
-
-'''
-# the code used in 2018 and before
-class UdmxOld(BackgroundLoopOutput):
-
-    def __init__(self, uri, bus):
-        from light9.io.udmx import Udmx
-        self._dev = Udmx(bus)
-
-        super().__init__(uri)
-
-    def _write(self, buf: bytes):
-        try:
-            if not buf:
-                return
-            self.dev.SendDMX(buf)
-
-        except usb.core.USBError as e:
-            # not in main thread
-            if e.errno != 75:
-                msg = 'usb: sending %s bytes to %r; error %r' % (
-                    len(buf), self.uri, e)
-                log.warn(msg)
-            raise
-
-
-# out of date
-class EnttecDmx(BackgroundLoopOutput):
-    stats = scales.collection('/output/enttecDmx', scales.PmfStat('write', recalcPeriod=1),
-                              scales.PmfStat('update', recalcPeriod=1))
-
-    def __init__(self, uri, devicePath='/dev/dmx0', numChannels=80):
-        sys.path.append("dmx_usb_module")
-        from dmx import Dmx
-        self.dev = Dmx(devicePath)
-        super().__init__(uri)
-
-
-    @stats.update.time()
-    def update(self, values):
-
-        # I was outputting on 76 and it was turning on the light at
-        # dmx75. So I added the 0 byte. No notes explaining the footer byte.
-        self.currentBuffer = '\x00' + ''.join(map(chr, values)) + "\x00"
-
-    @stats.write.time()
-    def _write(self, buf):
-        self.dev.write(buf)
-'''
--- a/light9/collector/output_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-import unittest
-from light9.namespaces import L9
-# from light9.collector.output import DmxOutput
-
-
-
-
--- a/light9/collector/service.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,161 +0,0 @@
-#!bin/python
-"""
-Collector receives device attrs from multiple senders, combines
-them, and sends output attrs to hardware. The combining part has
-custom code for some attributes.
-
-Input can be over http or zmq.
-"""
-import asyncio
-import functools
-import logging
-import subprocess
-import traceback
-from typing import List
-
-from light9 import networking
-from light9.collector.collector import Collector
-from light9.collector.output import ArtnetDmx, DummyOutput, Output, Udmx  # noqa
-from light9.collector.weblisteners import UiListener, WebListeners
-from light9.namespaces import L9
-from light9.run_local import log
-from light9.zmqtransport import parseJsonMessage
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from starlette.applications import Starlette
-from starlette.endpoints import WebSocketEndpoint
-from starlette.requests import ClientDisconnect
-from starlette.responses import Response
-from starlette.routing import Route, WebSocketRoute
-from starlette.types import Receive, Scope, Send
-from starlette.websockets import WebSocket
-from starlette_exporter import PrometheusMiddleware, handle_metrics
-
-import zmq
-import zmq.asyncio
-
-
-# this is the rate sent to usb
-RATE = 20
-
-
-class Updates(WebSocketEndpoint, UiListener):
-
-    def __init__(self, listeners, scope: Scope, receive: Receive, send: Send) -> None:
-        super().__init__(scope, receive, send)
-        self.listeners = listeners
-
-    async def on_connect(self, websocket: WebSocket):
-        await websocket.accept()
-        log.info('socket connect %s', self.scope['client'])
-        self.websocket = websocket
-        self.listeners.addClient(self)
-
-    async def sendMessage(self, msgText):
-        await self.websocket.send_text(msgText)
-
-    # async def on_receive(self, websocket, data):
-    #     json.loads(data)
-
-    async def on_disconnect(self, websocket: WebSocket, close_code: int):
-        self.listeners.delClient(self)
-
-    pass
-
-
-async def PutAttrs(collector: Collector, request):
-    try:
-        body = await request.body()
-    except ClientDisconnect:
-        log.warning("PUT /attrs request disconnected- ignoring")
-        return Response('', status_code=400)
-    client, clientSession, settings, sendTime = parseJsonMessage(collector.graph, body)
-    collector.setAttrs(client, clientSession, settings, sendTime)
-    return Response('', status_code=202)
-
-
-async def zmqListener(collector):
-    try:
-        ctx = zmq.asyncio.Context()
-        sock = ctx.socket(zmq.SUB)
-        sock.bind('tcp://127.0.0.1:9203')
-        sock.subscribe(b'setAttr')
-        while True:
-            [topic, msg] = await sock.recv_multipart()
-            if topic != b'setAttr':
-                raise ValueError(topic)
-            # log.info(f'zmq recv {len(msg)}')
-            client, clientSession, settings, sendTime = parseJsonMessage(collector.graph, msg)
-            collector.setAttrs(client, clientSession, settings, sendTime)
-    except:
-        traceback.print_exc()
-        raise
-
-def findDevice():
-    for line in subprocess.check_output("lsusb").decode('utf8').splitlines():
-        if '16c0:05dc' in line:
-            words = line.split(':')[0].split()
-            dev = f'/dev/bus/usb/{words[1]}/{words[3]}'
-            log.info(f'device will be {dev}')
-            return dev ,int(words[3])
-    raise ValueError("no matching uDMX found")
-
-def main():
-    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
-    logging.getLogger('syncedgraph').setLevel(logging.INFO)
-    logging.getLogger('output.allDmx').setLevel(logging.WARNING)
-    logging.getLogger().setLevel(logging.DEBUG)
-    logging.getLogger('collector').setLevel(logging.DEBUG)
-
-    graph = SyncedGraph(networking.rdfdb.url, "collector")
-
-    devPath, usbAddress = findDevice()
-            # if user doesn't have r/w, fail now
-    try:
-        # todo: drive outputs with config files
-        outputs: List[Output] = [
-            # ArtnetDmx(L9['output/dmxA/'],
-            #           host='127.0.0.1',
-            #           port=6445,
-            #           rate=rate),
-            #sudo chmod a+rw /dev/bus/usb/003/021
-            Udmx(L9['output/dmxA/'], bus=1, address=usbAddress, lastDmxChannel=200, rate=RATE),
-        ]
-    except Exception:
-        log.error("setting up outputs:")
-        traceback.print_exc()
-        raise
-    listeners = WebListeners()
-    c = Collector(graph, outputs, listeners)
-    zl = asyncio.create_task(zmqListener(c))
-    app = Starlette(
-        debug=True,
-        routes=[
-            # Route('/recentRequests', lambda req: get_recentRequests(req, db)),
-            WebSocketRoute('/updates', endpoint=functools.partial(Updates, listeners)),
-            Route('/attrs', functools.partial(PutAttrs, c), methods=['PUT']),
-        ],
-    )
-    app.add_middleware(PrometheusMiddleware)
-    app.add_route("/metrics", handle_metrics)
-
-    # loadtest = os.environ.get('LOADTEST', False)  # call myself with some synthetic load then exit
-    # if loadtest:
-    #     # in a subprocess since we don't want this client to be
-    #     # cooperating with the main event loop and only sending
-    #     # requests when there's free time
-    #     def afterWarmup():
-    #         log.info('running collector_loadtest')
-    #         d = utils.getProcessValue('bin/python', ['bin/collector_loadtest.py'])
-
-    #         def done(*a):
-    #             log.info('loadtest done')
-    #             reactor.stop()
-
-    #         d.addCallback(done)
-
-    #     reactor.callLater(2, afterWarmup)
-
-    return app
-
-
-app = main()
--- a/light9/collector/weblisteners.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-import asyncio
-import json
-import logging
-import time
-from typing import Any, Awaitable, Dict, List, Protocol, Tuple
-
-from light9.collector.output import Output as OutputInstance
-from light9.newtypes import (DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr, OutputUri, OutputValue)
-
-log = logging.getLogger('weblisteners')
-
-
-def shortenOutput(out: OutputUri) -> str:
-    return str(out).rstrip('/').rsplit('/', 1)[-1]
-
-
-class UiListener(Protocol):
-
-    async def sendMessage(self, msg):
-        ...
-
-
-class WebListeners:
-
-    def __init__(self) -> None:
-        self.clients: List[Tuple[UiListener, Dict[DeviceUri, Dict[OutputAttr, OutputValue]]]] = []
-        self.pendingMessageForDev: Dict[DeviceUri, Tuple[Dict[OutputAttr, OutputValue], Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri,
-                                                                                                                                 DmxMessageIndex]]]] = {}
-        self.lastFlush = 0
-        asyncio.create_task(self.flusher())
-
-    def addClient(self, client: UiListener):
-        self.clients.append((client, {}))  # seen = {dev: attrs}
-        log.info('added client %s %s', len(self.clients), client)
-        # todo: it would be nice to immediately fill in the client on the
-        # latest settings, but I lost them so I can't.
-
-    def delClient(self, client: UiListener):
-        self.clients = [(c, t) for c, t in self.clients if c != client]
-        log.info('delClient %s, %s left', client, len(self.clients))
-
-    def outputAttrsSet(self, dev: DeviceUri, attrs: Dict[OutputAttr, Any], outputMap: Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]):
-        """called often- don't be slow"""
-        self.pendingMessageForDev[dev] = (attrs, outputMap)
-        # maybe put on a stack for flusher or something
-
-    async def flusher(self):
-        await asyncio.sleep(3)  # help startup faster?
-        while True:
-            await self._flush()
-            await asyncio.sleep(.05)
-
-    async def _flush(self):
-        now = time.time()
-        if now < self.lastFlush + .05 or not self.clients:
-            return
-        self.lastFlush = now
-
-        while self.pendingMessageForDev:
-            dev, (attrs, outputMap) = self.pendingMessageForDev.popitem()
-
-            msg = None  # lazy, since makeMsg is slow
-
-            sendAwaits: List[Awaitable[None]] = []
-
-            # this omits repeats, but can still send many
-            # messages/sec. Not sure if piling up messages for the browser
-            # could lead to slowdowns in the real dmx output.
-            for client, seen in self.clients:
-                if seen.get(dev) == attrs:
-                    continue
-                if msg is None:
-                    msg = self.makeMsg(dev, attrs, outputMap)
-
-                seen[dev] = attrs
-                sendAwaits.append(client.sendMessage(msg))
-            await asyncio.gather(*sendAwaits)
-
-    def makeMsg(self, dev: DeviceUri, attrs: Dict[OutputAttr, Any], outputMap: Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]):
-        attrRows = []
-        for attr, val in attrs.items():
-            outputUri, bufIndex = outputMap[(dev, attr)]
-            dmxIndex = DmxIndex(bufIndex + 1)
-            attrRows.append({'attr': attr.rsplit('/')[-1], 'val': val, 'chan': (shortenOutput(outputUri), dmxIndex)})
-        attrRows.sort(key=lambda r: r['chan'])
-        for row in attrRows:
-            row['chan'] = '%s %s' % (row['chan'][0], row['chan'][1])
-
-        msg = json.dumps({'outputAttrsSet': {'dev': dev, 'attrs': attrRows}}, sort_keys=True)
-        return msg
--- a/light9/cursor1.xbm	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-#define cursor1_width 20
-#define cursor1_height 20
-#define cursor1_x_hot 5
-#define cursor1_y_hot 5
-static char cursor1_bits[] = {
-  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x07, 0x00, 
-  0x00, 0x1d, 0x00, 0x00, 0x27, 0x00, 0x00, 0x23, 0x00, 0x80, 0x21, 0x00, 
-  0x80, 0x21, 0x00, 0x80, 0x23, 0x00, 0x80, 0x3e, 0x00, 0x80, 0x1f, 0x00, 
-  0x80, 0x71, 0x00, 0x80, 0x47, 0x00, 0x80, 0x7c, 0x00, 0xc0, 0x00, 0x00, 
-  0x40, 0x00, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0x00, 0x10, 0x00, 0x00, 
-  };
--- a/light9/curvecalc/client.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-"""
-client code for talking to curvecalc
-"""
-import cyclone.httpclient
-from light9 import networking
-import urllib.request, urllib.parse, urllib.error
-
-
-def sendLiveInputPoint(curve, value):
-    f = cyclone.httpclient.fetch(networking.curveCalc.path('liveInputPoint'),
-                                 method='POST',
-                                 timeout=1,
-                                 postdata=urllib.parse.urlencode({
-                                     'curve':
-                                     curve,
-                                     'value':
-                                     str(value),
-                                 }))
-
-    @f.addCallback
-    def cb(result):
-        if result.code // 100 != 2:
-            raise ValueError("curveCalc said %s: %s", result.code, result.body)
-
-    return f
--- a/light9/curvecalc/cursors.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-import logging
-log = logging.getLogger("cursors")
-
-# accept ascii images, read file images, add hotspots, read xbm as
-# cursor with @filename form
-
-_pushed = {}  # widget : [old, .., newest]
-
-
-def push(widget, new_cursor):
-    global _pushed
-    _pushed.setdefault(widget, []).append(widget.cget("cursor"))
-
-
-def pop(widget):
-    global _pushed
-    try:
-        c = _pushed[widget].pop(-1)
-    except IndexError:
-        log.debug("cursor pop from empty stack")
-        return
-    widget.config(cursor=c)
--- a/light9/curvecalc/curve.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,384 +0,0 @@
-import logging, ast, os
-from bisect import bisect_left, bisect
-import louie as dispatcher
-from twisted.internet import reactor
-from rdflib import Literal
-from light9 import showconfig
-from light9.namespaces import L9, RDF, RDFS
-from rdfdb.patch import Patch
-
-log = logging.getLogger()
-# todo: move to config, consolidate with ascoltami, musicPad, etc
-introPad = 4
-postPad = 4
-
-
-class Curve(object):
-    """curve does not know its name. see Curveset"""
-
-    def __init__(self, uri, pointsStorage='graph'):
-        self.uri = uri
-        self.pointsStorage = pointsStorage
-        self.points = []  # x-sorted list of (x,y)
-        self._muted = False
-
-    def __repr__(self):
-        return "<%s %s (%s points)>" % (self.__class__.__name__, self.uri,
-                                        len(self.points))
-
-    def muted():
-        doc = "Whether to currently send levels (boolean, obviously)"
-
-        def fget(self):
-            return self._muted
-
-        def fset(self, val):
-            self._muted = val
-            dispatcher.send('mute changed', sender=self)
-
-        return locals()
-
-    muted = property(**muted())
-
-    def toggleMute(self):
-        self.muted = not self.muted
-
-    def load(self, filename):
-        self.points[:] = []
-        for line in open(filename):
-            x, y = line.split()
-            self.points.append((float(x), ast.literal_eval(y)))
-        self.points.sort()
-        dispatcher.send("points changed", sender=self)
-
-    def set_from_string(self, pts):
-        self.points[:] = []
-        vals = pts.split()
-        pairs = list(zip(vals[0::2], vals[1::2]))
-        for x, y in pairs:
-            self.points.append((float(x), ast.literal_eval(y)))
-        self.points.sort()
-        dispatcher.send("points changed", sender=self)
-
-    def points_as_string(self):
-
-        def outVal(x):
-            if isinstance(x, str):  # markers
-                return x
-            return "%.4g" % x
-
-        return ' '.join(
-            "%s %s" % (outVal(p[0]), outVal(p[1])) for p in self.points)
-
-    def save(self, filename):
-        # this is just around for markers, now
-        if filename.endswith('-music') or filename.endswith('_music'):
-            print("not saving music track")
-            return
-        f = open(filename, 'w')
-        for p in self.points:
-            f.write("%s %r\n" % p)
-        f.close()
-
-    def eval(self, t, allow_muting=True):
-        if self.muted and allow_muting:
-            return 0
-        if not self.points:
-            raise ValueError("curve has no points")
-        i = bisect_left(self.points, (t, None)) - 1
-
-        if i == -1:
-            return self.points[0][1]
-        if self.points[i][0] > t:
-            return self.points[i][1]
-        if i >= len(self.points) - 1:
-            return self.points[i][1]
-
-        p1, p2 = self.points[i], self.points[i + 1]
-        frac = (t - p1[0]) / (p2[0] - p1[0])
-        y = p1[1] + (p2[1] - p1[1]) * frac
-        return y
-
-    __call__ = eval
-
-    def insert_pt(self, new_pt):
-        """returns index of new point"""
-        i = bisect(self.points, (new_pt[0], None))
-        self.points.insert(i, new_pt)
-        # missing a check that this isn't the same X as the neighbor point
-        dispatcher.send("points changed", sender=self)
-        return i
-
-    def live_input_point(self, new_pt, clear_ahead_secs=.01):
-        x, y = new_pt
-        exist = self.points_between(x, x + clear_ahead_secs)
-        for pt in exist:
-            self.remove_point(pt)
-        self.insert_pt(new_pt)
-        dispatcher.send("points changed", sender=self)
-        # now simplify to the left
-
-    def set_points(self, updates):
-        for i, pt in updates:
-            self.points[i] = pt
-
-        # this should be on, but live_input_point made it fail a
-        # lot. need a new solution.
-        #self.checkOverlap()
-        dispatcher.send("points changed", sender=self)
-
-    def checkOverlap(self):
-        x = None
-        for p in self.points:
-            if p[0] <= x:
-                raise ValueError("overlapping points")
-            x = p[0]
-
-    def pop_point(self, i):
-        p = self.points.pop(i)
-        dispatcher.send("points changed", sender=self)
-        return p
-
-    def remove_point(self, pt):
-        self.points.remove(pt)
-        dispatcher.send("points changed", sender=self)
-
-    def indices_between(self, x1, x2, beyond=0):
-        leftidx = max(0, bisect(self.points, (x1, None)) - beyond)
-        rightidx = min(len(self.points),
-                       bisect(self.points, (x2, None)) + beyond)
-        return list(range(leftidx, rightidx))
-
-    def points_between(self, x1, x2):
-        """returns (x,y) points"""
-        return [self.points[i] for i in self.indices_between(x1, x2)]
-
-    def point_before(self, x):
-        """(x,y) of the point left of x, or None"""
-        leftidx = self.index_before(x)
-        if leftidx is None:
-            return None
-        return self.points[leftidx]
-
-    def index_before(self, x):
-        leftidx = bisect(self.points, (x, None)) - 1
-        if leftidx < 0:
-            return None
-        return leftidx
-
-
-class CurveResource(object):
-    """
-    holds a Curve, deals with graphs
-    """
-
-    def __init__(self, graph, uri):
-        # probably newCurve and loadCurve should be the constructors instead.
-        self.graph, self.uri = graph, uri
-
-    def curvePointsContext(self):
-        return self.uri
-
-    def newCurve(self, ctx, label):
-        """
-        Save type/label for a new :Curve resource.
-        Pass the ctx where the main curve data (not the points) will go.
-        """
-        if hasattr(self, 'curve'):
-            raise ValueError('CurveResource already has a curve %r' %
-                             self.curve)
-        self.graph.patch(
-            Patch(addQuads=[
-                (self.uri, RDF.type, L9['Curve'], ctx),
-                (self.uri, RDFS.label, label, ctx),
-            ]))
-        self.curve = Curve(self.uri)
-        self.curve.points.extend([(0, 0)])
-        self.saveCurve()
-        self.watchCurvePointChanges()
-
-    def loadCurve(self):
-        if hasattr(self, 'curve'):
-            raise ValueError('CurveResource already has a curve %r' %
-                             self.curve)
-        pointsFile = self.graph.value(self.uri, L9['pointsFile'])
-        self.curve = Curve(self.uri,
-                           pointsStorage='file' if pointsFile else 'graph')
-        if hasattr(self.graph, 'addHandler'):
-            self.graph.addHandler(self.pointsFromGraph)
-        else:
-            # given a currentState graph
-            self.pointsFromGraph()
-
-    def pointsFromGraph(self):
-        pts = self.graph.value(self.uri, L9['points'])
-        if pts is not None:
-            self.curve.set_from_string(pts)
-        else:
-            diskPts = self.graph.value(self.uri, L9['pointsFile'])
-            if diskPts is not None:
-                self.curve.load(os.path.join(showconfig.curvesDir(), diskPts))
-            else:
-                log.warn("curve %s has no points", self.uri)
-        self.watchCurvePointChanges()
-
-    def saveCurve(self):
-        self.pendingSave = None
-        for p in self.getSavePatches():
-            self.graph.patch(p)
-
-    def getSavePatches(self):
-        if self.curve.pointsStorage == 'file':
-            log.warn("not saving file curves anymore- skipping %s" % self.uri)
-            #cur.save("%s-%s" % (basename,name))
-            return []
-        elif self.curve.pointsStorage == 'graph':
-            return [
-                self.graph.getObjectPatch(self.curvePointsContext(),
-                                          subject=self.uri,
-                                          predicate=L9['points'],
-                                          newObject=Literal(
-                                              self.curve.points_as_string()))
-            ]
-        else:
-            raise NotImplementedError(self.curve.pointsStorage)
-
-    def watchCurvePointChanges(self):
-        """start watching and saving changes to the graph"""
-        dispatcher.connect(self.onChange, 'points changed', sender=self.curve)
-
-    def onChange(self):
-
-        # Don't write a patch for the edited curve points until they've been
-        # stable for this long. This can be very short, since it's just to
-        # stop a 100-point edit from sending many updates. If it's too long,
-        # you won't see output lights change while you drag a point.  Todo:
-        # this is just the wrong timing algorithm- it should be a max rate,
-        # not a max-hold-still-time.
-        HOLD_POINTS_GRAPH_COMMIT_SECS = .1
-
-        if getattr(self, 'pendingSave', None):
-            self.pendingSave.cancel()
-        self.pendingSave = reactor.callLater(HOLD_POINTS_GRAPH_COMMIT_SECS,
-                                             self.saveCurve)
-
-
-class Markers(Curve):
-    """Marker is like a point but the y value is a string"""
-
-    def eval(self):
-        raise NotImplementedError()
-
-
-def slope(p1, p2):
-    if p2[0] == p1[0]:
-        return 0
-    return (p2[1] - p1[1]) / (p2[0] - p1[0])
-
-
-class Curveset(object):
-
-    def __init__(self, graph, session):
-        self.graph, self.session = graph, session
-
-        self.currentSong = None
-        self.curveResources = {}  # uri : CurveResource
-
-        self.markers = Markers(uri=None, pointsStorage='file')
-
-        graph.addHandler(self.loadCurvesForSong)
-
-    def curveFromUri(self, uri):
-        return self.curveResources[uri].curve
-
-    def loadCurvesForSong(self):
-        """
-        current curves will track song's curves.
-        
-        This fires 'add_curve' dispatcher events to announce the new curves.
-        """
-        log.info('loadCurvesForSong')
-        dispatcher.send("clear_curves")
-        self.curveResources.clear()
-        self.markers = Markers(uri=None, pointsStorage='file')
-
-        self.currentSong = self.graph.value(self.session, L9['currentSong'])
-        if self.currentSong is None:
-            return
-
-        for uri in sorted(self.graph.objects(self.currentSong, L9['curve'])):
-            try:
-                cr = self.curveResources[uri] = CurveResource(self.graph, uri)
-                cr.loadCurve()
-
-                curvename = self.graph.label(uri)
-                if not curvename:
-                    raise ValueError("curve %r has no label" % uri)
-                dispatcher.send("add_curve",
-                                sender=self,
-                                uri=uri,
-                                label=curvename,
-                                curve=cr.curve)
-            except Exception as e:
-                log.error("loading %s failed: %s", uri, e)
-
-        basename = os.path.join(
-            showconfig.curvesDir(),
-            showconfig.songFilenameFromURI(self.currentSong))
-        try:
-            self.markers.load("%s.markers" % basename)
-        except IOError:
-            print("no marker file found")
-
-    def save(self):
-        """writes a file for each curve with a name
-        like basename-curvename, or saves them to the rdf graph"""
-        basename = os.path.join(
-            showconfig.curvesDir(),
-            showconfig.songFilenameFromURI(self.currentSong))
-
-        patches = []
-        for cr in list(self.curveResources.values()):
-            patches.extend(cr.getSavePatches())
-
-        self.markers.save("%s.markers" % basename)
-        # this will cause reloads that will rebuild our curve list
-        for p in patches:
-            self.graph.patch(p)
-
-    def sorter(self, name):
-        return self.curves[name].uri
-
-    def curveUrisInOrder(self):
-        return sorted(self.curveResources.keys())
-
-    def currentCurves(self):
-        # deprecated
-        for uri, cr in sorted(self.curveResources.items()):
-            with self.graph.currentState(tripleFilter=(uri, RDFS['label'],
-                                                       None)) as g:
-                yield uri, g.label(uri), cr.curve
-
-    def globalsdict(self):
-        raise NotImplementedError('subterm used to get a dict of name:curve')
-
-    def get_time_range(self):
-        return 0, dispatcher.send("get max time")[0][1]
-
-    def new_curve(self, name):
-        if isinstance(name, Literal):
-            name = str(name)
-
-        uri = self.graph.sequentialUri(self.currentSong + '/curve-')
-
-        cr = self.curveResources[uri] = CurveResource(self.graph, uri)
-        cr.newCurve(ctx=self.currentSong, label=Literal(name))
-        s, e = self.get_time_range()
-        cr.curve.points.extend([(s, 0), (e, 0)])
-
-        ctx = self.currentSong
-        self.graph.patch(
-            Patch(addQuads=[
-                (self.currentSong, L9['curve'], uri, ctx),
-            ]))
-        cr.saveCurve()
--- a/light9/curvecalc/curvecalc.glade	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1042 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- Generated with glade 3.16.1 -->
-<interface>
-  <requires lib="gtk+" version="3.10"/>
-  <object class="GtkAccelGroup" id="accelgroup1"/>
-  <object class="GtkAdjustment" id="adjustment1">
-    <property name="upper">100</property>
-    <property name="step_increment">1</property>
-    <property name="page_increment">10</property>
-  </object>
-  <object class="GtkTextBuffer" id="help">
-    <property name="text">Mousewheel zoom; C-p play/pause music at mouse
-Keys in a selected curve: 1..5 add point at time cursor
-Keys in any curve: q,w,e,r,t,y set marker at time cursor
-Curve point bindings: B1 drag point; C-B1 curve add point; S-B1 sketch points; B1 drag select points
-
-Old subterm system may still work:
-Drag sub into curve area for new curve+subterm
-Available in functions: nsin/ncos period=amp=1; within(a,b) bef(x) aft(x) compare to time; smoove(x) cubic smoothstep; chan(name); curvename(t) eval curve</property>
-  </object>
-  <object class="GtkWindow" id="MainWindow">
-    <property name="can_focus">False</property>
-    <child>
-      <object class="GtkBox" id="vbox1">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="orientation">vertical</property>
-        <child>
-          <object class="GtkMenuBar" id="menubar1">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkMenuItem" id="menuitem1">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">_Curvecalc</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu1">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem2">
-                        <property name="label">gtk-save</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                        <signal name="activate" handler="onSave" swapped="no"/>
-                        <accelerator key="s" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem5">
-                        <property name="label">gtk-quit</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                        <signal name="activate" handler="onQuit" swapped="no"/>
-                        <accelerator key="q" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem7">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">_Edit</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu5">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem1">
-                        <property name="label">gtk-cut</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem3">
-                        <property name="label">gtk-copy</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem4">
-                        <property name="label">gtk-paste</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkImageMenuItem" id="imagemenuitem6">
-                        <property name="label">gtk-delete</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="use_underline">True</property>
-                        <property name="use_stock">True</property>
-                        <signal name="activate" handler="onDelete" swapped="no"/>
-                        <accelerator key="Delete" signal="activate"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem13">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">_Create</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu6">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem14">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Curve...</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onNewCurve" swapped="no"/>
-                        <accelerator key="n" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem15">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Subterm...</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onNewSubterm" swapped="no"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem2">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">_View</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu2">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem8">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">See current time</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onSeeCurrentTime" swapped="no"/>
-                        <accelerator key="Escape" signal="activate"/>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem9">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">See from current time -&gt; end</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onSeeTimeUntilEnd" swapped="no"/>
-                        <accelerator key="Escape" signal="activate" modifiers="GDK_SHIFT_MASK"/>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem10">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Zoom all</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onZoomAll" swapped="no"/>
-                        <accelerator key="Escape" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem11">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Zoom in (wheel up)</property>
-                        <property name="use_underline">True</property>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem12">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Zoom out (wheel down)</property>
-                        <property name="use_underline">True</property>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem17">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Redraw curves</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onRedrawCurves" swapped="no"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem3">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">_Playback</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu3">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem5">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">_Play/pause</property>
-                        <property name="use_underline">True</property>
-                        <signal name="activate" handler="onPlayPause" swapped="no"/>
-                        <accelerator key="p" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem4">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Poin_ts</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu4">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem6">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Delete</property>
-                        <property name="use_underline">True</property>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem16">
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Debug</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu7">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkCheckMenuItem" id="checkmenuitem1">
-                        <property name="use_action_appearance">False</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Python console</property>
-                        <property name="use_underline">True</property>
-                        <signal name="toggled" handler="onPythonConsole" swapped="no"/>
-                        <accelerator key="p" signal="activate" modifiers="GDK_SHIFT_MASK | GDK_CONTROL_MASK"/>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkBox" id="songRow">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkBox" id="currentSongEditChoice">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <child>
-                  <placeholder/>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkLabel" id="label2">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="xalign">1</property>
-                <property name="label" translatable="yes">Player is on song </property>
-                <property name="justify">right</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkLinkButton" id="playerSong">
-                <property name="label" translatable="yes">(song)</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">True</property>
-                <property name="has_tooltip">True</property>
-                <property name="relief">none</property>
-                <property name="xalign">0</property>
-                <property name="uri">http://glade.gnome.org</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">2</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkCheckButton" id="followPlayerSongChoice">
-                <property name="label" translatable="yes">follow player song choice</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">False</property>
-                <property name="xalign">0.5</property>
-                <property name="draw_indicator">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="padding">15</property>
-                <property name="position">3</property>
-              </packing>
-            </child>
-            <child>
-              <placeholder/>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">False</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkPaned" id="paned1">
-            <property name="height_request">600</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="orientation">vertical</property>
-            <property name="position">600</property>
-            <child>
-              <object class="GtkExpander" id="expander2">
-                <property name="height_request">400</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="expanded">True</property>
-                <child>
-                  <object class="GtkBox" id="vbox4">
-                    <property name="height_request">100</property>
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="orientation">vertical</property>
-                    <child>
-                      <object class="GtkBox" id="curveTools">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="spacing">7</property>
-                        <child>
-                          <object class="GtkButton" id="button22">
-                            <property name="label">gtk-add</property>
-                            <property name="use_action_appearance">False</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">True</property>
-                            <property name="use_stock">True</property>
-                            <signal name="clicked" handler="onNewCurve" swapped="no"/>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">False</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">False</property>
-                        <property name="position">0</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkBox" id="zoomControlBox">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="orientation">vertical</property>
-                        <child>
-                          <object class="GtkLabel" id="zoomControl">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">[zoom control]</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">True</property>
-                        <property name="position">1</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkScrolledWindow" id="scrolledwindowCurves">
-                        <property name="visible">True</property>
-                        <property name="can_focus">True</property>
-                        <property name="hscrollbar_policy">never</property>
-                        <child>
-                          <object class="GtkViewport" id="viewport2">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <child>
-                              <object class="GtkEventBox" id="eventbox1">
-                                <property name="visible">True</property>
-                                <property name="can_focus">True</property>
-                                <child>
-                                  <object class="GtkBox" id="curves">
-                                    <property name="visible">True</property>
-                                    <property name="can_focus">False</property>
-                                    <property name="orientation">vertical</property>
-                                    <child>
-                                      <placeholder/>
-                                    </child>
-                                    <child>
-                                      <placeholder/>
-                                    </child>
-                                    <child>
-                                      <placeholder/>
-                                    </child>
-                                  </object>
-                                </child>
-                              </object>
-                            </child>
-                          </object>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">True</property>
-                        <property name="fill">True</property>
-                        <property name="position">2</property>
-                      </packing>
-                    </child>
-                  </object>
-                </child>
-                <child type="label">
-                  <object class="GtkLabel" id="label10">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label" translatable="yes">Curves</property>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="resize">True</property>
-                <property name="shrink">False</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkExpander" id="expander1">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <child>
-                  <object class="GtkBox" id="box1">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="orientation">vertical</property>
-                    <child>
-                      <object class="GtkScrolledWindow" id="scrolledwindow2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">True</property>
-                        <property name="vadjustment">adjustment1</property>
-                        <property name="hscrollbar_policy">never</property>
-                        <child>
-                          <object class="GtkViewport" id="viewport1">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="vadjustment">adjustment1</property>
-                            <property name="shadow_type">none</property>
-                            <child>
-                              <object class="GtkTable" id="subterms">
-                                <property name="visible">True</property>
-                                <property name="can_focus">False</property>
-                                <property name="n_columns">2</property>
-                                <signal name="add" handler="onSubtermChildAdded" swapped="no"/>
-                                <signal name="map" handler="onSubtermsMap" swapped="no"/>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                                <child>
-                                  <placeholder/>
-                                </child>
-                              </object>
-                            </child>
-                          </object>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">True</property>
-                        <property name="fill">True</property>
-                        <property name="position">0</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkLabel" id="newSubZone">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="ypad">18</property>
-                        <property name="label" translatable="yes">Drop new sub here</property>
-                        <signal name="drag-data-received" handler="onDragDataInNewSubZone" swapped="no"/>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">True</property>
-                        <property name="position">1</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkBox" id="box2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <child>
-                          <placeholder/>
-                        </child>
-                        <child>
-                          <object class="GtkButton" id="button2">
-                            <property name="label">gtk-add</property>
-                            <property name="use_action_appearance">False</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">True</property>
-                            <property name="use_stock">True</property>
-                            <signal name="clicked" handler="onNewSubterm" swapped="no"/>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">False</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <placeholder/>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">False</property>
-                        <property name="position">2</property>
-                      </packing>
-                    </child>
-                  </object>
-                </child>
-                <child type="label">
-                  <object class="GtkLabel" id="subtermsLabel">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label" translatable="yes">Subterms</property>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="resize">False</property>
-                <property name="shrink">False</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">2</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkBox" id="statusRow">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkFrame" id="frame2">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label_xalign">0</property>
-                <property name="shadow_type">none</property>
-                <child>
-                  <object class="GtkTable" id="status">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="n_columns">2</property>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                    <child>
-                      <placeholder/>
-                    </child>
-                  </object>
-                </child>
-                <child type="label">
-                  <object class="GtkLabel" id="label1">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label" translatable="yes">Status</property>
-                    <property name="use_markup">True</property>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">True</property>
-                <property name="fill">True</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkTextView" id="textview1">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="editable">False</property>
-                <property name="wrap_mode">word</property>
-                <property name="buffer">help</property>
-              </object>
-              <packing>
-                <property name="expand">True</property>
-                <property name="fill">True</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">False</property>
-            <property name="padding">5</property>
-            <property name="position">3</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
-  <object class="GtkImage" id="image2">
-    <property name="visible">True</property>
-    <property name="can_focus">False</property>
-    <property name="stock">gtk-refresh</property>
-  </object>
-  <object class="GtkListStore" id="liststore1"/>
-  <object class="GtkDialog" id="newSubterm">
-    <property name="can_focus">False</property>
-    <property name="border_width">5</property>
-    <property name="type">popup</property>
-    <property name="title" translatable="yes">New curve</property>
-    <property name="modal">True</property>
-    <property name="window_position">mouse</property>
-    <property name="type_hint">normal</property>
-    <child internal-child="vbox">
-      <object class="GtkBox" id="dialog-vbox3">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="spacing">2</property>
-        <child internal-child="action_area">
-          <object class="GtkButtonBox" id="dialog-action_area3">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="layout_style">end</property>
-            <child>
-              <object class="GtkButton" id="button12">
-                <property name="label">gtk-cancel</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_stock">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkButton" id="button3">
-                <property name="label">gtk-add</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="can_default">True</property>
-                <property name="has_default">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_stock">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkLabel" id="label11">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="label" translatable="yes">Name for new subterm</property>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkBox" id="vbox11">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="orientation">vertical</property>
-            <child>
-              <object class="GtkComboBox" id="newSubtermName">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="has_focus">True</property>
-                <property name="is_focus">True</property>
-                <property name="model">liststore1</property>
-                <property name="has_entry">True</property>
-                <property name="entry_text_column">0</property>
-                <child internal-child="entry">
-                  <object class="GtkEntry" id="combobox-entry1">
-                    <property name="can_focus">False</property>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">True</property>
-                <property name="fill">True</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkCheckButton" id="newSubtermMakeCurve">
-                <property name="label" translatable="yes">_Make new curve with the same name</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">False</property>
-                <property name="use_underline">True</property>
-                <property name="xalign">0.5</property>
-                <property name="active">True</property>
-                <property name="draw_indicator">True</property>
-              </object>
-              <packing>
-                <property name="expand">True</property>
-                <property name="fill">True</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">2</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-    <action-widgets>
-      <action-widget response="2">button12</action-widget>
-      <action-widget response="1">button3</action-widget>
-    </action-widgets>
-  </object>
-  <object class="GtkDialog" id="newCurve">
-    <property name="can_focus">False</property>
-    <property name="border_width">5</property>
-    <property name="title" translatable="yes">New curve</property>
-    <property name="modal">True</property>
-    <property name="window_position">mouse</property>
-    <property name="type_hint">normal</property>
-    <child internal-child="vbox">
-      <object class="GtkBox" id="dialog-vbox1">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="spacing">2</property>
-        <child internal-child="action_area">
-          <object class="GtkButtonBox" id="dialog-action_area1">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="layout_style">end</property>
-            <child>
-              <object class="GtkButton" id="button5">
-                <property name="label">gtk-cancel</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_stock">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkButton" id="button4">
-                <property name="label">gtk-add</property>
-                <property name="use_action_appearance">False</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="can_default">True</property>
-                <property name="has_default">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_stock">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">False</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="pack_type">end</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkLabel" id="label12">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="label" translatable="yes">Name for new curve</property>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkEntry" id="newCurveName">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="has_focus">True</property>
-            <property name="is_focus">True</property>
-            <property name="invisible_char">●</property>
-            <property name="activates_default">True</property>
-            <property name="primary_icon_activatable">False</property>
-            <property name="secondary_icon_activatable">False</property>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">2</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-    <action-widgets>
-      <action-widget response="2">button5</action-widget>
-      <action-widget response="1">button4</action-widget>
-    </action-widgets>
-  </object>
-  <object class="GtkSizeGroup" id="sizegroup1"/>
-  <object class="GtkSizeGroup" id="sizegroup2"/>
-  <object class="GtkTextBuffer" id="textbuffer1">
-    <property name="text" translatable="yes">song01(t)</property>
-  </object>
-  <object class="GtkBox" id="vbox2">
-    <property name="visible">True</property>
-    <property name="can_focus">False</property>
-    <property name="orientation">vertical</property>
-    <child>
-      <object class="GtkImage" id="image1">
-        <property name="width_request">289</property>
-        <property name="height_request">120</property>
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="stock">gtk-missing-image</property>
-      </object>
-      <packing>
-        <property name="expand">False</property>
-        <property name="fill">False</property>
-        <property name="position">0</property>
-      </packing>
-    </child>
-    <child>
-      <object class="GtkLabel" id="label18">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="yalign">0.4699999988079071</property>
-        <property name="label" translatable="yes">vidref from Sat 15:30</property>
-      </object>
-      <packing>
-        <property name="expand">True</property>
-        <property name="fill">True</property>
-        <property name="position">1</property>
-      </packing>
-    </child>
-  </object>
-</interface>
--- a/light9/curvecalc/curveedit.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-"""
-this may be split out from curvecalc someday, since it doesn't
-need to be tied to a gui """
-import cgi
-
-from louie import dispatcher
-from rdflib import URIRef
-from twisted.internet import reactor
-import cyclone.web
-
-from cycloneerr import PrettyErrorHandler
-
-
-def serveCurveEdit(port, hoverTimeResponse, curveset):
-    """
-    /hoverTime requests actually are handled by the curvecalc gui
-    """
-    curveEdit = CurveEdit(curveset)
-
-    class HoverTime(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-        def get(self):
-            hoverTimeResponse(self)
-
-    class LiveInputPoint(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-        def post(self):
-            params = cgi.parse_qs(self.request.body)
-            curve = URIRef(params['curve'][0])
-            value = float(params['value'][0])
-            curveEdit.liveInputPoint(curve, value)
-            self.set_status(204)
-
-    reactor.listenTCP(
-        port,
-        cyclone.web.Application(handlers=[
-            (r'/hoverTime', HoverTime),
-            (r'/liveInputPoint', LiveInputPoint),
-        ],
-                                debug=True))
-
-
-class CurveEdit(object):
-
-    def __init__(self, curveset):
-        self.curveset = curveset
-        dispatcher.connect(self.inputTime, "input time")
-        self.currentTime = 0
-
-    def inputTime(self, val):
-        self.currentTime = val
-
-    def liveInputPoint(self, curveUri, value):
-        curve = self.curveset.curveFromUri(curveUri)
-        curve.live_input_point((self.currentTime, value), clear_ahead_secs=.5)
--- a/light9/curvecalc/curveview.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1360 +0,0 @@
-import math, logging
-from gi.repository import Gtk
-from gi.repository import Gdk
-from gi.repository import GooCanvas
-import louie as dispatcher
-from rdflib import Literal
-from twisted.internet import reactor
-from light9.curvecalc.zoomcontrol import RegionZoom
-from light9.curvecalc.curve import introPad, postPad
-from lib.goocanvas_compat import Points, polyline_new_line
-import imp
-
-log = logging.getLogger()
-print("curveview.py toplevel")
-
-
-def vlen(v):
-    return math.sqrt(v[0] * v[0] + v[1] * v[1])
-
-
-def angle_between(base, p0, p1):
-    p0 = p0[0] - base[0], p0[1] - base[1]
-    p1 = p1[0] - base[0], p1[1] - base[1]
-    p0 = [x / vlen(p0) for x in p0]
-    p1 = [x / vlen(p1) for x in p1]
-    dot = p0[0] * p1[0] + p0[1] * p1[1]
-    dot = max(-1, min(1, dot))
-    return math.degrees(math.acos(dot))
-
-
-class Sketch:
-    """a sketch motion on a curveview, with temporary points while you
-    draw, and simplification when you release"""
-
-    def __init__(self, curveview, ev):
-        self.curveview = curveview
-        self.pts = []
-        self.last_x = None
-
-    def motion(self, ev):
-        p = self.curveview.world_from_screen(ev.x, ev.y)
-        p = p[0], max(0, min(1, p[1]))
-        if self.last_x is not None and abs(ev.x - self.last_x) < 4:
-            return
-        self.last_x = ev.x
-        self.pts.append(p)
-        self.curveview.add_point(p)
-
-    def release(self, ev):
-        pts = sorted(self.pts)
-        finalPoints = pts[:]
-
-        dx = .01
-        to_remove = []
-        for i in range(1, len(pts) - 1):
-            x = pts[i][0]
-
-            p_left = (x - dx, self.curveview.curve(x - dx))
-            p_right = (x + dx, self.curveview.curve(x + dx))
-
-            if angle_between(pts[i], p_left, p_right) > 160:
-                to_remove.append(i)
-
-        for i in to_remove:
-            self.curveview.curve.remove_point(pts[i])
-            finalPoints.remove(pts[i])
-
-        # the simplified curve may now be too far away from some of
-        # the points, so we'll put them back. this has an unfortunate
-        # bias toward reinserting the earlier points
-        for i in to_remove:
-            p = pts[i]
-            if abs(self.curveview.curve(p[0]) - p[1]) > .1:
-                self.curveview.add_point(p)
-                finalPoints.append(p)
-
-        self.curveview.update_curve()
-        self.curveview.select_points(finalPoints)
-
-
-class SelectManip(object):
-    """
-    selection manipulator is created whenever you have a selection. It
-    draws itself on the canvas and edits the points when you drag
-    various parts
-    """
-
-    def __init__(self, parent, getSelectedIndices, getWorldPoint,
-                 getScreenPoint, getCanvasHeight, setPoints, getWorldTime,
-                 getWorldValue, getDragRange):
-        """parent goocanvas group"""
-        self.getSelectedIndices = getSelectedIndices
-        self.getWorldPoint = getWorldPoint
-        self.getScreenPoint = getScreenPoint
-        self.getCanvasHeight = getCanvasHeight
-        self.setPoints = setPoints
-        self.getWorldTime = getWorldTime
-        self.getDragRange = getDragRange
-        self.getWorldValue = getWorldValue
-        self.grp = GooCanvas.CanvasGroup(parent=parent)
-
-        self.title = GooCanvas.CanvasText(parent=self.grp,
-                                          text="selectmanip",
-                                          x=10,
-                                          y=10,
-                                          fill_color='white',
-                                          font="ubuntu 10")
-
-        self.bbox = GooCanvas.CanvasRect(parent=self.grp,
-                                         fill_color_rgba=0xffff0030,
-                                         line_width=0)
-
-        self.xTrans = polyline_new_line(
-            parent=self.grp,
-            close_path=True,
-            fill_color_rgba=0xffffff88,
-        )
-        self.centerScale = polyline_new_line(
-            parent=self.grp,
-            close_path=True,
-            fill_color_rgba=0xffffff88,
-        )
-
-        thickLine = lambda: polyline_new_line(
-            parent=self.grp, stroke_color_rgba=0xffffccff, line_width=6)
-        self.leftScale = thickLine()
-        self.rightScale = thickLine()
-        self.topScale = thickLine()
-
-        for grp, name in [
-            (self.xTrans, 'x'),
-            (self.leftScale, 'left'),
-            (self.rightScale, 'right'),
-            (self.topScale, 'top'),
-            (self.centerScale, 'centerScale'),
-        ]:
-            grp.connect("button-press-event", self.onPress, name)
-            grp.connect("button-release-event", self.onRelease, name)
-            grp.connect("motion-notify-event", self.onMotion, name)
-            grp.connect("enter-notify-event", self.onEnter, name)
-            grp.connect("leave-notify-event", self.onLeave, name)
-            # and hover highlight
-        self.update()
-
-    def onEnter(self, item, target_item, event, param):
-        self.prevColor = item.props.stroke_color_rgba
-        item.props.stroke_color_rgba = 0xff0000ff
-
-    def onLeave(self, item, target_item, event, param):
-        item.props.stroke_color_rgba = self.prevColor
-
-    def onPress(self, item, target_item, event, param):
-        self.dragStartTime = self.getWorldTime(event.x)
-        idxs = self.getSelectedIndices()
-
-        self.origPoints = [self.getWorldPoint(i) for i in idxs]
-        self.origMaxValue = max(p[1] for p in self.origPoints)
-        moveLeft, moveRight = self.getDragRange(idxs)
-
-        if param == 'centerScale':
-            self.maxPointMove = min(moveLeft, moveRight)
-
-        self.dragRange = (self.dragStartTime - moveLeft,
-                          self.dragStartTime + moveRight)
-        return True
-
-    def onMotion(self, item, target_item, event, param):
-        if hasattr(self, 'dragStartTime'):
-            origPts = list(zip(self.getSelectedIndices(), self.origPoints))
-            left = origPts[0][1][0]
-            right = origPts[-1][1][0]
-            width = right - left
-            dontCross = .001
-
-            clampLo = left if param == 'right' else self.dragRange[0]
-            clampHi = right if param == 'left' else self.dragRange[1]
-
-            def clamp(x, lo, hi):
-                return max(lo, min(hi, x))
-
-            mouseT = self.getWorldTime(event.x)
-            clampedT = clamp(mouseT, clampLo + dontCross, clampHi - dontCross)
-
-            dt = clampedT - self.dragStartTime
-
-            if param == 'x':
-                self.setPoints(
-                    (i, (orig[0] + dt, orig[1])) for i, orig in origPts)
-            elif param == 'left':
-                self.setPoints(
-                    (i,
-                     (left + dt + (orig[0] - left) / width *
-                      clamp(width - dt, dontCross, right - clampLo - dontCross),
-                      orig[1])) for i, orig in origPts)
-            elif param == 'right':
-                self.setPoints(
-                    (i,
-                     (left + (orig[0] - left) / width *
-                      clamp(width + dt, dontCross, clampHi - left - dontCross),
-                      orig[1])) for i, orig in origPts)
-            elif param == 'top':
-                v = self.getWorldValue(event.y)
-                if self.origMaxValue == 0:
-                    self.setPoints((i, (orig[0], v)) for i, orig in origPts)
-                else:
-                    scl = max(0,
-                              min(1 / self.origMaxValue, v / self.origMaxValue))
-                    self.setPoints(
-                        (i, (orig[0], orig[1] * scl)) for i, orig in origPts)
-
-            elif param == 'centerScale':
-                dt = mouseT - self.dragStartTime
-                rad = width / 2
-                tMid = left + rad
-                maxScl = (rad + self.maxPointMove - dontCross) / rad
-                newWidth = max(dontCross / width, min(
-                    (rad + dt) / rad, maxScl)) * width
-                self.setPoints(
-                    (i, (tMid + ((orig[0] - left) / width - .5) * newWidth,
-                         orig[1])) for i, orig in origPts)
-
-    def onRelease(self, item, target_item, event, param):
-        if hasattr(self, 'dragStartTime'):
-            del self.dragStartTime
-
-    def update(self):
-        """if the view or selection or selected point positions
-        change, call this to redo the layout of the manip"""
-        idxs = self.getSelectedIndices()
-        pts = [self.getScreenPoint(i) for i in idxs]
-
-        b = self.bbox.props
-        b.x = min(p[0] for p in pts) - 5
-        b.y = min(p[1] for p in pts) - 5
-        margin = 10 if len(pts) > 1 else 0
-        b.width = max(p[0] for p in pts) - b.x + margin
-        b.height = min(
-            max(p[1] for p in pts) - b.y + margin,
-            self.getCanvasHeight() - b.y - 1)
-
-        multi = (GooCanvas.CanvasItemVisibility.VISIBLE
-                 if len(pts) > 1 else GooCanvas.CanvasItemVisibility.INVISIBLE)
-        b.visibility = multi
-        self.leftScale.props.visibility = multi
-        self.rightScale.props.visibility = multi
-        self.topScale.props.visibility = multi
-        self.centerScale.props.visibility = multi
-
-        self.title.props.text = "%s %s selected" % (
-            len(idxs), "point" if len(idxs) == 1 else "points")
-
-        centerX = b.x + b.width / 2
-
-        midY = self.getCanvasHeight() * .5
-        loY = self.getCanvasHeight() * .8
-
-        self.leftScale.props.points = Points([(b.x, b.y),
-                                              (b.x, b.y + b.height)])
-        self.rightScale.props.points = Points([(b.x + b.width, b.y),
-                                               (b.x + b.width, b.y + b.height)])
-
-        self.topScale.props.points = Points([(b.x, b.y), (b.x + b.width, b.y)])
-
-        self.updateXTrans(centerX, midY)
-
-        self.centerScale.props.points = Points([(centerX - 5, loY - 5),
-                                                (centerX + 5, loY - 5),
-                                                (centerX + 5, loY + 5),
-                                                (centerX - 5, loY + 5)])
-
-    def updateXTrans(self, centerX, midY):
-        x1 = centerX - 30
-        x2 = centerX - 20
-        x3 = centerX + 20
-        x4 = centerX + 30
-        y1 = midY - 10
-        y2 = midY - 5
-        y3 = midY + 5
-        y4 = midY + 10
-        shape = [
-            (x1, midY),  # left tip
-            (x2, y1),
-            (x2, y2),
-            (x3, y2),
-            (x3, y1),
-            (x4, midY),  # right tip
-            (x3, y4),
-            (x3, y3),
-            (x2, y3),
-            (x2, y4)
-        ]
-
-        self.xTrans.props.points = Points(shape)
-
-    def destroy(self):
-        self.grp.remove()
-
-
-class Curveview(object):
-    """
-    graphical curve widget only. Please pack .widget
-
-    <caller's parent>
-     -> self.widget <caller packs this>
-       -> EventBox
-         -> Box vertical, for border
-           -> self.canvas GooCanvas
-             -> root CanvasItem
-               ..various groups and items..
-
-    The canvas x1/x2/y1/y2 coords are updated to match self.widget.
-    
-    """
-
-    def __init__(self,
-                 curve,
-                 markers,
-                 knobEnabled=False,
-                 isMusic=False,
-                 zoomControl=None):
-        """knobEnabled=True highlights the previous key and ties it to a
-        hardware knob"""
-        self.curve = curve
-        self.markers = markers
-        self.knobEnabled = knobEnabled
-        self._isMusic = isMusic
-        self.zoomControl = zoomControl
-
-        self.redrawsEnabled = False
-
-        box = self.createOuterWidgets()
-        self.canvas = self.createCanvasWidget(box)
-        self.trackWidgetSize()
-        self.update_curve()
-
-        self._time = -999
-        self.last_mouse_world = None
-        self.entered = False  # is the mouse currently over this widget
-        self.selected_points = []  # idx of points being dragged
-        self.dots = {}
-        # self.bind("<Enter>",self.focus)
-        dispatcher.connect(self.playPause, "onPlayPause")
-        dispatcher.connect(self.input_time, "input time")
-        dispatcher.connect(self.update_curve, "zoom changed")
-        dispatcher.connect(self.update_curve,
-                           "points changed",
-                           sender=self.curve)
-        dispatcher.connect(self.update_curve, "mute changed", sender=self.curve)
-        dispatcher.connect(self.select_between, "select between")
-        dispatcher.connect(self.acls, "all curves lose selection")
-        if self.knobEnabled:
-            dispatcher.connect(self.knob_in, "knob in")
-            dispatcher.connect(self.slider_in, "set key")
-
-        # todo: hold control to get a [+] cursor
-        #        def curs(ev):
-        #            print ev.state
-        #        self.bind("<KeyPress>",curs)
-        #        self.bind("<KeyRelease-Control_L>",lambda ev: curs(0))
-
-        # this binds on c-a-b1, etc
-        if 0:  # unported
-            self.regionzoom = RegionZoom(self, self.world_from_screen,
-                                         self.screen_from_world)
-
-        self.sketch = None  # an in-progress sketch
-
-        self.dragging_dots = False
-        self.selecting = False
-
-    def acls(self, butNot=None):
-        if butNot is self:
-            return
-        self.unselect()
-
-    def createOuterWidgets(self):
-        self.timelineLine = self.curveGroup = self.selectManip = None
-        self.widget = Gtk.EventBox()
-        self.widget.set_can_focus(True)
-        self.widget.add_events(Gdk.EventMask.KEY_PRESS_MASK |
-                               Gdk.EventMask.FOCUS_CHANGE_MASK)
-        self.onFocusOut()
-
-        box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
-        box.set_border_width(1)
-        self.widget.add(box)
-        box.show()
-        return box
-
-    def trackWidgetSize(self):
-        """
-        Also tried:
-
-            visibility-notify-event
-            (Gdk.EventMask.VISIBILITY_NOTIFY_MASK) fires on some
-            resizes but definitely not all. During window resizes,
-            sometimes I have to 'shake' the window size to get all
-            curves to update.
-        
-            configure-event seems to never fire.
-
-            size-allocate seems right but i get into infinite bounces
-            between two sizes
-        """
-
-        def sizeEvent(w, alloc):
-            p = self.canvas.props
-            if (alloc.width, alloc.height) != (p.x2, p.y2):
-                p.x1, p.x2 = 0, alloc.width
-                p.y1, p.y2 = 0, alloc.height
-                # calling update_curve in this event usually doesn't work
-                reactor.callLater(0, self.update_curve)
-            return False
-
-        #self.widget.connect('size-allocate', sizeEvent) # see docstring
-
-        def visEvent(w, alloc):
-            self.setCanvasToWidgetSize()
-            return False
-
-        self.widget.add_events(Gdk.EventMask.VISIBILITY_NOTIFY_MASK)
-        self.widget.connect('visibility-notify-event', visEvent)
-
-    def setCanvasToWidgetSize(self):
-        p = self.canvas.props
-        w = self.widget.get_allocated_width()
-        h = self.widget.get_allocated_height()
-        if (w, h) != (p.x2, p.y2):
-            p.x1, p.x2 = 0, w
-            p.y1, p.y2 = 0, h
-            self.update_curve()
-
-    def createCanvasWidget(self, parent):
-        # this is only separate from createOuterWidgets because in the
-        # past, i worked around display bugs by recreating the whole
-        # canvas widget. If that's not necessary, this could be more
-        # clearly combined with createOuterWidgets since there's no
-        # time you'd want that one but not this one.
-        canvas = GooCanvas.Canvas()
-        parent.pack_start(canvas, expand=True, fill=True, padding=0)
-        canvas.show()
-
-        p = canvas.props
-        p.background_color = 'black'
-        root = canvas.get_root_item()
-
-        canvas.connect("leave-notify-event", self.onLeave)
-        canvas.connect("enter-notify-event", self.onEnter)
-        canvas.connect("motion-notify-event", self.onMotion)
-        canvas.connect("scroll-event", self.onScroll)
-        canvas.connect("button-release-event", self.onRelease)
-        root.connect("button-press-event", self.onCanvasPress)
-
-        self.widget.connect("key-press-event", self.onKeyPress)
-
-        self.widget.connect("focus-in-event", self.onFocusIn)
-        self.widget.connect("focus-out-event", self.onFocusOut)
-        #self.widget.connect("event", self.onAny)
-        return canvas
-
-    def onAny(self, w, event):
-        print("   %s on %s" % (event, w))
-
-    def onFocusIn(self, *args):
-        dispatcher.send('curve row focus change')
-        dispatcher.send("all curves lose selection", butNot=self)
-
-        self.widget.modify_bg(Gtk.StateFlags.NORMAL, Gdk.color_parse("red"))
-
-    def onFocusOut(self, widget=None, event=None):
-        dispatcher.send('curve row focus change')
-        self.widget.modify_bg(Gtk.StateFlags.NORMAL, Gdk.color_parse("gray30"))
-
-        # you'd think i'm unselecting when we lose focus, but we also
-        # lose focus when the user moves off the toplevel window, and
-        # that's not a time to forget the selection. See the 'all
-        # curves lose selection' signal for the fix.
-
-    def onKeyPress(self, widget, event):
-        if event.string in list('12345'):
-            x = int(event.string)
-            self.add_point((self.current_time(), (x - 1) / 4.0))
-        if event.string in list('qwerty'):
-            self.add_marker((self.current_time(), event.string))
-
-    def onDelete(self):
-        if self.selected_points:
-            self.remove_point_idx(*self.selected_points)
-
-    def onCanvasPress(self, item, target_item, event):
-        # when we support multiple curves per canvas, this should find
-        # the close one and add a point to that. Binding to the line
-        # itself is probably too hard to hit. Maybe a background-color
-        # really thick line would be a nice way to allow a sloppier
-        # click
-
-        self.widget.grab_focus()
-
-        _, flags = event.get_state()
-        if flags & Gdk.ModifierType.CONTROL_MASK:
-            self.new_point_at_mouse(event)
-        elif flags & Gdk.ModifierType.SHIFT_MASK:
-            self.sketch_press(event)
-        else:
-            self.select_press(event)
-
-        # this stops some other handler that wants to unfocus
-        return True
-
-    def playPause(self):
-        """
-        user has pressed ctrl-p over a curve view, possibly this
-        one. Returns the time under the mouse if we know it, or else
-        None
-
-        todo: there should be a faint timecursor line under the mouse
-        so it's more obvious that we use that time for some
-        events. Rt-click should include Ctrl+P as 'play/pause from
-        here'
-        """
-        # maybe self.canvas.get_pointer would be ok for this? i didn't try it
-        if self.entered and hasattr(self, 'lastMouseX'):
-            t = self.world_from_screen(self.lastMouseX, 0)[0]
-            return t
-        return None
-
-    def goLive(self):
-        """this is for startup performance only, since the curves were
-        getting redrawn many times. """
-        self.redrawsEnabled = True
-        self.update_curve()
-
-    def knob_in(self, curve, value):
-        """user turned a hardware knob, which edits the point to the
-        left of the current time"""
-        if curve != self.curve:
-            return
-        idx = self.curve.index_before(self.current_time())
-        if idx is not None:
-            pos = self.curve.points[idx]
-            self.curve.set_points([(idx, (pos[0], value))])
-
-    def slider_in(self, curve, value=None):
-        """user pushed on a slider. make a new key.  if value is None,
-        the value will be the same as the last."""
-        if curve != self.curve:
-            return
-
-        if value is None:
-            value = self.curve.eval(self.current_time())
-
-        self.curve.insert_pt((self.current_time(), value))
-
-    def print_state(self, msg=""):
-        if 0:
-            print("%s: dragging_dots=%s selecting=%s" %
-                  (msg, self.dragging_dots, self.selecting))
-
-    def select_points(self, pts):
-        """set selection to the given point values (tuples, not indices)"""
-        idxs = []
-        for p in pts:
-            idxs.append(self.curve.points.index(p))
-        self.select_indices(idxs)
-
-    def select_indices(self, idxs):
-        """set selection to these point indices. This is the only
-        writer to self.selected_points"""
-        self.selected_points = idxs
-        self.highlight_selected_dots()
-        if self.selected_points and not self.selectManip:
-            self.selectManip = SelectManip(
-                self.canvas.get_root_item(),
-                getSelectedIndices=lambda: sorted(self.selected_points),
-                getWorldPoint=lambda i: self.curve.points[i],
-                getScreenPoint=lambda i: self.screen_from_world(self.curve.
-                                                                points[i]),
-                getWorldTime=lambda x: self.world_from_screen(x, 0)[0],
-                getWorldValue=lambda y: self.world_from_screen(0, y)[1],
-                getCanvasHeight=lambda: self.canvas.props.y2,
-                setPoints=self.setPoints,
-                getDragRange=self.getDragRange,
-            )
-        if not self.selected_points and self.selectManip:
-            self.selectManip.destroy()
-            self.selectManip = None
-
-        self.selectionChanged()
-
-    def getDragRange(self, idxs):
-        """
-        if you're dragging these points, what's the most time you can move
-        left and right before colliding (exactly) with another
-        point
-        """
-        maxLeft = maxRight = 99999
-        cp = self.curve.points
-        for i in idxs:
-            nextStatic = i
-            while nextStatic >= 0 and nextStatic in idxs:
-                nextStatic -= 1
-            if nextStatic >= 0:
-                maxLeft = min(maxLeft, cp[i][0] - cp[nextStatic][0])
-
-            nextStatic = i
-            while nextStatic <= len(cp) - 1 and nextStatic in idxs:
-                nextStatic += 1
-            if nextStatic <= len(cp) - 1:
-                maxRight = min(maxRight, cp[nextStatic][0] - cp[i][0])
-        return maxLeft, maxRight
-
-    def setPoints(self, updates):
-        self.curve.set_points(updates)
-
-    def selectionChanged(self):
-        if self.selectManip:
-            self.selectManip.update()
-
-    def select_press(self, ev):
-        # todo: these select_ handlers are getting called on c-a-drag
-        # zooms too. the dispatching should be more selective than
-        # just calling both handlers all the time
-        self.print_state("select_press")
-        if self.dragging_dots:
-            return
-        if not self.selecting:
-            self.selecting = True
-            self.select_start = self.world_from_screen(ev.x, 0)[0]
-            #cursors.push(self,"gumby")
-
-    def select_motion(self, ev):
-        if not self.selecting:
-            return
-        start = self.select_start
-        cur = self.world_from_screen(ev.x, 0)[0]
-        self.select_between(start, cur)
-
-    def select_release(self, ev):
-        self.print_state("select_release")
-
-        # dotrelease never gets called, but I can clear that state here
-        self.dragging_dots = False
-
-        if not self.selecting:
-            return
-        #cursors.pop(self)
-        self.selecting = False
-        self.select_between(self.select_start,
-                            self.world_from_screen(ev.x, 0)[0])
-
-    def sketch_press(self, ev):
-        self.sketch = Sketch(self, ev)
-
-    def sketch_motion(self, ev):
-        if self.sketch:
-            self.sketch.motion(ev)
-
-    def sketch_release(self, ev):
-        if self.sketch:
-            self.sketch.release(ev)
-            self.sketch = None
-
-    def current_time(self):
-        return self._time
-
-    def _coords(self):
-        z = self.zoomControl
-        ht = self.canvas.props.y2
-        marginBottom = 3 if ht > 40 else 0
-        marginTop = marginBottom
-        return z, ht, marginBottom, marginTop
-
-    def screen_from_world(self, p):
-        z, ht, marginBottom, marginTop = self._coords()
-        return ((p[0] - z.start) / (z.end - z.start) * self.canvas.props.x2,
-                (ht - marginBottom) - p[1] * (ht - (marginBottom + marginTop)))
-
-    def world_from_screen(self, x, y):
-        z, ht, marginBottom, marginTop = self._coords()
-        return (x / self.canvas.props.x2 * (z.end - z.start) + z.start,
-                ((ht - marginBottom) - y) / (ht - (marginBottom + marginTop)))
-
-    def input_time(self, val, forceUpdate=False):
-        if self._time == val:
-            return
-        self.update_time_bar(val)
-
-    def alive(self):
-        # Some handlers still run after a view is destroyed, which
-        # leads to crashes in somewhere like
-        # goocanvas_add_item. Workaround is to disable certain methods
-        # when the widget is gone. Correct solution would be to stop
-        # and free those handlers when the widget is gone.
-        return self.canvas.is_visible()
-
-    def update_time_bar(self, t):
-        if not self.alive():
-            return
-        if not getattr(self, 'timelineLine', None):
-            self.timelineGroup = GooCanvas.CanvasGroup(
-                parent=self.canvas.get_root_item())
-            self.timelineLine = polyline_new_line(parent=self.timelineGroup,
-                                                  points=Points([(0, 0),
-                                                                 (0, 0)]),
-                                                  line_width=2,
-                                                  stroke_color='red')
-
-        try:
-            pts = [
-                self.screen_from_world((t, 0)),
-                self.screen_from_world((t, 1))
-            ]
-        except ZeroDivisionError:
-            pts = [(-1, -1), (-1, -1)]
-        self.timelineLine.set_property('points', Points(pts))
-
-        self._time = t
-        if self.knobEnabled:
-            self.delete('knob')
-            prevKey = self.curve.point_before(t)
-            if prevKey is not None:
-                pos = self.screen_from_world(prevKey)
-                self.create_oval(pos[0] - 8,
-                                 pos[1] - 8,
-                                 pos[0] + 8,
-                                 pos[1] + 8,
-                                 outline='#800000',
-                                 tags=('knob',))
-                dispatcher.send("knob out", value=prevKey[1], curve=self.curve)
-
-    def update_curve(self, *args):
-        if not getattr(self, '_pending_update', False):
-            self._pending_update = True
-            reactor.callLater(.01, self._update_curve)
-
-    def _update_curve(self):
-        try:
-            self._update_curve2()
-        except Exception:
-            log.error("in update_curve on %s", self.curve.uri)
-            raise
-
-    def _update_curve2(self):
-        if not getattr(self, '_pending_update', False):
-            return
-        self._pending_update = False
-        if not self.alive():
-            return
-        if not self.redrawsEnabled:
-            print("no redrawsEnabled, skipping", self)
-            return
-
-        visible_x = (self.world_from_screen(0, 0)[0],
-                     self.world_from_screen(self.canvas.props.x2, 0)[0])
-
-        visible_idxs = self.curve.indices_between(visible_x[0],
-                                                  visible_x[1],
-                                                  beyond=1)
-        visible_points = [self.curve.points[i] for i in visible_idxs]
-
-        if getattr(self, 'curveGroup', None):
-            self.curveGroup.remove()
-        self.curveGroup = GooCanvas.CanvasGroup(
-            parent=self.canvas.get_root_item())
-        self.curveGroup.lower(None)
-
-        self.canvas.set_property("background-color",
-                                 "gray20" if self.curve.muted else "black")
-
-        self.update_time_bar(self._time)
-        self._draw_line(visible_points, area=True)
-        self._draw_markers(
-            self.markers.points[i]
-            for i in self.markers.indices_between(visible_x[0], visible_x[1]))
-        if self.canvas.props.y2 > 80:
-            self._draw_time_tics(visible_x)
-
-            self.dots = {}  # idx : canvas rectangle
-
-            if len(visible_points) < 50 and not self.curve.muted:
-                self._draw_handle_points(visible_idxs, visible_points)
-
-        self.selectionChanged()
-
-    def is_music(self):
-        """are we one of the music curves (which might be drawn a bit
-        differently)"""
-        return self._isMusic
-
-    def _draw_markers(self, pts):
-        colorMap = {
-            'q': '#598522',
-            'w': '#662285',
-            'e': '#852922',
-            'r': '#85225C',
-            't': '#856B22',
-            'y': '#227085',
-        }
-        for t, name in pts:
-            x = int(self.screen_from_world((t, 0))[0]) + .5
-            polyline_new_line(self.curveGroup,
-                              x,
-                              0,
-                              x,
-                              self.canvas.props.y2,
-                              line_width=.4 if name in 'rty' else .8,
-                              stroke_color=colorMap.get(name, 'gray'))
-
-    def _draw_time_tics(self, visible_x):
-        tic = self._draw_one_tic
-
-        tic(0, "0")
-        t1, t2 = visible_x
-        if t2 - t1 < 30:
-            for t in range(int(t1), int(t2) + 1):
-                tic(t, str(t))
-        tic(introPad, str(introPad))
-
-        endtimes = dispatcher.send("get max time")
-        if endtimes:
-            endtime = endtimes[0][1]
-            tic(endtime, "end %.1f" % endtime)
-            tic(endtime - postPad, "post %.1f" % (endtime - postPad))
-
-    def _draw_one_tic(self, t, label):
-        try:
-            x = self.screen_from_world((t, 0))[0]
-            if not 0 <= x < self.canvas.props.x2:
-                return
-            x = max(5, x)  # cheat left-edge stuff onscreen
-        except ZeroDivisionError:
-            x = -100
-
-        ht = self.canvas.props.y2
-        polyline_new_line(self.curveGroup,
-                          x,
-                          ht,
-                          x,
-                          ht - 20,
-                          line_width=.5,
-                          stroke_color='gray70')
-        GooCanvas.CanvasText(parent=self.curveGroup,
-                             fill_color="white",
-                             anchor=GooCanvas.CanvasAnchorType.SOUTH,
-                             font="ubuntu 7",
-                             x=x + 3,
-                             y=ht - 20,
-                             text=label)
-
-    def _draw_line(self, visible_points, area=False):
-        if not visible_points:
-            return
-        linepts = []
-        step = 1
-        linewidth = 1.5
-        maxPointsToDraw = self.canvas.props.x2 / 2
-        if len(visible_points) > maxPointsToDraw:
-            step = int(len(visible_points) / maxPointsToDraw)
-            linewidth = .8
-        for p in visible_points[::step]:
-            try:
-                x, y = self.screen_from_world(p)
-            except ZeroDivisionError:
-                x = y = -100
-            linepts.append((int(x) + .5, int(y) + .5))
-
-        if self.curve.muted:
-            fill = 'grey34'
-        else:
-            fill = 'white'
-
-        if area:
-            try:
-                base = self.screen_from_world((0, 0))[1]
-            except ZeroDivisionError:
-                base = -100
-            base = base + linewidth / 2
-            areapts = linepts[:]
-            if len(areapts) >= 1:
-                areapts.insert(0, (0, areapts[0][1]))
-                areapts.append((self.canvas.props.x2, areapts[-1][1]))
-            polyline_new_line(
-                parent=self.curveGroup,
-                points=Points([(areapts[0][0], base)] + areapts +
-                              [(areapts[-1][0], base)]),
-                close_path=True,
-                line_width=0,
-                # transparent as a workaround for
-                # covering some selectmanips (which
-                # become unclickable)
-                fill_color_rgba=0x00800080,
-            )
-
-        self.pl = polyline_new_line(
-            parent=self.curveGroup,
-            points=Points(linepts),
-            line_width=linewidth,
-            stroke_color=fill,
-        )
-
-    def _draw_handle_points(self, visible_idxs, visible_points):
-        for i, p in zip(visible_idxs, visible_points):
-            rad = 6
-            worldp = p
-            try:
-                p = self.screen_from_world(p)
-            except ZeroDivisionError:
-                p = (-100, -100)
-            dot = GooCanvas.CanvasRect(
-                parent=self.curveGroup,
-                x=int(p[0] - rad) + .5,
-                y=int(p[1] - rad) + .5,
-                width=rad * 2,
-                height=rad * 2,
-                stroke_color='gray90',
-                fill_color='blue',
-                line_width=1,
-                #tags=('curve','point', 'handle%d' % i)
-            )
-
-            if worldp[1] == 0:
-                rad += 3
-                GooCanvas.CanvasEllipse(
-                    parent=self.curveGroup,
-                    center_x=p[0],
-                    center_y=p[1],
-                    radius_x=rad,
-                    radius_y=rad,
-                    line_width=2,
-                    stroke_color='#00a000',
-                    #tags=('curve','point', 'handle%d' % i)
-                )
-            dot.connect("button-press-event", self.dotpress, i)
-            #self.tag_bind('handle%d' % i,"<ButtonPress-1>",
-            #              lambda ev,i=i: self.dotpress(ev,i))
-            #self.tag_bind('handle%d' % i, "<Key-d>",
-            #              lambda ev, i=i: self.remove_point_idx(i))
-
-            self.dots[i] = dot
-
-        self.highlight_selected_dots()
-
-    def find_index_near(self, x, y):
-        tags = self.gettags(self.find_closest(x, y))
-        try:
-            handletags = [t for t in tags if t.startswith('handle')]
-            return int(handletags[0][6:])
-        except IndexError:
-            raise ValueError("no point found")
-
-    def new_point_at_mouse(self, ev):
-        p = self.world_from_screen(ev.x, ev.y)
-        x = p[0]
-        y = self.curve.eval(x)
-        self.add_point((x, y))
-
-    def add_points(self, pts):
-        idxs = [self.curve.insert_pt(p) for p in pts]
-        self.select_indices(idxs)
-
-    def add_point(self, p):
-        self.add_points([p])
-
-    def add_marker(self, p):
-        self.markers.insert_pt(p)
-
-    def remove_point_idx(self, *idxs):
-        idxs = list(idxs)
-        while idxs:
-            i = idxs.pop()
-
-            self.curve.pop_point(i)
-            newsel = []
-            newidxs = []
-            for si in range(len(self.selected_points)):
-                sp = self.selected_points[si]
-                if sp == i:
-                    continue
-                if sp > i:
-                    sp -= 1
-                newsel.append(sp)
-            for ii in range(len(idxs)):
-                if ii > i:
-                    ii -= 1
-                newidxs.append(idxs[ii])
-
-            self.select_indices(newsel)
-            idxs[:] = newidxs
-
-    def highlight_selected_dots(self):
-        if not self.redrawsEnabled:
-            return
-
-        for i, d in list(self.dots.items()):
-            if i in self.selected_points:
-                d.set_property('fill_color', 'red')
-            else:
-                d.set_property('fill_color', 'blue')
-
-    def dotpress(self, r1, r2, ev, dotidx):
-        self.print_state("dotpress")
-        if dotidx not in self.selected_points:
-            self.select_indices([dotidx])
-
-        self.last_mouse_world = self.world_from_screen(ev.x, ev.y)
-        self.dragging_dots = True
-
-    def select_between(self, start, end):
-        if start > end:
-            start, end = end, start
-        self.select_indices(self.curve.indices_between(start, end))
-
-    def onEnter(self, widget, event):
-        self.entered = True
-
-    def onLeave(self, widget, event):
-        self.entered = False
-
-    def onMotion(self, widget, event):
-        self.lastMouseX = event.x
-
-        if event.state & Gdk.ModifierType.SHIFT_MASK and 1:  # and B1
-            self.sketch_motion(event)
-            return
-
-        self.select_motion(event)
-
-        if not self.dragging_dots:
-            return
-        if not event.state & 256:
-            return  # not lmb-down
-
-        # this way is accumulating error and also making it harder to
-        # undo (e.g. if the user moves far out of the window or
-        # presses esc or something). Instead, we should be resetting
-        # the points to their start pos plus our total offset.
-        cur = self.world_from_screen(event.x, event.y)
-        if self.last_mouse_world:
-            delta = (cur[0] - self.last_mouse_world[0],
-                     cur[1] - self.last_mouse_world[1])
-        else:
-            delta = 0, 0
-        self.last_mouse_world = cur
-
-        self.translate_points(delta)
-
-    def translate_points(self, delta):
-        moved = False
-
-        cp = self.curve.points
-        updates = []
-        for idx in self.selected_points:
-
-            newp = [cp[idx][0] + delta[0], cp[idx][1] + delta[1]]
-
-            newp[1] = max(0, min(1, newp[1]))
-
-            if idx > 0 and newp[0] <= cp[idx - 1][0]:
-                continue
-            if idx < len(cp) - 1 and newp[0] >= cp[idx + 1][0]:
-                continue
-            moved = True
-            updates.append((idx, tuple(newp)))
-        self.curve.set_points(updates)
-        return moved
-
-    def unselect(self):
-        self.select_indices([])
-
-    def onScroll(self, widget, event):
-        t = self.world_from_screen(event.x, 0)[0]
-        self.zoomControl.zoom_about_mouse(
-            t,
-            factor=1.5 if event.direction == Gdk.ScrollDirection.DOWN else 1 /
-            1.5)
-        # Don't actually scroll the canvas! (it shouldn't have room to
-        # scroll anyway, but it does because of some coordinate errors
-        # and borders and stuff)
-        return True
-
-    def onRelease(self, widget, event):
-        self.print_state("dotrelease")
-
-        if event.state & Gdk.ModifierType.SHIFT_MASK:  # relese-B1
-            self.sketch_release(event)
-            return
-
-        self.select_release(event)
-
-        if not self.dragging_dots:
-            return
-        self.last_mouse_world = None
-        self.dragging_dots = False
-
-
-class CurveRow(object):
-    """
-    one of the repeating curve rows (including widgets on the left)
-
-    i wish these were in a list-style TreeView so i could set_reorderable on it
-
-    please pack self.box
-    """
-
-    def __init__(self, graph, name, curve, markers, zoomControl):
-        self.graph = graph
-        self.name = name
-        self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
-        self.box.set_border_width(1)
-
-        self.cols = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
-        self.box.add(self.cols)
-
-        controls = Gtk.Frame()
-        controls.set_size_request(160, -1)
-        controls.set_shadow_type(Gtk.ShadowType.OUT)
-        self.cols.pack_start(controls, expand=False, fill=True, padding=0)
-        self.setupControls(controls, name, curve)
-
-        self.curveView = Curveview(curve,
-                                   markers,
-                                   isMusic=name in ['music', 'smooth_music'],
-                                   zoomControl=zoomControl)
-
-        self.initCurveView()
-        dispatcher.connect(self.rebuild, "all curves rebuild")
-
-    def isFocus(self):
-        return self.curveView.widget.is_focus()
-
-    def rebuild(self):
-        raise NotImplementedError('obsolete, if curves are drawing right')
-        self.curveView.rebuild()
-        self.initCurveView()
-        self.update_ui_to_collapsed_state()
-
-    def destroy(self):
-        self.curveView.entered = False  # help suppress bad position events
-        del self.curveView
-        self.box.destroy()
-
-    def initCurveView(self):
-        self.curveView.widget.show()
-        self.setHeight(100)
-        self.cols.pack_start(self.curveView.widget,
-                             expand=True,
-                             fill=True,
-                             padding=0)
-
-    def setHeight(self, h):
-        self.curveView.widget.set_size_request(-1, h)
-
-        # this should have been automatic when the size changed, but
-        # the signals for that are wrong somehow.
-        reactor.callLater(0, self.curveView.setCanvasToWidgetSize)
-
-    def setupControls(self, controls, name, curve):
-        box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
-        controls.add(box)
-
-        curve_name_label = Gtk.LinkButton()
-        print("need to truncate this name length somehow")
-
-        def update_label():
-            # todo: abort if we don't still exist...
-            p = curve_name_label.props
-            p.uri = curve.uri
-            p.label = self.graph.label(curve.uri)
-
-        self.graph.addHandler(update_label)
-
-        self.muted = Gtk.CheckButton("M")
-        self.muted.connect("toggled", self.sync_mute_to_curve)
-        dispatcher.connect(self.mute_changed, 'mute changed', sender=curve)
-
-        box.pack_start(curve_name_label, expand=True, fill=True, padding=0)
-        box.pack_start(self.muted, expand=True, fill=True, padding=0)
-
-    def onDelete(self):
-        self.curveView.onDelete()
-
-    def sync_mute_to_curve(self, *args):
-        """send value from CheckButton to the master attribute inside Curve"""
-        new_mute = self.muted.get_active()
-        self.curveView.curve.muted = new_mute
-
-    def update_mute_look(self):
-        """set colors on the widgets in the row according to self.muted.get()"""
-        # not yet ported for gtk
-        return
-        if self.curveView.curve.muted:
-            new_bg = 'grey20'
-        else:
-            new_bg = 'normal'
-
-        for widget in self.widgets:
-            widget['bg'] = new_bg
-
-    def mute_changed(self):
-        """call this if curve.muted changed"""
-        self.muted.set_active(self.curveView.curve.muted)
-        #self.update_mute_look()
-
-
-class Curvesetview(object):
-    """
-    
-    """
-
-    def __init__(self, graph, curvesVBox, zoomControlBox, curveset):
-        self.graph = graph
-        self.live = True
-        self.curvesVBox = curvesVBox
-        self.curveset = curveset
-        self.allCurveRows = set()
-        self.visibleHeight = 1000
-
-        self.zoomControl = self.initZoomControl(zoomControlBox)
-        self.zoomControl.redrawzoom()
-
-        for uri, label, curve in curveset.currentCurves():
-            self.add_curve(uri, label, curve)
-
-        dispatcher.connect(self.clear_curves, "clear_curves")
-        dispatcher.connect(self.add_curve, "add_curve", sender=self.curveset)
-        dispatcher.connect(self.set_featured_curves, "set_featured_curves")
-        dispatcher.connect(self.song_has_changed, "song_has_changed")
-
-        self.newcurvename = Gtk.EntryBuffer.new("", 0)
-
-        eventBox = self.curvesVBox.get_parent()
-        eventBox.connect("key-press-event", self.onKeyPress)
-        eventBox.connect("button-press-event", self.takeFocus)
-
-        self.watchCurveAreaHeight()
-
-    def __del__(self):
-        print("del curvesetview", id(self))
-
-    def initZoomControl(self, zoomControlBox):
-        import light9.curvecalc.zoomcontrol
-        imp.reload(light9.curvecalc.zoomcontrol)
-        zoomControl = light9.curvecalc.zoomcontrol.ZoomControl()
-        zoomControlBox.add(zoomControl.widget)
-        zoomControl.widget.show_all()
-        return zoomControl
-
-    def clear_curves(self):
-        """curveset is about to re-add all new curves"""
-        while self.allCurveRows:
-            self.allCurveRows.pop().destroy()
-
-    def song_has_changed(self):
-        self.zoomControl.redrawzoom()
-
-    def takeFocus(self, *args):
-        """the whole curveset's eventbox is what gets the focus, currently, so
-        keys like 'c' can work in it"""
-        dispatcher.send("all curves lose selection")
-        self.curvesVBox.get_parent().grab_focus()
-
-    def curveRow_from_name(self, name):
-        for cr in self.allCurveRows:
-            if cr.name == name:
-                return cr
-        raise ValueError("couldn't find curveRow named %r" % name)
-
-    def set_featured_curves(self, curveNames):
-        """bring these curves to the top of the stack"""
-        for n in curveNames[::-1]:
-            self.curvesVBox.reorder_child(
-                self.curveRow_from_name(n).box, Gtk.PACK_START)
-
-    def onKeyPress(self, widget, event):
-        if not self.live:  # workaround for old instances living past reload()
-            return
-
-        #r = self.row_under_mouse()
-        #key = event.string
-        pass  # no handlers right now
-
-    def row_under_mouse(self):
-        x, y = self.curvesVBox.get_pointer()
-        for r in self.allCurveRows:
-            inRowX, inRowY = self.curvesVBox.translate_coordinates(r.box, x, y)
-            alloc = r.box.get_allocation()
-            if 0 <= inRowX < alloc.width and 0 <= inRowY < alloc.height:
-                return r
-        raise ValueError("no curveRow is under the mouse")
-
-    def focus_entry(self):
-        self.entry.focus()
-
-    def new_curve(self, event):
-        self.curveset.new_curve(self.newcurvename.get())
-        self.newcurvename.set('')
-
-    def add_curve(self, uri, label, curve):
-        if isinstance(label, Literal):
-            label = str(label)
-
-        f = CurveRow(self.graph, label, curve, self.curveset.markers,
-                     self.zoomControl)
-        self.curvesVBox.pack_start(f.box, expand=True, fill=True, padding=0)
-        f.box.show_all()
-        self.allCurveRows.add(f)
-        self.setRowHeights()
-        f.curveView.goLive()
-
-    def watchCurveAreaHeight(self):
-
-        def sizeEvent(w, size):
-            # this is firing really often
-            if self.visibleHeight == size.height:
-                return
-            log.debug("size.height is new: %s", size.height)
-            self.visibleHeight = size.height
-            self.setRowHeights()
-
-        visibleArea = self.curvesVBox.get_parent().get_parent()
-        visibleArea.connect('size-allocate', sizeEvent)
-
-        dispatcher.connect(self.setRowHeights, "curve row focus change")
-
-    def setRowHeights(self):
-        nRows = len(self.allCurveRows)
-        if not nRows:
-            return
-        anyFocus = any(r.isFocus() for r in self.allCurveRows)
-
-        evenHeight = max(14, self.visibleHeight // nRows) - 3
-        if anyFocus:
-            focusHeight = max(100, evenHeight)
-            if nRows > 1:
-                otherHeight = max(14, (self.visibleHeight - focusHeight) //
-                                  (nRows - 1)) - 3
-        else:
-            otherHeight = evenHeight
-        for row in self.allCurveRows:
-            row.setHeight(focusHeight if row.isFocus() else otherHeight)
-
-    def row(self, name):
-        if isinstance(name, Literal):
-            name = str(name)
-        matches = [r for r in self.allCurveRows if r.name == name]
-        if not matches:
-            raise ValueError("no curveRow named %r. only %s" %
-                             (name, [r.name for r in self.allCurveRows]))
-        return matches[0]
-
-    def goLive(self):
-        """for startup performance, none of the curves redraw
-        themselves until this is called once (and then they're normal)"""
-
-        for cr in self.allCurveRows:
-            cr.curveView.goLive()
-
-    def onDelete(self):
-        for r in self.allCurveRows:
-            r.onDelete()
--- a/light9/curvecalc/musicaccess.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-import json
-from louie import dispatcher
-from rdflib import URIRef
-from light9 import networking
-from twisted.internet import reactor
-from twisted.web.client import Agent
-from twisted.internet.protocol import Protocol
-from twisted.internet.defer import Deferred
-from zope.interface import implements
-from twisted.internet.defer import succeed
-from twisted.web.iweb import IBodyProducer
-
-
-class GatherJson(Protocol):
-    """calls back the 'finished' deferred with the parsed json data we
-    received"""
-
-    def __init__(self, finished):
-        self.finished = finished
-        self.buf = ""
-
-    def dataReceived(self, bytes):
-        self.buf += bytes
-
-    def connectionLost(self, reason):
-        self.finished.callback(json.loads(self.buf))
-
-
-class StringProducer(object):
-    # http://twistedmatrix.com/documents/current/web/howto/client.html
-    implements(IBodyProducer)
-
-    def __init__(self, body):
-        self.body = body
-        self.length = len(body)
-
-    def startProducing(self, consumer):
-        consumer.write(self.body)
-        return succeed(None)
-
-    def pauseProducing(self):
-        pass
-
-    def stopProducing(self):
-        pass
-
-
-class Music:
-
-    def __init__(self):
-        self.recenttime = 0
-        self.player = Agent(reactor)
-        self.timePath = networking.musicPlayer.path("time")
-
-    def current_time(self):
-        """return deferred which gets called with the current
-        time. This gets called really often"""
-        d = self.player.request("GET", self.timePath)
-        d.addCallback(self._timeReturned)
-        return d
-
-    def _timeReturned(self, response):
-        done = Deferred()
-        done.addCallback(self._bodyReceived)
-        response.deliverBody(GatherJson(done))
-        return done
-
-    def _bodyReceived(self, data):
-        if 't' in data:
-            dispatcher.send("input time", val=data['t'])
-        if 'song' in data and data['song']:
-            dispatcher.send("current_player_song", song=URIRef(data['song']))
-        return data['t']  # pass along to the real receiver
-
-    def playOrPause(self, t=None):
-        if t is None:
-            # could be better
-            self.current_time().addCallback(lambda t: self.playOrPause(t))
-        else:
-            self.player.request("POST",
-                                networking.musicPlayer.path("seekPlayOrPause"),
-                                bodyProducer=StringProducer(json.dumps({"t":
-                                                                        t})))
--- a/light9/curvecalc/output.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,72 +0,0 @@
-import time, logging
-from twisted.internet import reactor
-from light9 import Submaster, dmxclient
-
-from louie import dispatcher
-log = logging.getLogger("output")
-
-
-class Output(object):
-    lastsendtime = 0
-    lastsendlevs = None
-
-    def __init__(self, graph, session, music, curveset, currentSubterms):
-        self.graph, self.session, self.music = graph, session, music
-        self.currentSubterms = currentSubterms
-        self.curveset = curveset
-
-        self.recent_t = []
-        self.later = None
-
-        self.update()
-
-    def update(self):
-        d = self.music.current_time()
-        d.addCallback(self.update2)
-        d.addErrback(self.updateerr)
-
-    def updateerr(self, e):
-
-        print(e.getTraceback())
-        dispatcher.send("update status", val=e.getErrorMessage())
-        if self.later and not self.later.cancelled and not self.later.called:
-            self.later.cancel()
-        self.later = reactor.callLater(1, self.update)
-
-    def update2(self, t):
-        # spot alsa soundcard offset is always 0, we get times about a
-        # second ahead of what's really getting played
-        #t = t - .7
-        dispatcher.send("update status",
-                        val="ok: receiving time from music player")
-        if self.later and not self.later.cancelled and not self.later.called:
-            self.later.cancel()
-
-        self.later = reactor.callLater(.02, self.update)
-
-        self.recent_t = self.recent_t[-50:] + [t]
-        period = (self.recent_t[-1] - self.recent_t[0]) / len(self.recent_t)
-        dispatcher.send("update period", val=period)
-        self.send_dmx(t)
-
-    def send_dmx(self, t):
-        dispatcher.send("curves to sliders", t=t)
-
-        if not self.currentSubterms:
-            return
-
-        scaledsubs = []
-        for st in self.currentSubterms:
-            scl = st.scaled(t)
-            scaledsubs.append(scl)
-
-        out = Submaster.sub_maxes(*scaledsubs)
-        levs = out.get_levels()
-        now = time.time()
-        if now - self.lastsendtime > 5 or levs != self.lastsendlevs:
-            dispatcher.send("output levels", val=levs)
-            dmxclient.outputlevels(out.get_dmx_list(),
-                                   twisted=1,
-                                   clientid='curvecalc')
-            self.lastsendtime = now
-            self.lastsendlevs = levs
--- a/light9/curvecalc/subterm.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,144 +0,0 @@
-import logging
-from rdflib import Literal
-from louie import dispatcher
-import light9.Effects
-from light9 import Submaster
-from light9.Patch import get_dmx_channel
-from rdfdb.patch import Patch
-from light9.namespaces import L9
-log = logging.getLogger()
-
-
-class Expr(object):
-    """singleton, provides functions for use in subterm expressions,
-    e.g. chases"""
-
-    def __init__(self):
-        self.effectGlobals = light9.Effects.configExprGlobals()
-
-    def exprGlobals(self, startDict, t):
-        """globals dict for use by expressions"""
-
-        glo = startDict.copy()
-
-        # add in functions from Effects
-        glo.update(self.effectGlobals)
-
-        def chan(name):
-            return Submaster.Submaster(name=name,
-                                       levels={get_dmx_channel(name): 1.0})
-
-        glo['chan'] = chan
-        glo['within'] = lambda a, b: a < t < b
-        glo['bef'] = lambda x: t < x
-
-        def aft(t, x, smooth=0):
-            left = x - smooth / 2
-            right = x + smooth / 2
-            if left < t < right:
-                return light9.Effects.smoove((t - left) / (right - left))
-            return t > x
-
-        glo['aft'] = lambda x, smooth=0: aft(t, x, smooth)
-
-        glo['smooth_random'] = lambda speed=1: glo['smooth_random2'](t, speed)
-        glo['notch_random'] = lambda speed=1: glo['notch_random2'](t, speed)
-
-        glo['noise'] = glo['smooth_random']
-        glo['notch'] = glo['notch_random']
-
-        return glo
-
-
-exprglo = Expr()
-
-
-class Subterm(object):
-    """one Submaster and its expression evaluator"""
-
-    def __init__(self, graph, subterm, saveContext, curveset):
-        self.graph, self.uri = graph, subterm
-        self.saveContext = saveContext
-        self.curveset = curveset
-        self.ensureExpression(saveContext)
-
-        self.submasters = Submaster.get_global_submasters(self.graph)
-
-    def ensureExpression(self, saveCtx):
-        with self.graph.currentState(tripleFilter=(self.uri, None,
-                                                   None)) as current:
-            if current.value(self.uri, L9['expression']) is None:
-                self.graph.patch(
-                    Patch(addQuads=[
-                        (self.uri, L9['expression'], Literal("..."), saveCtx),
-                    ]))
-
-    def scaled(self, t):
-        with self.graph.currentState(tripleFilter=(self.uri, None,
-                                                   None)) as current:
-            subexpr_eval = self.eval(current, t)
-            # we prevent any exceptions from escaping, since they cause us to
-            # stop sending levels
-            try:
-                if isinstance(subexpr_eval, Submaster.Submaster):
-                    # if the expression returns a submaster, just return it
-                    return subexpr_eval
-                else:
-                    # otherwise, return our submaster multiplied by the value
-                    # returned
-                    if subexpr_eval == 0:
-                        return Submaster.Submaster("zero", {})
-                    subUri = current.value(self.uri, L9['sub'])
-                    sub = self.submasters.get_sub_by_uri(subUri)
-                    return sub * subexpr_eval
-            except Exception as e:
-                dispatcher.send("expr_error", sender=self.uri, exc=repr(e))
-                return Submaster.Submaster(name='Error: %s' % str(e), levels={})
-
-    def curves_used_by_expr(self):
-        """names of curves that are (maybe) used in this expression"""
-
-        with self.graph.currentState(tripleFilter=(self.uri, None,
-                                                   None)) as current:
-            expr = current.value(self.uri, L9['expression'])
-
-        used = []
-        for name in self.curveset.curveNamesInOrder():
-            if name in expr:
-                used.append(name)
-        return used
-
-    def eval(self, current, t):
-        """current graph is being passed as an optimization. It should be
-        equivalent to use self.graph in here."""
-
-        objs = list(current.objects(self.uri, L9['expression']))
-        if len(objs) > 1:
-            raise ValueError("found multiple expressions for %s: %s" %
-                             (self.uri, objs))
-
-        expr = current.value(self.uri, L9['expression'])
-        if not expr:
-            dispatcher.send("expr_error",
-                            sender=self.uri,
-                            exc="no expr, using 0")
-            return 0
-        glo = self.curveset.globalsdict()
-        glo['t'] = t
-
-        glo = exprglo.exprGlobals(glo, t)
-        glo['getsub'] = lambda name: self.submasters.get_sub_by_name(name)
-        glo['chan'] = lambda name: Submaster.Submaster(
-            "chan", {get_dmx_channel(name): 1})
-
-        try:
-            self.lasteval = eval(expr, glo)
-        except Exception as e:
-            dispatcher.send("expr_error", sender=self.uri, exc=e)
-            return Submaster.Submaster("zero", {})
-        else:
-            dispatcher.send("expr_error", sender=self.uri, exc="ok")
-        return self.lasteval
-
-    def __repr__(self):
-        return "<Subterm %s>" % self.uri
--- a/light9/curvecalc/subtermview.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,134 +0,0 @@
-import logging
-from gi.repository import Gtk
-from louie import dispatcher
-from rdflib import Literal, URIRef
-from light9.namespaces import L9
-log = logging.getLogger()
-
-# inspired by http://www.daa.com.au/pipermail/pygtk/2008-August/015772.html
-# keeping a ref to the __dict__ of the object stops it from getting zeroed
-keep = []
-
-
-class Subexprview(object):
-
-    def __init__(self, graph, ownerSubterm, saveContext, curveset):
-        self.graph, self.ownerSubterm = graph, ownerSubterm
-        self.saveContext = saveContext
-        self.curveset = curveset
-
-        self.box = Gtk.HBox()
-
-        self.entryBuffer = Gtk.EntryBuffer("", -1)
-        self.entry = Gtk.Entry()
-        self.error = Gtk.Label("")
-
-        self.box.pack_start(self.entry, expand=True)
-        self.box.pack_start(self.error, expand=False)
-
-        self.entry.set_buffer(self.entryBuffer)
-        self.graph.addHandler(self.set_expression_from_graph)
-        self.entryBuffer.connect("deleted-text", self.entry_changed)
-        self.entryBuffer.connect("inserted-text", self.entry_changed)
-
-        self.entry.connect("focus-in-event", self.onFocus)
-
-        dispatcher.connect(self.exprError,
-                           "expr_error",
-                           sender=self.ownerSubterm)
-        keep.append(self.__dict__)
-
-    def onFocus(self, *args):
-        curveNames = self.curveset.curveNamesInOrder()
-        currentExpr = self.entryBuffer.get_text()
-
-        usedCurves = sorted([n for n in curveNames if n in currentExpr])
-
-        dispatcher.send("set_featured_curves", curveNames=usedCurves)
-
-    def exprError(self, exc):
-        self.error.set_text(str(exc))
-
-    def set_expression_from_graph(self):
-        e = str(self.graph.value(self.ownerSubterm, L9['expression']))
-        print("from graph, set to %r" % e)
-
-        if e != self.entryBuffer.get_text():
-            self.entryBuffer.set_text(e, len(e))
-
-    def entry_changed(self, *args):
-        log.info("want to patch to %r", self.entryBuffer.get_text())
-        self.graph.patchObject(self.saveContext, self.ownerSubterm,
-                               L9['expression'],
-                               Literal(self.entryBuffer.get_text()))
-
-
-class Subtermview(object):
-    """
-    has .label and .exprView widgets for you to put in a table
-    """
-
-    def __init__(self, st, curveset):
-        self.subterm = st
-        self.graph = st.graph
-
-        self.label = Gtk.Label("sub")
-        self.graph.addHandler(self.setName)
-
-        self.label.drag_dest_set(flags=Gtk.DEST_DEFAULT_ALL,
-                                 targets=[('text/uri-list', 0, 0)],
-                                 actions=Gtk.gdk.ACTION_COPY)
-        self.label.connect("drag-data-received", self.onDataReceivedOnLabel)
-
-        sev = Subexprview(self.graph, self.subterm.uri,
-                          self.subterm.saveContext, curveset)
-        self.exprView = sev.box
-
-    def onDataReceivedOnLabel(self, widget, context, x, y, selection,
-                              targetType, time):
-        self.graph.patchObject(self.subterm.saveContext, self.subterm.uri,
-                               L9['sub'], URIRef(selection.data.strip()))
-
-    def setName(self):
-        # some of this could be pushed into Submaster
-        sub = self.graph.value(self.subterm.uri, L9['sub'])
-        if sub is None:
-            tail = self.subterm.uri.rsplit('/', 1)[-1]
-            self.label.set_text("no sub (%s)" % tail)
-            return
-        label = self.graph.label(sub)
-        if label is None:
-            self.label.set_text("sub %s has no label" % sub)
-            return
-        self.label.set_text(label)
-
-
-def add_one_subterm(subterm, curveset, master, show=False):
-    stv = Subtermview(subterm, curveset)
-
-    y = master.get_property('n-rows')
-    master.attach(stv.label, 0, 1, y, y + 1, xoptions=0, yoptions=0)
-    master.attach(stv.exprView, 1, 2, y, y + 1, yoptions=0)
-    scrollToRowUponAdd(stv.label)
-    if show:
-        master.show_all()
-
-
-def scrollToRowUponAdd(widgetInRow):
-    """when this table widget is ready, scroll the table so we can see it"""
-
-    # this doesn't work right, yet
-    return
-
-    vp = widgetInRow
-    while vp.get_name() != 'GtkViewport':
-        log.info("walk %s", vp.get_name())
-        vp = vp.get_parent()
-    adj = vp.props.vadjustment
-
-    def firstExpose(widget, event, adj, widgetInRow):
-        log.info("scroll %s", adj.props.value)
-        adj.props.value = adj.props.upper
-        widgetInRow.disconnect(handler)
-
-    handler = widgetInRow.connect('expose-event', firstExpose, adj, widgetInRow)
--- a/light9/curvecalc/zoomcontrol.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,380 +0,0 @@
-from gi.repository import GooCanvas
-import louie as dispatcher
-from light9.curvecalc import cursors
-from lib.goocanvas_compat import Points, polyline_new_line
-from twisted.internet import reactor
-
-
-class ZoomControl(object):
-    """
-    please pack .widget
-    """
-
-    mintime = 0
-
-    def maxtime():
-        doc = "seconds at the right edge of the bar"
-
-        def fget(self):
-            return self._maxtime
-
-        def fset(self, value):
-            self._maxtime = value
-            self.redrawzoom()
-
-        return locals()
-
-    maxtime = property(**maxtime())
-
-    _end = _start = 0
-
-    def start():
-
-        def fget(self):
-            return self._start
-
-        def fset(self, v):
-            v = max(self.mintime, v)
-            # don't protect for start<end since zooming sometimes sets
-            # start temporarily after end
-            self._start = v
-
-        return locals()
-
-    start = property(**start())
-
-    def end():
-
-        def fget(self):
-            return self._end
-
-        def fset(self, v):
-            v = min(self.maxtime, v)
-            self._end = v
-
-        return locals()
-
-    end = property(**end())
-
-    def offset():
-        doc = "virtual attr that adjusts start and end together"
-
-        def fget(self):
-            # work off the midpoint so that "crushing works equally
-            # well in both directions
-            return (self.start + self.end) / 2
-
-        def fset(self, value):
-            d = self.end - self.start
-            self.start = value - d / 2
-            self.end = value + d / 2
-
-        return locals()
-
-    offset = property(**offset())
-
-    def __init__(self, **kw):
-        self.widget = GooCanvas.Canvas(bounds_padding=5)
-        self.widget.set_property("background-color", "gray60")
-        self.widget.set_size_request(-1, 30)
-        self.widget.props.x2 = 2000
-
-        endtimes = dispatcher.send("get max time")
-        if endtimes:
-            self.maxtime = endtimes[0][1]
-        else:
-            self.maxtime = 0
-
-        self.start = 0
-        self.end = 250
-
-        self.root = self.widget.get_root_item()
-        self.leftbrack = polyline_new_line(parent=self.root,
-                                           line_width=5,
-                                           stroke_color='black')
-        self.rightbrack = polyline_new_line(parent=self.root,
-                                            line_width=5,
-                                            stroke_color='black')
-        self.shade = GooCanvas.CanvasRect(parent=self.root,
-                                          fill_color='gray70',
-                                          line_width=.5)
-        self.time = polyline_new_line(parent=self.root,
-                                      line_width=2,
-                                      stroke_color='red')
-
-        self.redrawzoom()
-        self.widget.connect("size-allocate", self.redrawzoom)
-
-        self.widget.connect("motion-notify-event", self.adjust)
-        self.widget.connect("button-release-event", self.release)
-        self.leftbrack.connect(
-            "button-press-event", lambda i, t, ev: self.press(ev, 'start'))
-        self.rightbrack.connect(
-            "button-press-event", lambda i, t, ev: self.press(ev, 'end'))
-        self.shade.connect(
-            "button-press-event", lambda i, t, ev: self.press(ev, 'offset'))
-
-        dispatcher.connect(self.input_time, "input time")
-        dispatcher.connect(self.max_time, "max time")
-        dispatcher.connect(self.zoom_about_mouse, "zoom about mouse")
-        dispatcher.connect(self.see_time, "see time")
-        dispatcher.connect(self.see_time_until_end, "see time until end")
-        dispatcher.connect(self.show_all, "show all")
-        dispatcher.connect(self.zoom_to_range, "zoom to range")
-        self.created = 1
-        self.lastTime = 0
-
-    def max_time(self, maxtime):
-        self.maxtime = maxtime
-        self.redrawzoom()
-
-    def zoom_to_range(self, start, end):
-        self.start = start
-        self.end = end
-        self.redrawzoom()
-
-    def show_all(self):
-        self.start = self.mintime
-        self.end = self.maxtime
-        self.redrawzoom()
-
-    def zoom_about_mouse(self, t, factor):
-        self.start = t - factor * (t - self.start)
-        self.end = t + factor * (self.end - t)
-        self.redrawzoom()
-
-    def see_time(self, t=None):
-        """defaults to current time"""
-        if t is None:
-            t = self.lastTime
-        vis_seconds = self.end - self.start
-        # note that setting self.offset positions the time in the
-        # *middle*.
-        margin = vis_seconds * -.4
-        if t < self.start or t > (self.end - vis_seconds * .6):
-            self.offset = t - margin
-
-        self.redrawzoom()
-
-    def see_time_until_end(self, t=None):
-        """defaults to current time"""
-        if t is None:
-            t = self.lastTime
-        self.start = t - 2
-        self.end = self.maxtime
-
-        self.redrawzoom()
-
-    def input_time(self, val):
-        """move time cursor to this time"""
-        self.lastTime = val
-        try:
-            x = self.can_for_t(self.lastTime)
-        except ZeroDivisionError:
-            x = -100
-        self.time.set_property("points", Points([(x, 0),
-                                                 (x, self.size.height)]))
-
-    def press(self, ev, attr):
-        self.adjustingattr = attr
-
-    def release(self, widget, ev):
-        if hasattr(self, 'adjustingattr'):
-            del self.adjustingattr
-        if hasattr(self, 'lastx'):
-            del self.lastx
-
-    def adjust(self, widget, ev):
-
-        if not hasattr(self, 'adjustingattr'):
-            return
-        attr = self.adjustingattr
-
-        if not hasattr(self, 'lastx'):
-            self.lastx = ev.x
-        new = self.can_for_t(getattr(self, attr)) + (ev.x - self.lastx)
-        self.lastx = ev.x
-        setattr(self, attr, self.t_for_can(new))
-        self.redrawzoom()
-
-    def can_for_t(self, t):
-        a, b = self.mintime, self.maxtime
-        return (t - a) / (b - a) * (self.size.width - 30) + 20
-
-    def t_for_can(self, x):
-        a, b = self.mintime, self.maxtime
-        return (x - 20) / (self.size.width - 30) * (b - a) + a
-
-    def redrawzoom(self, *args):
-        # often, this was clearing the zoom widget and not repainting right
-        reactor.callLater(0, self._redrawzoom)
-
-    def _redrawzoom(self):
-        """redraw pieces based on start/end"""
-        self.size = self.widget.get_allocation()
-        dispatcher.send("zoom changed")
-        if not hasattr(self, 'created'):
-            return
-        y1, y2 = 3, self.size.height - 3
-        lip = 6
-        try:
-            scan = self.can_for_t(self.start)
-            ecan = self.can_for_t(self.end)
-        except ZeroDivisionError:
-            # todo: set the zoom to some clear null state
-            return
-
-        self.leftbrack.set_property(
-            "points",
-            Points([(scan + lip, y1), (scan, y1), (scan, y2),
-                    (scan + lip, y2)]))
-        self.rightbrack.set_property(
-            "points",
-            Points([(ecan - lip, y1), (ecan, y1), (ecan, y2),
-                    (ecan - lip, y2)]))
-        self.shade.set_properties(x=scan + 5,
-                                  y=y1 + lip,
-                                  width=max(0, ecan - 5 - (scan + 5)),
-                                  height=max(0, y2 - lip - (y1 + lip)))
-
-        self.redrawTics()
-
-    def redrawTics(self):
-        if hasattr(self, 'ticsGroup'):
-            self.ticsGroup.remove()
-        self.ticsGroup = GooCanvas.CanvasGroup(parent=self.root)
-
-        lastx = -1000
-
-        for t in range(0, int(self.maxtime)):
-            x = self.can_for_t(t)
-            if 0 < x < self.size.width and x - lastx > 30:
-                txt = str(t)
-                if lastx == -1000:
-                    txt = txt + "sec"
-                GooCanvas.CanvasPolyline(parent=self.ticsGroup,
-                                         points=Points([(x, 0), (x, 15)]),
-                                         line_width=.8,
-                                         stroke_color='black')
-                GooCanvas.CanvasText(parent=self.ticsGroup,
-                                     x=x,
-                                     y=self.size.height - 1,
-                                     anchor=GooCanvas.CanvasAnchorType.SOUTH,
-                                     text=txt,
-                                     font='ubuntu 7')
-                lastx = x
-
-
-class RegionZoom:
-    """rigs c-a-b1 to drag out an area to zoom to. also catches other types of drag events, like b1 drag for selecting points
-
-    this is used with Curveview
-    """
-
-    def __init__(self, canvas, world_from_screen, screen_from_world):
-        self.canvas, self.world_from_screen = canvas, world_from_screen
-        self.screen_from_world = screen_from_world
-
-        for evtype, method in [("ButtonPress-1", self.press),
-                               ("Motion", self.motion),
-                               ("ButtonRelease-1", self.release)]:
-            #canvas.bind("<Control-Alt-%s>" % evtype, method, add=True)
-            if 1 or evtype != "ButtonPress-1":
-                canvas.bind("<%s>" % evtype, method, add=True)
-
-        canvas.bind("<Leave>", self.finish)
-        self.start_t = self.old_cursor = None
-        self.state = self.mods = None
-
-    def press(self, ev):
-        if self.state is not None:
-            self.finish()
-
-        if ev.state == 12:
-            self.mods = "c-a"
-        elif ev.state == 13:
-            # todo: right now this never happens because only the
-            # sketching handler gets the event
-            self.mods = "c-s-a"
-        elif ev.state == 0:
-            return  # no
-            self.mods = "none"
-        else:
-            return
-        self.state = "buttonpress"
-
-        self.start_t = self.end_t = self.world_from_screen(ev.x, 0)[0]
-        self.start_x = ev.x
-        can = self.canvas
-
-        for pos in ('start_t', 'end_t', 'hi', 'lo'):
-            can.create_line(0,
-                            0,
-                            50,
-                            50,
-                            width=3,
-                            fill='yellow',
-                            tags=("regionzoom", pos))
-        # if updatelines isn't called here, subsequent updatelines
-        # will fail for reasons i don't understand
-        self.updatelines()
-
-        # todo: just holding the modifiers ought to turn on the zoom
-        # cursor (which is not finished)
-        cursors.push(can, "@light9/cursor1.xbm")
-
-    def updatelines(self):
-
-        # better would be a gray25 rectangle over the region
-
-        can = self.canvas
-        pos_x = {}
-        height = can.winfo_height()
-        for pos in ('start_t', 'end_t'):
-            pos_x[pos] = x = self.screen_from_world((getattr(self, pos), 0))[0]
-            cid = can.find_withtag("regionzoom && %s" % pos)
-            can.coords(cid, x, 0, x, height)
-
-        for tag, frac in [('hi', .1), ('lo', .9)]:
-            cid = can.find_withtag("regionzoom && %s" % tag)
-            can.coords(cid, pos_x['start_t'], frac * height, pos_x['end_t'],
-                       frac * height)
-
-    def motion(self, ev):
-        if self.state != "buttonpress":
-            return
-
-        self.end_t = self.world_from_screen(ev.x, 0)[0]
-        self.updatelines()
-
-    def release(self, ev):
-        if self.state != "buttonpress":
-            return
-
-        if abs(self.start_x - ev.x) < 10:
-            # clicked
-            if self.mods in ("c-a", "c-s-a"):
-                factor = 1 / 1.5
-                if self.mods == "c-s-a":
-                    factor = 1.5  # c-s-a-b1 zooms out
-                dispatcher.send("zoom about mouse",
-                                t=self.start_t,
-                                factor=factor)
-
-            self.finish()
-            return
-
-        start, end = min(self.start_t, self.end_t), max(self.start_t,
-                                                        self.end_t)
-        if self.mods == "c-a":
-            dispatcher.send("zoom to range", start=start, end=end)
-        elif self.mods == "none":
-            dispatcher.send("select between", start=start, end=end)
-        self.finish()
-
-    def finish(self, *ev):
-        if self.state is not None:
-            self.state = None
-            self.canvas.delete("regionzoom")
-            self.start_t = None
-            cursors.pop(self.canvas)
--- a/light9/dmxchanedit.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,243 +0,0 @@
-"""
-
-widget to show all dmx channel levels and allow editing. levels might
-not actually match what dmxserver is outputting.
-
-proposal for new focus and edit system:
-- rows can be selected
-- the chan number or label can be used to select rows. dragging over rows brings all of them into or out of the current selection
-- numbers drag up and down (like today)
-- if you drag a number in a selected row, all the selected numbers change
-- if you start dragging a number in an unselected row, your row becomes the new selection and then the edit works
-
-
-proposal for new attribute system:
-- we always want to plan some attributes for each light: where to center; what stage to cover; what color gel to apply; whether the light is burned out
-- we have to stop packing these into the names. Names should be like 'b33' or 'blue3' or just '44'. maybe 'blacklight'.
-
-"""
-
-import tkinter as tk
-from rdflib import RDF
-import math, logging
-from decimal import Decimal
-from light9.namespaces import L9
-log = logging.getLogger('dmxchanedit')
-stdfont = ('Arial', 7)
-
-
-def gradient(lev, low=(80, 80, 180), high=(255, 55, 50)):
-    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
-    col = "#%02X%02X%02X" % tuple(out)
-    return col
-
-
-class Onelevel(tk.Frame):
-    """a name/level pair
-
-    source data is like this:
-        ch:b11-c     a :Channel;
-         :output dmx:c54;
-         rdfs:label "b11-c" .
-
-    and the level is like this:
-
-       ?editor :currentSub ?sub .
-       ?sub :lightLevel [:channel ?ch; :level ?level] .
-
-    levels come in with self.setTo and go out by the onLevelChange
-    callback. This object does not use the graph for level values,
-    which I'm doing for what I think is efficiency. Unclear why I
-    didn't use Observable for that API.
-    """
-
-    def __init__(self, parent, graph, channelUri, onLevelChange):
-        tk.Frame.__init__(self, parent, height=20)
-        self.graph = graph
-        self.onLevelChange = onLevelChange
-        self.uri = channelUri
-        self.currentLevel = 0  # the level we're displaying, 0..1
-
-        # no statement yet
-        self.channelnum = int(
-            self.graph.value(self.uri, L9['output']).rsplit('/c')[-1])
-
-        # 3 widgets, left-to-right:
-
-        # channel number -- will turn yellow when being altered
-        self.num_lab = tk.Label(self,
-                                text=str(self.channelnum),
-                                width=3,
-                                bg='grey40',
-                                fg='white',
-                                font=stdfont,
-                                padx=0,
-                                pady=0,
-                                bd=0,
-                                height=1)
-        self.num_lab.pack(side='left')
-
-        # text description of channel
-        self.desc_lab = tk.Label(self,
-                                 width=14,
-                                 font=stdfont,
-                                 anchor='w',
-                                 padx=0,
-                                 pady=0,
-                                 bd=0,
-                                 height=1,
-                                 bg='black',
-                                 fg='white')
-        self.graph.addHandler(self.updateLabel)
-        self.desc_lab.pack(side='left')
-
-        # current level of channel, shows intensity with color
-        self.level_lab = tk.Label(self,
-                                  width=3,
-                                  bg='lightBlue',
-                                  anchor='e',
-                                  font=stdfont,
-                                  padx=1,
-                                  pady=0,
-                                  bd=0,
-                                  height=1)
-        self.level_lab.pack(side='left')
-
-        self.setupmousebindings()
-
-    def updateLabel(self):
-        self.desc_lab.config(text=self.graph.label(self.uri))
-
-    def setupmousebindings(self):
-
-        def b1down(ev):
-            self.desc_lab.config(bg='cyan')
-            self._start_y = ev.y
-            self._start_lev = self.currentLevel
-
-        def b1motion(ev):
-            delta = self._start_y - ev.y
-            self.setlevel(max(0, min(1, self._start_lev + delta * .005)))
-
-        def b1up(ev):
-            self.desc_lab.config(bg='black')
-
-        def b3up(ev):
-            self.setlevel(0.0)
-
-        def b3down(ev):
-            self.setlevel(1.0)
-
-        def b2down(ev):  # same thing for now
-            self.setlevel(1.0)
-
-        # make the buttons work in the child windows
-        for w in self.winfo_children():
-            for e, func in (('<ButtonPress-1>',
-                             b1down), ('<B1-Motion>',
-                                       b1motion), ('<ButtonRelease-1>', b1up),
-                            ('<ButtonPress-2>',
-                             b2down), ('<ButtonRelease-3>',
-                                       b3up), ('<ButtonPress-3>', b3down)):
-
-                w.bind(e, func)
-
-    def colorlabel(self):
-        """color the level label based on its own text (which is 0..100)"""
-        txt = self.level_lab['text'] or "0"
-        lev = float(txt) / 100
-        self.level_lab.config(bg=gradient(lev))
-
-    def setlevel(self, newlev):
-        """UI received a level change, which we put in the graph"""
-        self.onLevelChange(self.uri, newlev)
-
-    def setTo(self, newLevel):
-        """levelbox saw a change in the graph"""
-        self.currentLevel = min(1, max(0, newLevel))
-        newLevel = "%d" % (self.currentLevel * 100)
-        olddisplay = self.level_lab.cget('text')
-        if newLevel != olddisplay:
-            self.level_lab.config(text=newLevel)
-            self.colorlabel()
-
-
-class Levelbox(tk.Frame):
-    """
-    this also watches all the levels in the sub and sets the boxes when they change
-    """
-
-    def __init__(self, parent, graph, currentSub):
-        """
-        currentSub is an Observable(PersistentSubmaster)
-        """
-        tk.Frame.__init__(self, parent)
-
-        self.currentSub = currentSub
-        self.graph = graph
-        graph.addHandler(self.updateChannels)
-
-        self.currentSub.subscribe(lambda _: graph.addHandler(self.
-                                                             updateLevelValues))
-
-    def updateChannels(self):
-        """(re)make Onelevel boxes for the defined channels"""
-
-        [ch.destroy() for ch in self.winfo_children()]
-        self.levelFromUri = {}  # channel : OneLevel
-
-        chans = list(self.graph.subjects(RDF.type, L9.Channel))
-        chans.sort(
-            key=lambda c: int(self.graph.value(c, L9.output).rsplit('/c')[-1]))
-        cols = 2
-        rows = int(math.ceil(len(chans) / cols))
-
-        def make_frame(parent):
-            f = tk.Frame(parent, bd=0, bg='black')
-            f.pack(side='left')
-            return f
-
-        columnFrames = [make_frame(self) for x in range(cols)]
-
-        for i, channel in enumerate(chans):  # sort?
-            # frame for this channel
-            f = Onelevel(columnFrames[i // rows], self.graph, channel,
-                         self.onLevelChange)
-
-            self.levelFromUri[channel] = f
-            f.pack(side='top')
-
-    def updateLevelValues(self):
-        """set UI level from graph"""
-        submaster = self.currentSub()
-        if submaster is None:
-            return
-        sub = submaster.uri
-        if sub is None:
-            raise ValueError("currentSub is %r" % submaster)
-
-        remaining = set(self.levelFromUri.keys())
-        for ll in self.graph.objects(sub, L9['lightLevel']):
-            chan = self.graph.value(ll, L9['channel'])
-            try:
-                lev = self.graph.value(ll, L9['level']).toPython()
-            except AttributeError as e:
-                log.error('on lightlevel %r:', ll)
-                log.exception(e)
-                continue
-            if isinstance(lev, Decimal):
-                lev = float(lev)
-            assert isinstance(lev, (int, float)), repr(lev)
-            try:
-                self.levelFromUri[chan].setTo(lev)
-                remaining.remove(chan)
-            except KeyError as e:
-                log.exception(e)
-        for channel in remaining:
-            self.levelFromUri[channel].setTo(0)
-
-    def onLevelChange(self, chan, newLevel):
-        """UI received a change which we put in the graph"""
-        if self.currentSub() is None:
-            raise ValueError("no currentSub in Levelbox")
-        self.currentSub().editLevel(chan, newLevel)
--- a/light9/dmxclient.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,76 +0,0 @@
-""" module for clients to use for easy talking to the dmx
-server. sending levels is now a simple call to
-dmxclient.outputlevels(..)
-
-client id is formed from sys.argv[0] and the PID.  """
-
-import xmlrpc.client, os, sys, socket, time, logging
-from twisted.internet import defer
-from txzmq import ZmqEndpoint, ZmqFactory, ZmqPushConnection
-import json
-
-from light9 import networking
-_dmx = None
-log = logging.getLogger('dmxclient')
-
-procname = os.path.basename(sys.argv[0])
-procname = procname.replace('.py', '')
-_id = "%s-%s-%s" % (procname, socket.gethostname(), os.getpid())
-
-
-class TwistedZmqClient(object):
-
-    def __init__(self, service):
-        zf = ZmqFactory()
-        e = ZmqEndpoint('connect', 'tcp://%s:%s' % (service.host, service.port))
-
-        class Push(ZmqPushConnection):
-            pass  # highWaterMark = 3000
-
-        self.conn = Push(zf, e)
-
-    def send(self, clientid, levellist):
-        self.conn.push(
-            json.dumps({
-                'clientid': clientid,
-                'levellist': levellist
-            }))
-
-
-def outputlevels(levellist, twisted=0, clientid=_id):
-    """present a list of dmx channel levels, each scaled from
-    0..1. list can be any length- it will apply to the first len() dmx
-    channels.
-
-    if the server is not found, outputlevels will block for a
-    second."""
-
-    global _dmx, _id
-
-    if _dmx is None:
-        url = networking.dmxServer.url
-        if not twisted:
-            _dmx = xmlrpc.client.Server(url)
-        else:
-            _dmx = TwistedZmqClient(networking.dmxServerZmq)
-
-    if not twisted:
-        try:
-            _dmx.outputlevels(clientid, levellist)
-        except socket.error as e:
-            log.error("dmx server error %s, waiting" % e)
-            time.sleep(1)
-        except xmlrpc.client.Fault as e:
-            log.error("outputlevels had xml fault: %s" % e)
-            time.sleep(1)
-    else:
-        _dmx.send(clientid, levellist)
-        return defer.succeed(None)
-
-
-dummy = os.getenv('DMXDUMMY')
-if dummy:
-    print("dmxclient: DMX is in dummy mode.")
-
-    def outputlevels(*args, **kw):  # noqa
-        pass
--- a/light9/editchoice.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,118 +0,0 @@
-import tkinter as tk
-from rdflib import URIRef
-from light9.tkdnd import dragSourceRegister, dropTargetRegister
-
-
-class Local(object):
-    """placeholder for the local uri that EditChoice does not
-    manage. Set resourceObservable to Local to indicate that you're
-    unlinked"""
-
-
-class EditChoice(object):
-    """
-    Observable <-> linker UI
-
-    widget for tying some UI to a shared resource for editing, or
-    unlinking it (which means associating it with a local resource
-    that's not named or shared). This object does not own the choice
-    of resource; the caller does.
-
-    UI actions:
-    - drag a uri on here to make it the one we're editing
-
-    - button to clear the currentSub (putting it back to
-      sessionLocalSub, and also resetting sessionLocalSub to be empty
-      again)
-
-    - drag the sub uri off of here to send it to another receiver,
-      but, if we're in local mode, the local sub should not be so
-      easily addressable. Maybe you just can't drag it off.
-
-
-    Todo:
-
-    - filter by type so you can't drag a curve onto a subcomposer
-
-    - 'save new' : make a new sub: transfers the current data (from a shared sub or
-      from the local one) to the new sub. If you're on a local sub,
-      the new sub is named automatically, ideally something brief,
-      pretty distinct, readable, and based on the lights that are
-      on. If you're on a named sub, the new one starts with a
-      'namedsub 2' style name. The uri can also be with a '2' suffix,
-      although maybe that will be stupid. If you change the name
-      before anyone knows about this uri, we could update the current
-      sub's uri to a slug of the new label.
-
-    - rename this sub: not available if you're on a local sub. Sets
-      the label of a named sub. Might update the uri of the named sub
-      if it's new enough that no one else would have that uri. Not
-      sure where we measure that 'new enough' value. Maybe track if
-      the sub has 'never been dragged out of this subcomposer
-      session'? But subs will also show up in other viewers and
-      finders.
-
-    - list of recent resources that this choice was set to
-    """
-
-    def __init__(self, parent, graph, resourceObservable, label="Editing:"):
-        """
-        getResource is called to get the URI of the currently
-        """
-        self.graph = graph
-        self.frame = tk.Frame(parent, relief='raised', border=2)
-        self.frame.pack(side='top')
-        tk.Label(self.frame, text=label).pack(side='left')
-        self.currentLinkFrame = tk.Frame(self.frame)
-        self.currentLinkFrame.pack(side='left')
-
-        self.subIcon = tk.Label(self.currentLinkFrame,
-                                text="...",
-                                borderwidth=2,
-                                relief='raised',
-                                padx=10,
-                                pady=10)
-        self.subIcon.pack()
-
-        self.resourceObservable = resourceObservable
-        resourceObservable.subscribe(self.uriChanged)
-
-        # when the value is local, this should stop being a drag source
-        dragSourceRegister(self.subIcon, 'copy', 'text/uri-list',
-                           self.resourceObservable)
-
-        def onEv(ev):
-            self.resourceObservable(URIRef(ev.data))
-            return "link"
-
-        self.onEv = onEv
-
-        b = tk.Button(self.frame, text="Unlink", command=self.switchToLocalSub)
-        b.pack(side='left')
-
-        # it would be nice if I didn't receive my own drags here, and
-        # if the hover display wasn't per widget
-        for target in ([self.frame, self.currentLinkFrame] +
-                       self.frame.winfo_children() +
-                       self.currentLinkFrame.winfo_children()):
-            dropTargetRegister(target,
-                               typeList=["*"],
-                               onDrop=onEv,
-                               hoverStyle=dict(background="#555500"))
-
-    def uriChanged(self, newUri):
-        # if this resource had a type icon or a thumbnail, those would be
-        # cool to show in here too
-        if newUri is Local:
-            self.subIcon.config(text="(local)")
-        else:
-            self.graph.addHandler(self.updateLabel)
-
-    def updateLabel(self):
-        uri = self.resourceObservable()
-        print("get label", repr(uri))
-        label = self.graph.label(uri)
-        self.subIcon.config(text=label or uri)
-
-    def switchToLocalSub(self):
-        self.resourceObservable(Local)
--- a/light9/editchoicegtk.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,99 +0,0 @@
-import logging
-from gi.repository import Gtk
-from gi.repository import Gdk
-from rdflib import URIRef
-log = logging.getLogger('editchoicegtk')
-
-
-class Local(object):
-    """placeholder for the local uri that EditChoice does not
-    manage. Set resourceObservable to Local to indicate that you're
-    unlinked"""
-
-
-class EditChoice(Gtk.HBox):
-    """
-    this is a gtk port of editchoice.EditChoice
-    """
-
-    def __init__(self, graph, resourceObservable, label="Editing:"):
-        """
-        getResource is called to get the URI of the currently
-        """
-        self.graph = graph
-
-        # the outer box should have a distinctive border so it's more
-        # obviously a special drop target
-        Gtk.HBox.__init__(self)
-        self.pack_start(Gtk.Label(label), False, True, 0)  #expand, fill, pad
-
-        # this is just a label, but it should look like a physical
-        # 'thing' (and gtk labels don't work as drag sources)
-        self.currentLink = Gtk.Button("http://bar")
-
-        self.pack_start(self.currentLink, True, True, 0)  #expand, fill, pad
-
-        self.unlinkButton = Gtk.Button(label="Unlink")
-        self.pack_start(self.unlinkButton, False, True, 0)  #expand, fill pad
-
-        self.unlinkButton.connect("clicked", self.onUnlink)
-
-        self.show_all()
-
-        self.resourceObservable = resourceObservable
-        resourceObservable.subscribe(self.uriChanged)
-
-        self.makeDragSource()
-        self.makeDropTarget()
-
-    def makeDropTarget(self):
-
-        def ddr(widget, drag_context, x, y, selection_data, info, timestamp):
-            dtype = selection_data.get_data_type()
-            if dtype.name() not in ['text/uri-list', 'TEXT']:
-                raise ValueError("unknown DnD selection type %r" % dtype)
-            data = selection_data.get_data().strip()
-            log.debug('drag_data_received data=%r', data)
-            self.resourceObservable(URIRef(data))
-
-        self.currentLink.drag_dest_set(
-            flags=Gtk.DestDefaults.ALL,
-            targets=[
-                Gtk.TargetEntry.new('text/uri-list', 0, 0),
-                Gtk.TargetEntry.new('TEXT', 0,
-                                    0),  # getting this from chrome :(
-            ],
-            actions=Gdk.DragAction.LINK | Gdk.DragAction.COPY)
-        self.currentLink.connect("drag_data_received", ddr)
-
-    def makeDragSource(self):
-        self.currentLink.drag_source_set(
-            start_button_mask=Gdk.ModifierType.BUTTON1_MASK,
-            targets=[
-                Gtk.TargetEntry.new(target='text/uri-list', flags=0, info=0)
-            ],
-            actions=Gdk.DragAction.LINK | Gdk.DragAction.COPY)
-
-        def source_drag_data_get(btn, context, selection_data, info, time):
-            selection_data.set_uris([self.resourceObservable()])
-
-        self.currentLink.connect("drag_data_get", source_drag_data_get)
-
-    def uriChanged(self, newUri):
-        # if this resource had a type icon or a thumbnail, those would be
-        # cool to show in here too
-        if newUri is Local:
-            self.currentLink.set_label("(local)")
-            self.currentLink.drag_source_unset()
-        else:
-            self.graph.addHandler(self.updateLabel)
-            self.makeDragSource()
-        self.unlinkButton.set_sensitive(newUri is not Local)
-
-    def updateLabel(self):
-        uri = self.resourceObservable()
-        label = self.graph.label(uri)
-        self.currentLink.set_label(label or uri or "")
-
-    def onUnlink(self, *args):
-        self.resourceObservable(Local)
--- a/light9/effect/edit.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,205 +0,0 @@
-from rdflib import URIRef, Literal
-from twisted.internet.defer import inlineCallbacks, returnValue
-import treq
-
-from light9 import networking
-from light9.curvecalc.curve import CurveResource
-from light9.namespaces import L9, RDF, RDFS
-from rdfdb.patch import Patch
-
-
-def clamp(x, lo, hi):
-    return max(lo, min(hi, x))
-
-
-@inlineCallbacks
-def getMusicStatus():
-    resp = yield treq.get(networking.musicPlayer.path('time'), timeout=.5)
-    body = yield resp.json_content()
-    returnValue(body)
-
-
-@inlineCallbacks
-def songEffectPatch(graph, dropped, song, event, ctx):
-    """
-    some uri was 'dropped' in the curvecalc timeline. event is 'default' or 'start' or 'end'.
-    """
-    with graph.currentState(tripleFilter=(dropped, None, None)) as g:
-        droppedTypes = list(g.objects(dropped, RDF.type))
-        droppedLabel = g.label(dropped)
-        droppedCodes = list(g.objects(dropped, L9['code']))
-
-    quads = []
-    fade = 2 if event == 'default' else 0
-
-    if _songHasEffect(graph, song, dropped):
-        # bump the existing curve
-        pass
-    else:
-        effect, q = _newEffect(graph, song, ctx)
-        quads.extend(q)
-
-        curve = graph.sequentialUri(song + "/curve-")
-        yield _newEnvelopeCurve(graph, ctx, curve, droppedLabel, fade)
-        quads.extend([
-            (song, L9['curve'], curve, ctx),
-            (effect, RDFS.label, droppedLabel, ctx),
-            (effect, L9['code'], Literal('env = %s' % curve.n3()), ctx),
-        ])
-
-        if L9['EffectClass'] in droppedTypes:
-            quads.extend([
-                (effect, RDF.type, dropped, ctx),
-            ] + [(effect, L9['code'], c, ctx) for c in droppedCodes])
-        elif L9['Submaster'] in droppedTypes:
-            quads.extend([
-                (effect, L9['code'], Literal('out = %s * env' % dropped.n3()),
-                 ctx),
-            ])
-        else:
-            raise NotImplementedError(
-                "don't know how to add an effect from %r (types=%r)" %
-                (dropped, droppedTypes))
-
-        _maybeAddMusicLine(quads, effect, song, ctx)
-
-    print("adding")
-    for qq in quads:
-        print(qq)
-    returnValue(Patch(addQuads=quads))
-
-
-@inlineCallbacks
-def songNotePatch(graph, dropped, song, event, ctx, note=None):
-    """
-    drop into effectsequencer timeline
-
-    ported from timeline.coffee makeNewNote
-    """
-    with graph.currentState(tripleFilter=(dropped, None, None)) as g:
-        droppedTypes = list(g.objects(dropped, RDF.type))
-
-    quads = []
-    fade = 2 if event == 'default' else 0.1
-
-    if note:
-        musicStatus = yield getMusicStatus()
-        songTime = musicStatus['t']
-        _finishCurve(graph, note, quads, ctx, songTime)
-    else:
-        if L9['Effect'] in droppedTypes:
-            musicStatus = yield getMusicStatus()
-            songTime = musicStatus['t']
-            note = _makeNote(graph, song, note, quads, ctx, dropped, songTime,
-                             event, fade)
-        else:
-            raise NotImplementedError
-
-    returnValue((note, Patch(addQuads=quads)))
-
-
-def _point(ctx, uri, t, v):
-    return [(uri, L9['time'], Literal(round(t, 3)), ctx),
-            (uri, L9['value'], Literal(round(v, 3)), ctx)]
-
-
-def _finishCurve(graph, note, quads, ctx, songTime):
-    with graph.currentState() as g:
-        origin = g.value(note, L9['originTime']).toPython()
-        curve = g.value(note, L9['curve'])
-
-    pt2 = graph.sequentialUri(curve + 'p')
-    pt3 = graph.sequentialUri(curve + 'p')
-    quads.extend([(curve, L9['point'], pt2, ctx)] +
-                 _point(ctx, pt2, songTime - origin, 1) +
-                 [(curve, L9['point'], pt3, ctx)] +
-                 _point(ctx, pt3, songTime - origin + .5, 0))
-
-
-def _makeNote(graph, song, note, quads, ctx, dropped, songTime, event, fade):
-    note = graph.sequentialUri(song + '/n')
-    curve = graph.sequentialUri(note + 'c')
-    quads.extend([
-        (song, L9['note'], note, ctx),
-        (note, RDF.type, L9['Note'], ctx),
-        (note, L9['curve'], curve, ctx),
-        (note, L9['effectClass'], dropped, ctx),
-        (note, L9['originTime'], Literal(songTime), ctx),
-        (curve, RDF.type, L9['Curve'], ctx),
-        (curve, L9['attr'], L9['strength'], ctx),
-    ])
-    if event == 'default':
-        coords = [(0 - fade, 0), (0, 1), (20, 1), (20 + fade, 0)]
-    elif event == 'start':
-        coords = [
-            (0 - fade, 0),
-            (0, 1),
-        ]
-    elif event == 'end':  # probably unused- goes to _finishCurve instead
-        coords = [(20, 1), (20 + fade, 0)]
-    else:
-        raise NotImplementedError(event)
-    for t, v in coords:
-        pt = graph.sequentialUri(curve + 'p')
-        quads.extend([(curve, L9['point'], pt, ctx)] + _point(ctx, pt, t, v))
-    return note
-
-
-def _songHasEffect(graph, song, uri):
-    """does this song have an effect of class uri or a sub curve for sub
-    uri? this should be simpler to look up."""
-    return False  # todo
-
-
-def musicCurveForSong(uri):
-    return URIRef(uri + 'music')
-
-
-def _newEffect(graph, song, ctx):
-    effect = graph.sequentialUri(song + "/effect-")
-    quads = [
-        (song, L9['effect'], effect, ctx),
-        (effect, RDF.type, L9['Effect'], ctx),
-    ]
-    print("_newEffect", effect, quads)
-    return effect, quads
-
-
-@inlineCallbacks
-def _newEnvelopeCurve(graph, ctx, uri, label, fade=2):
-    """this does its own patch to the graph"""
-
-    cr = CurveResource(graph, uri)
-    cr.newCurve(ctx, label=Literal(label))
-    yield _insertEnvelopePoints(cr.curve, fade)
-    cr.saveCurve()
-
-
-@inlineCallbacks
-def _insertEnvelopePoints(curve, fade=2):
-    # wrong: we might not be adding to the currently-playing song.
-    musicStatus = yield getMusicStatus()
-    songTime = musicStatus['t']
-    songDuration = musicStatus['duration']
-
-    t1 = clamp(songTime - fade, .1, songDuration - .1 * 2) + fade
-    t2 = clamp(songTime + 20, t1 + .1, songDuration)
-
-    curve.insert_pt((t1 - fade, 0))
-    curve.insert_pt((t1, 1))
-    curve.insert_pt((t2, 1))
-    curve.insert_pt((t2 + fade, 0))
-
-
-def _maybeAddMusicLine(quads, effect, song, ctx):
-    """
-    add a line getting the current music into 'music' if any code might
-    be mentioning that var
-    """
-
-    for spoc in quads:
-        if spoc[1] == L9['code'] and 'music' in spoc[2]:
-            quads.extend([(effect, L9['code'],
-                           Literal('music = %s' % musicCurveForSong(song).n3()),
-                           ctx)])
-            break
--- a/light9/effect/effect_function_library.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-"""repo of the EffectFunctions in the graph. Includes URI->realPythonFunction"""
-import logging
-from dataclasses import dataclass, field
-from typing import Callable, List, Optional, cast
-
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import RDF, RDFS, Literal
-
-from light9.namespaces import FUNC, L9
-from light9.newtypes import EffectAttr, EffectFunction, VTUnion
-from light9.typedgraph import typedValue
-
-from . import effect_functions
-
-log = logging.getLogger('effectfuncs')
-
-
-@dataclass
-class _EffectFunctionInput:
-    effectAttr: EffectAttr
-    defaultValue: Optional[VTUnion]
-
-
-@dataclass
-class _RdfEffectFunction:
-    uri: EffectFunction
-    label: Optional[Literal]
-    inputs: List[_EffectFunctionInput]
-
-
-@dataclass
-class EffectFunctionLibrary:
-    """parses :EffectFunction structures"""
-    graph: SyncedGraph
-
-    funcs: List[_RdfEffectFunction] = field(default_factory=list)
-
-    def __post_init__(self):
-        self.graph.addHandler(self._compile)
-
-    def _compile(self):
-        self.funcs = []
-        for subj in self.graph.subjects(RDF.type, L9['EffectFunction']):
-            label = typedValue(Literal | None, self.graph, subj, RDFS.label)
-            inputs = []
-            for inp in self.graph.objects(subj, L9['input']):
-                inputs.append(
-                    _EffectFunctionInput(  #
-                        typedValue(EffectAttr, self.graph, inp, L9['effectAttr']),  #
-                        typedValue(VTUnion | None, self.graph, inp, L9['defaultValue'])))
-
-            self.funcs.append(_RdfEffectFunction(cast(EffectFunction, subj), label, inputs))
-
-    def getFunc(self, uri: EffectFunction) -> Callable:
-        return {
-            FUNC['scale']: effect_functions.effect_scale,
-            FUNC['strobe']: effect_functions.effect_strobe,
-        }[uri]
-
-    def getDefaultValue(self, uri: EffectFunction, attr: EffectAttr) -> VTUnion:
-        for f in self.funcs:
-            if f.uri == uri:
-                for i in f.inputs:
-                    if i.effectAttr == attr:
-                        if i.defaultValue is not None:
-                            return i.defaultValue
-        raise ValueError(f'no default for {uri} {attr}')
\ No newline at end of file
--- a/light9/effect/effect_function_library_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.mock_syncedgraph import MockSyncedGraph
-
-PREFIXES = '''
-@prefix : <http://light9.bigasterisk.com/> .
-@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
-@prefix effect: <http://light9.bigasterisk.com/effect/> .
-@prefix func: <http://light9.bigasterisk.com/effectFunction/> .
-@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
-'''
-
-GRAPH = PREFIXES + '''
-
-    func:scale
-        a :EffectFunction;
-        rdfs:label "a submaster- scales :deviceSettings";
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :deviceSettings; ] . # e.g. "par2 at color=red; par3 at color=white"
-
-    func:strobe
-        a :EffectFunction;
-        rdfs:label "blink specified devices";
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :period; :defaultValue 0.5 ],
-            [ :effectAttr :onTime; :defaultValue 0.1 ],
-            [ :effectAttr :deviceSettings ] .
-'''
-
-
-class TestParsesGraph:
-
-    def test(self):
-        g = MockSyncedGraph(GRAPH)
-        lib = EffectFunctionLibrary(g)
-        assert len(lib.funcs) == 2
\ No newline at end of file
--- a/light9/effect/effect_functions.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-import logging
-import random
-
-from PIL import Image
-from webcolors import rgb_to_hex
-
-from light9.effect.scale import scale
-from light9.effect.settings import DeviceSettings
-from light9.namespaces import L9
-
-random.seed(0)
-
-log = logging.getLogger('effectfunc')
-
-
-def sample8(img, x, y, repeat=False):
-    if not (0 <= y < img.height):
-        return (0, 0, 0)
-    if 0 <= x < img.width:
-        return img.getpixel((x, y))
-    elif not repeat:
-        return (0, 0, 0)
-    else:
-        return img.getpixel((x % img.width, y))
-
-
-def effect_scale(strength: float, devs: DeviceSettings) -> DeviceSettings:
-    out = []
-    if strength != 0:
-        for d, da, v in devs.asList():
-            out.append((d, da, scale(v, strength)))
-    return DeviceSettings(devs.graph, out)
-
-
-def effect_strobe(
-        songTime: float,  #
-        strength: float,
-        period: float,
-        onTime: float,
-        devs: DeviceSettings) -> DeviceSettings:
-    if period == 0:
-        scl = 0
-    else:
-        scl = strength if (songTime % period) < onTime else 0
-    return effect_scale(scl, devs)
-
-
-def effect_image(
-    songTime: float,  #
-    strength: float,
-    period: float,
-    image: Image.Image,
-    devs: DeviceSettings,
-) -> DeviceSettings:
-    x = int((songTime / period) * image.width)
-    out = []
-    for y, (d, da, v) in enumerate(devs.asOrderedList()):
-        if da != L9['color']:
-            continue
-        color8 = sample8(image, x, y, repeat=True)
-        color = rgb_to_hex(tuple(color8))
-        out.append((d, da, scale(color, strength * v)))
-    return DeviceSettings(devs.graph, out)
\ No newline at end of file
--- a/light9/effect/effecteval.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,466 +0,0 @@
-import logging
-import math
-import random
-from colorsys import hsv_to_rgb
-
-from noise import pnoise1
-from PIL import Image
-from rdflib import Literal, Namespace
-from webcolors import hex_to_rgb, rgb_to_hex
-
-from light9.effect.scale import scale
-from light9.namespaces import DEV, L9
-
-SKY = Namespace('http://light9.bigasterisk.com/theater/skyline/device/')
-
-random.seed(0)
-
-log = logging.getLogger('effecteval')
-log.info("reload effecteval")
-
-
-def literalColor(rnorm, gnorm, bnorm):
-    return Literal(rgb_to_hex((
-        int(rnorm * 255),  #
-        int(gnorm * 255),  #
-        int(bnorm * 255))))
-
-
-def literalColorHsv(h, s, v):
-    return literalColor(*hsv_to_rgb(h, s, v))
-
-
-def nsin(x):
-    return (math.sin(x * (2 * math.pi)) + 1) / 2
-
-
-def ncos(x):
-    return (math.cos(x * (2 * math.pi)) + 1) / 2
-
-
-def nsquare(t, on=.5):
-    return (t % 1.0) < on
-
-
-def lerp(a, b, t):
-    return a + (b - a) * t
-
-
-def noise(t):
-    return pnoise1(t % 1000.0, 2)
-
-
-def clamp(lo, hi, x):
-    return max(lo, min(hi, x))
-
-
-def clamp255(x):
-    return min(255, max(0, x))
-
-
-def _8bit(f):
-    if not isinstance(f, (int, float)):
-        raise TypeError(repr(f))
-    return clamp255(int(f * 255))
-
-
-def effect_Curtain(effectSettings, strength, songTime, noteTime):
-    return {(L9['device/lowPattern%s' % n], L9['color']): literalColor(strength, strength, strength) for n in range(301, 308 + 1)}
-
-
-def effect_animRainbow(effectSettings, strength, songTime, noteTime):
-    out = {}
-    tint = effectSettings.get(L9['tint'], '#ffffff')
-    tintStrength = float(effectSettings.get(L9['tintStrength'], 0))
-    tr, tg, tb = hex_to_rgb(tint)
-    for n in range(1, 5 + 1):
-        scl = strength * nsin(songTime + n * .3)**3
-        col = literalColor(scl * lerp(nsin(songTime + n * .2), tr / 255, tintStrength), scl * lerp(nsin(songTime + n * .2 + .3), tg / 255, tintStrength),
-                           scl * lerp(nsin(songTime + n * .3 + .6), tb / 255, tintStrength))
-
-        dev = L9['device/aura%s' % n]
-        out.update({
-            (dev, L9['color']): col,
-            (dev, L9['zoom']): .9,
-        })
-        ang = songTime * 4
-        out.update({
-            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4) + .2 * math.sin(ang + n),
-            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4) + .5 * math.cos(ang + n),
-        })
-    return out
-
-
-def effect_auraSparkles(effectSettings, strength, songTime, noteTime):
-    out = {}
-    tint = effectSettings.get(L9['tint'], '#ffffff')
-    print(effectSettings)
-    tr, tg, tb = hex_to_rgb(tint)
-    for n in range(1, 5 + 1):
-        scl = strength * ((int(songTime * 10) % n) < 1)
-        col = literalColorHsv((songTime + (n / 5)) % 1, 1, scl)
-
-        dev = L9['device/aura%s' % n]
-        out.update({
-            (dev, L9['color']): col,
-            (dev, L9['zoom']): .95,
-        })
-        ang = songTime * 4
-        out.update({
-            (dev, L9['rx']): lerp(.27, .8, (n - 1) / 4) + .2 * math.sin(ang + n),
-            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4) + .4 * math.cos(ang + n),
-        })
-    return out
-
-
-def effect_qpan(effectSettings, strength, songTime, noteTime):
-    dev = L9['device/q2']
-    dur = 4
-    col = scale(scale('#ffffff', strength), effectSettings.get(L9['colorScale']) or '#ffffff')
-    return {
-        (dev, L9['color']): col,
-        (dev, L9['focus']): 0.589,
-        (dev, L9['rx']): lerp(0.778, 0.291, clamp(0, 1, noteTime / dur)),
-        (dev, L9['ry']): 0.5,
-        (dev, L9['zoom']): 0.714,
-    }
-
-
-def effect_pulseRainbow(effectSettings, strength, songTime, noteTime):
-    out = {}
-    tint = effectSettings.get(L9['tint'], '#ffffff')
-    tintStrength = float(effectSettings.get(L9['tintStrength'], 0))
-    tr, tg, tb = hex_to_rgb(tint)
-    for n in range(1, 5 + 1):
-        scl = strength
-        col = literalColor(scl * lerp(nsin(songTime + n * .2), tr / 255, tintStrength), scl * lerp(nsin(songTime + n * .2 + .3), tg / 255, tintStrength),
-                           scl * lerp(nsin(songTime + n * .3 + .6), tb / 255, tintStrength))
-
-        dev = L9['device/aura%s' % n]
-        out.update({
-            (dev, L9['color']): col,
-            (dev, L9['zoom']): .5,
-        })
-        out.update({
-            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4),
-            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4),
-        })
-    return out
-
-
-def effect_aurawash(effectSettings, strength, songTime, noteTime):
-    out = {}
-    scl = strength
-    period = float(effectSettings.get(L9['period'], 125 / 60 / 4))
-    if period < .05:
-        quantTime = songTime
-    else:
-        quantTime = int(songTime / period) * period
-    noisePos = quantTime * 6.3456
-
-    col = literalColorHsv(noise(noisePos), 1, scl)
-    col = scale(col, effectSettings.get(L9['colorScale']) or '#ffffff')
-
-    print(songTime, quantTime, col)
-
-    for n in range(1, 5 + 1):
-        dev = L9['device/aura%s' % n]
-        out.update({
-            (dev, L9['color']): col,
-            (dev, L9['zoom']): .5,
-        })
-        out.update({
-            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4),
-            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4),
-        })
-    return out
-
-
-def effect_qsweep(effectSettings, strength, songTime, noteTime):
-    out = {}
-    period = float(effectSettings.get(L9['period'], 2))
-
-    col = effectSettings.get(L9['colorScale'], '#ffffff')
-    col = scale(col, effectSettings.get(L9['strength'], 1))
-
-    for n in range(1, 3 + 1):
-        dev = L9['device/q%s' % n]
-        out.update({
-            (dev, L9['color']): col,
-            (dev, L9['zoom']): effectSettings.get(L9['zoom'], .5),
-        })
-        out.update({
-            (dev, L9['rx']): lerp(.3, .8, nsin(songTime / period + n / 4)),
-            (dev, L9['ry']): effectSettings.get(L9['ry'], .2),
-        })
-    return out
-
-
-def effect_qsweepusa(effectSettings, strength, songTime, noteTime):
-    out = {}
-    period = float(effectSettings.get(L9['period'], 2))
-
-    colmap = {
-        1: '#ff0000',
-        2: '#998888',
-        3: '#0050ff',
-    }
-
-    for n in range(1, 3 + 1):
-        dev = L9['device/q%s' % n]
-        out.update({
-            (dev, L9['color']): scale(colmap[n], effectSettings.get(L9['strength'], 1)),
-            (dev, L9['zoom']): effectSettings.get(L9['zoom'], .5),
-        })
-        out.update({
-            (dev, L9['rx']): lerp(.3, .8, nsin(songTime / period + n / 4)),
-            (dev, L9['ry']): effectSettings.get(L9['ry'], .5),
-        })
-    return out
-
-
-chase1_members = [
-    DEV['backlight1'],
-    DEV['lip1'],
-    DEV['backlight2'],
-    DEV['down2'],
-    DEV['lip2'],
-    DEV['backlight3'],
-    DEV['down3'],
-    DEV['lip3'],
-    DEV['backlight4'],
-    DEV['down4'],
-    DEV['lip4'],
-    DEV['backlight5'],
-    DEV['down5Edge'],
-    DEV['lip5'],
-    #DEV['upCenter'],
-]
-chase2_members = chase1_members * 10
-random.shuffle(chase2_members)
-
-
-def effect_chase1(effectSettings, strength, songTime, noteTime):
-    members = chase1_members + chase1_members[-2:0:-1]
-
-    out = {}
-    period = float(effectSettings.get(L9['period'], 2 / len(members)))
-
-    for i, dev in enumerate(members):
-        cursor = (songTime / period) % float(len(members))
-        dist = abs(i - cursor)
-        radius = 3
-        if dist < radius:
-            col = effectSettings.get(L9['colorScale'], '#ffffff')
-            col = scale(col, effectSettings.get(L9['strength'], 1))
-            col = scale(col, (1 - dist / radius))
-
-            out.update({
-                (dev, L9['color']): col,
-            })
-    return out
-
-
-def effect_chase2(effectSettings, strength, songTime, noteTime):
-    members = chase2_members
-
-    out = {}
-    period = float(effectSettings.get(L9['period'], 0.3))
-
-    for i, dev in enumerate(members):
-        cursor = (songTime / period) % float(len(members))
-        dist = abs(i - cursor)
-        radius = 3
-        if dist < radius:
-            col = effectSettings.get(L9['colorScale'], '#ffffff')
-            col = scale(col, effectSettings.get(L9['strength'], 1))
-            col = scale(col, (1 - dist / radius))
-
-            out.update({
-                (dev, L9['color']): col,
-            })
-    return out
-
-
-def effect_whirlscolor(effectSettings, strength, songTime, noteTime):
-    out = {}
-
-    col = effectSettings.get(L9['colorScale'], '#ffffff')
-    col = scale(col, effectSettings.get(L9['strength'], 1))
-
-    for n in (1, 3):
-        dev = L9['device/q%s' % n]
-        scl = strength
-        col = literalColorHsv(((songTime / 5) + (n / 5)) % 1, 1, scl)
-        out.update({
-            (dev, L9['color']): col,
-        })
-
-    return out
-
-
-def effect_orangeSearch(effectSettings, strength, songTime, noteTime):
-    dev = L9['device/auraStage']
-    return {
-        (dev, L9['color']): '#a885ff',
-        (dev, L9['rx']): lerp(.65, 1, nsin(songTime / 2.0)),
-        (dev, L9['ry']): .6,
-        (dev, L9['zoom']): 1,
-    }
-
-
-def effect_Strobe(effectSettings, strength, songTime, noteTime):
-    rate = 2
-    duty = .3
-    offset = 0
-    f = (((songTime + offset) * rate) % 1.0)
-    c = (f < duty) * strength
-    col = rgb_to_hex((int(c * 255), int(c * 255), int(c * 255)))
-    return {(L9['device/colorStrip'], L9['color']): Literal(col)}
-
-
-def effect_lightning(effectSettings, strength, songTime, noteTime):
-    devs = [
-        L9['device/veryLow1'], L9['device/veryLow2'], L9['device/veryLow3'], L9['device/veryLow4'], L9['device/veryLow5'], L9['device/backlight1'],
-        L9['device/backlight2'], L9['device/backlight3'], L9['device/backlight4'], L9['device/backlight5'], L9['device/down2'], L9['device/down3'],
-        L9['device/down4'], L9['device/hexLow3'], L9['device/hexLow5'], L9['device/postL1'], L9['device/postR1']
-    ]
-    out = {}
-    col = rgb_to_hex((int(255 * strength),) * 3)
-    for i, dev in enumerate(devs):
-        n = noise(songTime * 8 + i * 6.543)
-        if n > .4:
-            out[(dev, L9['color'])] = col
-    return out
-
-
-def sample8(img, x, y, repeat=False):
-    if not (0 <= y < img.height):
-        return (0, 0, 0)
-    if 0 <= x < img.width:
-        return img.getpixel((x, y))
-    elif not repeat:
-        return (0, 0, 0)
-    else:
-        return img.getpixel((x % img.width, y))
-
-
-def effect_image(effectSettings, strength, songTime, noteTime):
-    out = {}
-    imageSetting = effectSettings.get(L9["image"], 'specks.png')
-    imgPath = f'cur/anim/{imageSetting}'
-    t_offset = effectSettings.get(L9['tOffset'], 0)
-    pxPerSec = effectSettings.get(L9['pxPerSec'], 30)
-    img = Image.open(imgPath)
-    x = (noteTime * pxPerSec)
-
-    colorScale = hex_to_rgb(effectSettings.get(L9['colorScale'], '#ffffff'))
-
-    for dev, y in [
-        (SKY['strip1'], 0),
-        (SKY['strip2'], 1),
-        (SKY['strip3'], 2),
-        (SKY['par3'], 3),  # dl
-        (SKY['par4'], 4),  # ul
-        (SKY['par7'], 5),  # ur
-        (SKY['par1'], 6),  # dr
-        ('cyc1', 7),
-        ('cyc2', 8),
-        ('cyc3', 9),
-        ('cyc4', 10),
-        ('down1', 11),
-        ('down2', 12),
-        ('down3', 13),
-        ('down4', 14),
-        ('down5', 15),
-        ('down6', 16),
-        ('down7', 17),
-    ]:
-        color8 = sample8(img, x, y, effectSettings.get(L9['repeat'], True))
-        color = map(lambda v: v / 255 * strength, color8)
-        color = [v * cs / 255 for v, cs in zip(color, colorScale)]
-        if dev in ['cyc1', 'cyc2', 'cyc3', 'cyc4']:
-            column = dev[-1]
-            out[(SKY[f'cycRed{column}'], L9['brightness'])] = color[0]
-            out[(SKY[f'cycGreen{column}'], L9['brightness'])] = color[1]
-            out[(SKY[f'cycBlue{column}'], L9['brightness'])] = color[2]
-        else:
-            out[(dev, L9['color'])] = rgb_to_hex(tuple(map(_8bit, color)))
-    return out
-
-
-def effect_cyc(effectSettings, strength, songTime, noteTime):
-    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
-    r, g, b = map(lambda x: strength * x / 255, hex_to_rgb(colorScale))
-
-    out = {
-        (SKY['cycRed1'], L9['brightness']): r,
-        (SKY['cycRed2'], L9['brightness']): r,
-        (SKY['cycRed3'], L9['brightness']): r,
-        (SKY['cycRed4'], L9['brightness']): r,
-        (SKY['cycGreen1'], L9['brightness']): g,
-        (SKY['cycGreen2'], L9['brightness']): g,
-        (SKY['cycGreen3'], L9['brightness']): g,
-        (SKY['cycGreen4'], L9['brightness']): g,
-        (SKY['cycBlue1'], L9['brightness']): b,
-        (SKY['cycBlue2'], L9['brightness']): b,
-        (SKY['cycBlue3'], L9['brightness']): b,
-        (SKY['cycBlue4'], L9['brightness']): b,
-    }
-
-    return out
-
-
-cycChase1_members = [
-    SKY['cycRed1'],
-    SKY['cycRed2'],
-    SKY['cycRed3'],
-    SKY['cycRed4'],
-    SKY['cycGreen1'],
-    SKY['cycGreen2'],
-    SKY['cycGreen3'],
-    SKY['cycGreen4'],
-    SKY['cycBlue1'],
-    SKY['cycBlue2'],
-    SKY['cycBlue3'],
-    SKY['cycBlue4'],
-]
-cycChase1_members = cycChase1_members * 20
-random.shuffle(cycChase1_members)
-
-
-def effect_cycChase1(effectSettings, strength, songTime, noteTime):
-    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
-    r, g, b = map(lambda x: x / 255, hex_to_rgb(colorScale))
-    tintAmount = {'Red': r, 'Green': g, 'Blue': b}
-
-    members = cycChase1_members
-
-    out = {}
-    period = float(effectSettings.get(L9['period'], 6 / len(members)))
-
-    for i, dev in enumerate(members):
-        cursor = (songTime / period) % float(len(members))
-        dist = abs(i - cursor)
-        radius = 7
-        if dist < radius:
-            colorFromUri = str(dev).split('/')[-1].split('cyc')[1][:-1]
-            scale = strength * tintAmount[colorFromUri]
-            out.update({
-                (dev, L9['brightness']): (1 - dist / radius) * scale,
-            })
-    return out
-
-
-def effect_parNoise(effectSettings, strength, songTime, noteTime):
-    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
-    r, g, b = map(lambda x: x / 255, hex_to_rgb(colorScale))
-    out = {}
-    speed = 10
-    gamma = .6
-    for dev in [SKY['strip1'], SKY['strip2'], SKY['strip3']]:
-        out[(dev, L9['color'])] = scale(
-            rgb_to_hex((_8bit(r * math.pow(max(.01, noise(speed * songTime)), gamma)), _8bit(g * math.pow(max(.01, noise(speed * songTime + 10)), gamma)),
-                        _8bit(b * math.pow(max(.01, noise(speed * songTime + 20)), gamma)))), strength)
-
-    return out
--- a/light9/effect/effecteval2.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,122 +0,0 @@
-import traceback
-import inspect
-import logging
-from dataclasses import dataclass
-from typing import Callable, List, Optional
-
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import RDF
-from rdflib.term import Node
-
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.effect.settings import DeviceSettings, EffectSettings
-from light9.namespaces import L9
-from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectFunction, EffectUri, VTUnion)
-from light9.typedgraph import typedValue
-
-log = logging.getLogger('effecteval')
-
-
-@dataclass
-class Config:
-    effectFunction: EffectFunction
-    esettings: EffectSettings
-    devSettings: Optional[DeviceSettings]  # the EffectSettings :effectAttr :devSettings item, if there was one
-    func: Callable
-    funcArgs: List[inspect.Parameter]
-
-
-@dataclass
-class EffectEval2:
-    """Runs one effect code to turn EffectSettings (e.g. strength) into DeviceSettings"""
-    graph: SyncedGraph
-    uri: EffectUri
-    lib: EffectFunctionLibrary
-
-    config: Optional[Config] = None
-
-    def __post_init__(self):
-        self.graph.addHandler(self._compile)
-
-    def _compile(self):
-        self.config = None
-        if not self.graph.contains((self.uri, RDF.type, L9['Effect'])):
-            return
-
-        try:
-            effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
-            effSets = []
-            devSettings = None
-            for s in self.graph.objects(self.uri, L9['setting']):
-                attr = typedValue(EffectAttr, self.graph, s, L9['effectAttr'])
-                if attr == L9['deviceSettings']:
-                    value = typedValue(Node, self.graph, s, L9['value'])
-
-                    rows = []
-                    for ds in self.graph.objects(value, L9['setting']):
-                        d = typedValue(DeviceUri, self.graph, ds, L9['device'])
-                        da = typedValue(DeviceAttr, self.graph, ds, L9['deviceAttr'])
-                        v = typedValue(VTUnion, self.graph, ds, L9['value'])
-                        rows.append((d, da, v))
-                    devSettings = DeviceSettings(self.graph, rows)
-                else:
-                    value = typedValue(VTUnion, self.graph, s, L9['value'])
-                    effSets.append((self.uri, attr, value))
-            esettings = EffectSettings(self.graph, effSets)
-
-            try:
-                effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
-            except ValueError:
-                raise ValueError(f'{self.uri} has no :effectFunction')
-            func = self.lib.getFunc(effectFunction)
-
-            # This should be in EffectFunctionLibrary
-            funcArgs = list(inspect.signature(func).parameters.values())
-
-            self.config = Config(effectFunction, esettings, devSettings, func, funcArgs)
-        except Exception:
-            log.error(f"while compiling {self.uri}")
-            traceback.print_exc()
-
-    def compute(self, songTime: float, inputs: EffectSettings) -> DeviceSettings:
-        """
-        calls our function using inputs (publishedAttr attrs, e.g. :strength)
-        and effect-level settings including a special attr called :deviceSettings
-        with DeviceSettings as its value
-        """
-        if self.config is None:
-            return DeviceSettings(self.graph, [])
-
-        c = self.config
-        kw = {}
-        for arg in c.funcArgs:
-            if arg.annotation == DeviceSettings:
-                v = c.devSettings
-                if v is None: # asked for ds but we have none
-                    log.debug("%s asked for devs but we have none in config", self.uri)
-                    return DeviceSettings(self.graph, [])
-            elif arg.name == 'songTime':
-                v = songTime
-            else:
-                eaForName = EffectAttr(L9[arg.name])
-                v = self._getEffectAttrValue(eaForName, inputs)
-
-            kw[arg.name] = v
-
-        if False and log.isEnabledFor(logging.DEBUG):
-            log.debug('calling %s with %s', c.func, kw)
-        return c.func(**kw)
-
-    def _getEffectAttrValue(self, attr: EffectAttr, inputs: EffectSettings) -> VTUnion:
-        c = self.config
-        if c is None:
-            raise
-        try:
-            return inputs.getValue(self.uri, attr, defaultToZero=False)
-        except KeyError:
-            pass
-        try:
-            return c.esettings.getValue(self.uri, attr, defaultToZero=False)
-        except KeyError:
-            pass
-        return self.lib.getDefaultValue(c.effectFunction, attr)
--- a/light9/effect/effecteval_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,123 +0,0 @@
-from typing import List, Tuple
-
-import pytest
-
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.effect.effecteval2 import EffectEval2
-from light9.effect.settings import DeviceSettings, EffectSettings
-from light9.mock_syncedgraph import MockSyncedGraph
-from light9.namespaces import DEV, L9
-from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectUri, HexColor, VTUnion)
-
-PREFIX = '''
-    @prefix : <http://light9.bigasterisk.com/> .
-    @prefix dev: <http://light9.bigasterisk.com/device/> .
-    @prefix effect: <http://light9.bigasterisk.com/effect/> .
-    @prefix func: <http://light9.bigasterisk.com/effectFunction/> .
-    @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-'''
-
-GRAPH = PREFIX + '''
-
-    func:scale
-        a :EffectFunction;
-        rdfs:label "a submaster- scales :deviceSettings";
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :deviceSettings; ] . # e.g. "par2 at color=red; par3 at color=white"
-
-    func:strobe
-        a :EffectFunction;
-        rdfs:label "blink specified devices";
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :period; :defaultValue 0.5 ],
-            [ :effectAttr :onTime; :defaultValue 0.1 ],
-            [ :effectAttr :deviceSettings ] .
-
-    func:image
-        a :EffectFunction;
-        rdfs:label "sample image at x=time";
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :period; :defaultValue 2.0 ],
-            [ :effectAttr :image; :defaultValue "specks.png" ],
-            [ :effectAttr :deviceSettings; rdfs:comment "these might have a :sort key or a :y value" ] .
-
-
-    :effectSub
-        a :Effect;
-        :effectFunction func:scale;
-        :publishAttr :strength;
-        :setting [ :effectAttr :deviceSettings; :value [
-                   :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ]  ] ].
-
-    :effectDefaultStrobe
-        a :Effect;
-        :effectFunction func:strobe;
-        :publishAttr :strength;
-        :setting [ :effectAttr :deviceSettings; :value [
-                   :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
-
-    :effectCustomStrobe
-        a :Effect;
-        :effectFunction func:strobe;
-        :publishAttr :strength;
-        :setting
-            [ :effectAttr :period; :value 3.0],
-            [ :effectAttr :onTime; :value 0.5],
-            [ :effectAttr :deviceSettings; :value [
-              :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
-'''
-
-effectSub = EffectUri(L9['effectSub'])
-effectDefaultStrobe = EffectUri(L9['effectDefaultStrobe'])
-effectCustomStrobe = EffectUri(L9['effectCustomStrobe'])
-
-
-def light1At(col: str) -> List[Tuple[DeviceUri, DeviceAttr, VTUnion]]:
-    return [(DeviceUri(DEV['light1']), DeviceAttr(L9['color']), HexColor(col))]
-
-
-@pytest.fixture
-def effectFunctions():
-    g = MockSyncedGraph(GRAPH)
-    return EffectFunctionLibrary(g)
-
-
-class TestEffectEval:
-
-    def test_scalesColors(self, effectFunctions):
-        g = effectFunctions.graph
-        ee = EffectEval2(g, effectSub, effectFunctions)
-        s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.5)])
-        ds = ee.compute(songTime=0, inputs=s)
-        assert ds == DeviceSettings(g, light1At('#7f0000'))
-
-    def test_cullsZeroOutputs(self, effectFunctions):
-        g = effectFunctions.graph
-        ee = EffectEval2(g, effectSub, effectFunctions)
-        s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.0)])
-        ds = ee.compute(songTime=0, inputs=s)
-        assert ds == DeviceSettings(g, [])
-
-    def test_strobeDefaults(self, effectFunctions):
-        g = effectFunctions.graph
-        ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
-        s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 1.0)])
-        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
-        assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, [])
-
-    def strobeMultsStrength(self, effectFunctions):
-        g = effectFunctions.graph
-        ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
-        s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 0.5)])
-        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#7f0000'))
-
-    def test_strobeCustom(self, effectFunctions):
-        g = effectFunctions.graph
-        ee = EffectEval2(g, effectCustomStrobe, effectFunctions)
-        s = EffectSettings(g, [(effectCustomStrobe, EffectAttr(L9['strength']), 1.0)])
-        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
-        assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
-        assert ee.compute(songTime=.6, inputs=s) == DeviceSettings(g, [])
--- a/light9/effect/scale.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-from decimal import Decimal
-
-from webcolors import hex_to_rgb, rgb_to_hex
-
-from light9.newtypes import VTUnion
-
-
-def scale(value: VTUnion, strength: float):
-    if isinstance(value, Decimal):
-        raise TypeError()
-
-    if isinstance(value, str):
-        if value[0] == '#':
-            if strength == '#ffffff':
-                return value
-            r, g, b = hex_to_rgb(value)
-            # if isinstance(strength, Literal):
-            #     strength = strength.toPython()
-            # if isinstance(strength, str):
-            #     sr, sg, sb = [v / 255 for v in hex_to_rgb(strength)]
-            if True:
-                sr = sg = sb = strength
-            return rgb_to_hex((int(r * sr), int(g * sg), int(b * sb)))
-    elif isinstance(value, (int, float)):
-        return value * strength
-
-    raise NotImplementedError("%r,%r" % (value, strength))
--- a/light9/effect/sequencer/__init__.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-from .note import Note
--- a/light9/effect/sequencer/eval_faders.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,111 +0,0 @@
-import traceback
-import logging
-import time
-from dataclasses import dataclass
-from typing import List, Optional, cast
-
-from prometheus_client import Summary
-from rdfdb import SyncedGraph
-from rdflib import URIRef
-from rdflib.term import Node
-
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.effect.effecteval2 import EffectEval2
-from light9.effect.settings import DeviceSettings, EffectSettings
-from light9.namespaces import L9, RDF
-from light9.newtypes import EffectAttr, EffectUri, UnixTime
-from light9.typedgraph import typedValue
-
-log = logging.getLogger('seq.fader')
-
-COMPILE = Summary('compile_graph_fader', 'compile')
-COMPUTE_ALL_FADERS = Summary('compute_all_faders', 'compile')
-
-
-@dataclass
-class Fader:
-    graph: SyncedGraph
-    lib: EffectFunctionLibrary
-    uri: URIRef
-    effect: EffectUri
-    setEffectAttr: EffectAttr
-
-    value: Optional[float] = None  # mutable
-
-    def __post_init__(self):
-        self.ee = EffectEval2(self.graph, self.effect, self.lib)
-
-
-class FaderEval:
-    """peer to Sequencer, but this one takes the current :Fader settings -> sendToCollector
-
-    """
-
-    def __init__(self, graph: SyncedGraph, lib: EffectFunctionLibrary):
-        self.graph = graph
-        self.lib = lib
-        self.faders: List[Fader] = []
-        self.grandMaster = 1.0
-
-        self.graph.addHandler(self._compile)
-        self.graph.addHandler(self._compileGm)
-
-    @COMPILE.time()
-    def _compile(self) -> None:
-        """rebuild our data from the graph"""
-        self.faders = []
-        for fader in self.graph.subjects(RDF.type, L9['Fader']):
-            try:
-                self.faders.append(self._compileFader(fader))
-            except ValueError:
-                pass
-
-        # this could go in a second, smaller addHandler call to avoid rebuilding Fader objs constantly
-        for f in self.faders:
-            f.value = None
-            try:
-                setting = typedValue(Node, self.graph, f.uri, L9['setting'])
-            except ValueError:
-                continue
-
-            try:
-                f.value = typedValue(float, self.graph, setting, L9['value'])
-            except ValueError:
-                continue
-
-    def _compileFader(self, fader: URIRef) -> Fader:
-        effect = typedValue(EffectUri, self.graph, fader, L9['effect'])
-        setting = typedValue(Node, self.graph, fader, L9['setting'])
-        setAttr = typedValue(EffectAttr, self.graph, setting, L9['effectAttr'])
-        return Fader(self.graph, self.lib, cast(URIRef, fader), effect, setAttr)
-
-    def _compileGm(self):
-        try:
-            self.grandMaster = typedValue(float, self.graph, L9.grandMaster, L9.value)
-        except ValueError:
-            return
-
-    @COMPUTE_ALL_FADERS.time()
-    def computeOutput(self) -> DeviceSettings:
-        faderEffectOutputs: List[DeviceSettings] = []
-        now = UnixTime(time.time())
-        for f in self.faders:
-            try:
-                if f.value is None:
-                    log.warning(f'{f.value=}; should be set during _compile. Skipping {f.uri}')
-                    continue
-                v = f.value
-                v *= self.grandMaster
-                effectSettings = EffectSettings(self.graph, [(f.effect, f.setEffectAttr, v)])
-
-                ds = f.ee.compute(now, effectSettings)
-                faderEffectOutputs.append(ds)
-            except Exception:
-                log.warning(f'on fader {f}')
-                traceback.print_exc()
-                continue
-
-        merged = DeviceSettings.merge(self.graph, faderEffectOutputs)
-        # please remove (after fixing stats display to show it)
-        log.debug("computed %s faders in %.1fms", len(self.faders), (time.time() - now) * 1000)
-        return merged
--- a/light9/effect/sequencer/eval_faders_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,79 +0,0 @@
-from unittest import mock
-
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.effect.sequencer.eval_faders import FaderEval
-from light9.effect.settings import DeviceSettings
-from light9.mock_syncedgraph import MockSyncedGraph
-from light9.namespaces import L9
-
-PREFIXES = '''
-@prefix : <http://light9.bigasterisk.com/> .
-@prefix effect: <http://light9.bigasterisk.com/effect/> .
-@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
-@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
-@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
-@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
-@prefix func: <http://light9.bigasterisk.com/effectFunction/> .
-'''
-
-NOTE_GRAPH = PREFIXES + '''
-    :brightness
-        a :DeviceAttr;
-        rdfs:label "brightness";
-        :dataType :scalar .
-
-    :strength
-        a :EffectAttr;
-        rdfs:label "strength" .
-
-    :SimpleDimmer
-        a :DeviceClass;
-        rdfs:label "SimpleDimmer";
-        :deviceAttr :brightness;
-        :attr [ :outputAttr :level; :dmxOffset 0 ] .
-
-    :light1
-        a :SimpleDimmer;
-        :dmxUniverse dmxA:;
-        :dmxBase 178 .
-
-
-    func:scale
-        a :EffectFunction;
-        :input
-            [ :effectAttr :strength; :defaultValue 0.0 ],
-            [ :effectAttr :deviceSettings; ] .
-
-
-    effect:effect1
-        a :Effect;
-        :effectFunction func:scale;
-        :setting [:effectAttr :deviceSettings; :value [
-            :setting [
-              :device :light1; :deviceAttr :brightness; :value 0.5
-            ]
-        ] ] .
-
-
-    :fade1
-        a :Fader;
-        :effect effect:effect1;
-        :setting :fs1 .
-    :fs1
-        :value 0.6 ;
-        :effectAttr :strength .
-
-        '''
-
-
-class TestFaderEval:
-
-    def test_faderValueScalesEffectSettings(self):
-        g = MockSyncedGraph(NOTE_GRAPH)
-        sender = mock.MagicMock()
-
-        eff = EffectFunctionLibrary(g)
-        f = FaderEval(g, eff)
-        devSettings = f.computeOutput()
-        assert devSettings == DeviceSettings(g, [(L9['light1'], L9['brightness'], 0.3)])
\ No newline at end of file
--- a/light9/effect/sequencer/note.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,155 +0,0 @@
-import bisect
-import logging
-import time
-from dataclasses import dataclass
-from decimal import Decimal
-from typing import Any, Dict, List, Optional, Tuple, Union, cast
-from light9.typedgraph import typedValue
-
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import Literal
-
-from light9.effect.settings import BareEffectSettings
-from light9.namespaces import L9
-from light9.newtypes import (Curve, EffectAttr, NoteUri, VTUnion)
-
-log = logging.getLogger('sequencer')
-
-
-def pyType(n):
-    ret = n.toPython()
-    if isinstance(ret, Decimal):
-        return float(ret)
-    return ret
-
-
-def prettyFormat(x: Union[float, str]):
-    if isinstance(x, float):
-        return round(x, 4)
-    return x
-
-
-@dataclass
-class Note:
-    """A Note outputs EffectAttr settings.
-
-    Sample graph:
-     :note1 a :Note; :curve :n1c1; :effectClass effect:allcolor;
-
-    It can animate the EffectAttr settings over time, in two ways:
-     * a `timed note` has an envelope curve that animates
-       the :strength EffectAttr over time
-     * an `untimed note` has no curve, a fixed strength, but
-       still passes the wall clock time to its effect so the
-       effect can include animation. A `Fader` is an untimed note.
-
-    This obj is immutable, I think, but the graph can change,
-    which can affect the output. However, I think this doesn't
-    do its own rebuilds, and it's up to the caller to addHandler
-    around the creation of Note objects.
-    """
-    graph: SyncedGraph
-    uri: NoteUri
-    # simpleOutputs: SimpleOutputs
-    timed: bool = True
-
-    def __post_init__(self):  # graph ok
-        ec = self.graph.value(self.uri, L9['effectClass'])
-        if ec is None:
-            raise ValueError(f'note {self.uri} has no :effectClass')
-        self.effectClass = EffectClass(ec)
-
-        self.baseEffectSettings = self.getBaseEffectSettings()
-
-        if self.timed:
-            originTime = typedValue(float, self.graph, self.uri, L9['originTime'])
-            self.points: List[Tuple[float, float]] = []
-            for curve in self.graph.objects(self.uri, L9['curve']):
-                self.points.extend(self.getCurvePoints(cast(Curve, curve), L9['strength'], originTime))
-            self.points.sort()
-        else:
-            self.points = []
-            self.value = typedValue(float, self.graph, self.uri, L9['value'])
-
-    def getBaseEffectSettings(self) -> BareEffectSettings:  # graph ok
-        """i think these are settings that are fixed over time,
-        e.g. that you set in the note's body in the timeline editor
-        """
-        out: Dict[EffectAttr, VTUnion] = {}
-        for s in self.graph.objects(self.uri, L9['setting']):
-            settingValues = dict(self.graph.predicate_objects(s))
-            ea = cast(EffectAttr, settingValues[L9['effectAttr']])
-            out[ea] = pyType(settingValues[L9['value']])
-        return BareEffectSettings(s=out)
-
-    def getCurvePoints(self, curve: Curve, attr, originTime: float) -> List[Tuple[float, float]]:
-        points = []
-        po = list(self.graph.predicate_objects(curve))
-        if dict(po).get(L9['attr'], None) != attr:
-            return []
-        for point in [row[1] for row in po if row[0] == L9['point']]:
-            po2 = dict(self.graph.predicate_objects(point))
-            t = cast(Literal, po2[L9['time']]).toPython()
-            if not isinstance(t, float):
-                raise TypeError
-
-            v = cast(Literal, po2[L9['value']]).toPython()
-            if not isinstance(v, float):
-                raise TypeError
-            points.append((originTime + t, v))
-        return points
-
-    def activeAt(self, t: float) -> bool:
-        return self.points[0][0] <= t <= self.points[-1][0]
-
-    def evalCurve(self, t: float) -> float:
-        i = bisect.bisect_left(self.points, (t, None)) - 1
-
-        if i == -1:
-            return self.points[0][1]
-        if self.points[i][0] > t:
-            return self.points[i][1]
-        if i >= len(self.points) - 1:
-            return self.points[i][1]
-
-        p1, p2 = self.points[i], self.points[i + 1]
-        frac = (t - p1[0]) / (p2[0] - p1[0])
-        y = p1[1] + (p2[1] - p1[1]) * frac
-        return y
-
-    def outputCurrent(self):  # no graph
-
-        return self._outputSettings(t=None, strength=self.value)
-
-    def _outputSettings(
-            self,
-            t: float | None,
-            strength: Optional[float] = None  #
-    ) -> Tuple[BareEffectSettings, Dict]:  # no graph
-
-        if t is None:
-            if self.timed:
-                raise TypeError()
-            t = time.time()  # so live effects will move
-        report: Dict[str, Any] = {
-            'note': str(self.uri),
-            'effectClass': str(self.effectClass),
-        }
-
-        s = self.evalCurve(t) if strength is None else strength
-        out = self.baseEffectSettings.withStrength(s)
-        report['effectSettings'] = dict((str(k), prettyFormat(v)) for k, v in sorted(out.s.items()))
-        report['nonZero'] = s > 0
-
-        return out, report
-
-        # old api had this
-
-        startTime = self.points[0][0] if self.timed else 0
-        out, evalReport = self.effectEval.outputFromEffect(
-            effectSettings,
-            songTime=t,
-            # note: not using origin here since it's going away
-            noteTime=t - startTime)
-        report['devicesAffected'] = len(out.devices())
-        return out, report
--- a/light9/effect/sequencer/note_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,78 +0,0 @@
-import pytest
-
-from light9.effect.sequencer import Note
-from light9.effect.settings import BareEffectSettings
-from light9.mock_syncedgraph import MockSyncedGraph
-from light9.namespaces import L9
-from light9.newtypes import EffectAttr, NoteUri
-
-PREFIXES = '''
-@prefix : <http://light9.bigasterisk.com/> .
-@prefix effect: <http://light9.bigasterisk.com/effect/> .
-@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
-@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
-@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
-@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
-'''
-
-FADER_GRAPH = PREFIXES + '''
-    :fade1
-        a :Fader;
-        :effectClass effect:effect1;
-        :effectAttr :strength;
-        :value 0.6 .
-'''
-
-
-# class TestUntimedFaderNote:
-
-#     def test_returnsEffectSettings(self):
-#         g = MockSyncedGraph(FADER_GRAPH)
-#         n = Note(g, NoteUri(L9['fade1']), timed=False)
-#         out, report = n.outputCurrent()
-#         assert report['effectSettings'] == {'http://light9.bigasterisk.com/strength': 0.6}
-#         assert out == BareEffectSettings(s={EffectAttr(L9['strength']): 0.6})
-
-
-NOTE_GRAPH = PREFIXES + '''
-    :brightness
-        a :DeviceAttr;
-        rdfs:label "brightness";
-        :dataType :scalar .
-
-    :strength
-        a :EffectAttr;
-        rdfs:label "strength" .
-
-    :SimpleDimmer
-        a :DeviceClass;
-        rdfs:label "SimpleDimmer";
-        :deviceAttr :brightness;
-        :attr [ :outputAttr :level; :dmxOffset 0 ] .
-
-    dev:light1
-        a :SimpleDimmer;
-        :dmxUniverse dmxA:;
-        :dmxBase 178 .
-
-    effect:effect1
-        a :EffectClass;
-        :setting effect:effect1_set1 .
-    effect:effect1_set1
-        :device dev:light1;
-        :deviceAttr :brightness;
-        :scaledValue 0.5 .
-    :fade1
-        a :Fader;
-        :effectClass effect:effect1;
-        :effectAttr :strength;
-        :value 0.6 .
-        '''
-
-
-class TestTimedNote:
-
-    @pytest.mark.skip()
-    def test_scalesStrengthWithCurve(self):
-        pass
--- a/light9/effect/sequencer/sequencer.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,168 +0,0 @@
-'''
-copies from effectloop.py, which this should replace
-'''
-
-import asyncio
-import importlib
-import logging
-import time
-import traceback
-from typing import Callable, Coroutine, Dict, List, cast
-
-from louie import All, dispatcher
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import URIRef
-from twisted.internet import reactor
-from twisted.internet.inotify import INotify
-from twisted.python.filepath import FilePath
-
-from light9.ascoltami.musictime_client import MusicTime
-from light9.effect import effecteval
-from light9.effect.sequencer import Note
-from light9.effect.settings import DeviceSettings
-from light9.metrics import metrics
-from light9.namespaces import L9, RDF
-from light9.newtypes import NoteUri, Song
-
-log = logging.getLogger('sequencer')
-
-
-class StateUpdate(All):
-    pass
-
-
-class CodeWatcher:
-
-    def __init__(self, onChange):
-        self.onChange = onChange
-
-        self.notifier = INotify()
-        self.notifier.startReading()
-        self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc',
-                                                                 '.py')),
-                            callbacks=[self.codeChange])
-
-    def codeChange(self, watch, path, mask):
-
-        def go():
-            log.info("reload effecteval")
-            importlib.reload(effecteval)
-            self.onChange()
-
-        # in case we got an event at the start of the write
-        reactor.callLater(.1, go) # type: ignore
-
-
-class Sequencer:
-    """Notes from the graph + current song playback -> sendToCollector"""
-    def __init__(self,
-                 graph: SyncedGraph,
-                 sendToCollector: Callable[[DeviceSettings], Coroutine[None ,None,None]],
-                 fps=40,
-                 ):
-        self.graph = graph
-        self.fps = fps
-        metrics('update_loop_goal_fps').set(self.fps)
-        metrics('update_loop_goal_latency').set(1 / self.fps)
-        self.sendToCollector = sendToCollector
-        self.music = MusicTime(period=.2)
-
-        self.recentUpdateTimes: List[float] = []
-        self.lastStatLog = 0.0
-        self._compileGraphCall = None
-        self.notes: Dict[Song, List[Note]] = {}  # song: [notes]
-        self.simpleOutputs = SimpleOutputs(self.graph)
-        self.graph.addHandler(self.compileGraph)
-        self.lastLoopSucceeded = False
-
-        # self.codeWatcher = CodeWatcher(onChange=self.onCodeChange)
-        asyncio.create_task(self.updateLoop())
-
-    def onCodeChange(self):
-        log.debug('seq.onCodeChange')
-        self.graph.addHandler(self.compileGraph)
-        #self.updateLoop()
-
-    def compileGraph(self) -> None:
-        """rebuild our data from the graph"""
-        for song in self.graph.subjects(RDF.type, L9['Song']):
-
-            def compileSong(song: Song = cast(Song, song)) -> None:
-                self.compileSong(song)
-
-            self.graph.addHandler(compileSong)
-
-    def compileSong(self, song: Song) -> None:
-        anyErrors = False
-        self.notes[song] = []
-        for note in self.graph.objects(song, L9['note']):
-            try:
-                n = Note(self.graph, NoteUri(cast(NoteUri, note)))
-            except Exception:
-                log.warn(f"failed to build Note {note} - skipping")
-                anyErrors = True
-                continue
-            self.notes[song].append(n)
-        if not anyErrors:
-            log.info(f'built all notes for {song}')
-
-    async def updateLoop(self):
-        while True:
-            frameStart = time.time()
-            try:
-                sec = await self.update()
-            except Exception as e:
-                self.lastLoopSucceeded = False
-                traceback.print_exc()
-                log.warn('updateLoop: %r', e)
-                await asyncio.sleep(1)
-                continue
-            else:
-                took = time.time() - frameStart
-                metrics('update_loop_latency').observe(took)
-
-                if not self.lastLoopSucceeded:
-                    log.info('Sequencer.update is working')
-                    self.lastLoopSucceeded = True
-
-                delay = max(0, 1 / self.fps - took)
-                await asyncio.sleep(delay)
-                continue
-
-    async def update(self):
-        with metrics('update_s0_getMusic').time():
-            musicState = {'t':123.0,'song':'http://light9.bigasterisk.com/show/dance2019/song5'}#self.music.getLatest()
-            if not musicState.get('song') or not isinstance(
-                    musicState.get('t'), float):
-                return
-            song = Song(URIRef(musicState['song']))
-            # print('dispsend')
-            # import pdb;pdb.set_trace()
-            dispatcher.send(StateUpdate,
-                            update={
-                                'song': str(song),
-                                't': musicState['t']
-                            })
-
-        with metrics('update_s1_eval').time():
-            settings = []
-            songNotes = sorted(cast(List[Note], self.notes.get(song, [])), key=lambda n: n.uri)
-            noteReports = []
-            for note in songNotes:
-                try:
-                    s, report = note.outputSettings(musicState['t'])
-                except Exception:
-                    traceback.print_exc()
-                    raise
-                noteReports.append(report)
-                settings.append(s)
-            devSettings = DeviceSettings.fromList(self.graph, settings)
-        dispatcher.send(StateUpdate, update={'songNotes': noteReports})
-
-        with metrics('update_s3_send').time():  # our measurement
-            sendSecs = await self.sendToCollector(devSettings)
-
-        # sendToCollector's own measurement.
-        # (sometimes it's None, not sure why, and neither is mypy)
-        #if isinstance(sendSecs, float):
-        #    metrics('update_s3_send_client').observe(sendSecs)
--- a/light9/effect/sequencer/service.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
-"""
-plays back effect notes from the timeline (and an untimed note from the faders)
-"""
-
-import asyncio
-import json
-import logging
-import time
-
-from louie import dispatcher
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from sse_starlette.sse import EventSourceResponse
-from starlette.applications import Starlette
-from starlette.routing import Route
-from starlette_exporter import PrometheusMiddleware, handle_metrics
-
-from lib.background_loop import loop_forever
-from light9 import networking
-from light9.collector.collector_client_asyncio import sendToCollector
-from light9.effect.effect_function_library import EffectFunctionLibrary
-from light9.effect.sequencer.eval_faders import FaderEval
-from light9.effect.sequencer.sequencer import Sequencer, StateUpdate
-from light9.run_local import log
-
-RATE = 20
-
-
-async def changes():
-    state = {}
-    q = asyncio.Queue()
-
-    def onBroadcast(update):
-        state.update(update)
-        q.put_nowait(None)
-
-    dispatcher.connect(onBroadcast, StateUpdate)
-
-    lastSend = 0
-    while True:
-        await q.get()
-        now = time.time()
-        if now > lastSend + .2:
-            lastSend = now
-            yield json.dumps(state)
-
-
-async def send_page_updates(request):
-    return EventSourceResponse(changes())
-
-
-def main():
-    graph = SyncedGraph(networking.rdfdb.url, "effectSequencer")
-    logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
- 
-    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
-    logging.getLogger('syncedgraph').setLevel(logging.INFO)
- 
-    logging.getLogger('effecteval').setLevel(logging.INFO)
-    logging.getLogger('seq.fader').setLevel(logging.INFO)
-
-    # seq = Sequencer(graph, send)  # per-song timed notes
-    lib = EffectFunctionLibrary(graph)
-    faders = FaderEval(graph, lib)  # bin/fade's untimed effects
-
-    #@metrics('computeAndSend').time() # needs rework with async
-    async def update(first_run):
-        ds = faders.computeOutput()
-        await sendToCollector('effectSequencer', session='0', settings=ds)
-
-    faders_loop = loop_forever(func=update, metric_prefix='faders', sleep_period=1 / RATE)
-
-    app = Starlette(
-        debug=True,
-        routes=[
-            Route('/updates', endpoint=send_page_updates),
-        ],
-    )
-
-    app.add_middleware(PrometheusMiddleware)
-    app.add_route("/metrics", handle_metrics)
-
-    return app
-
-
-app = main()
--- a/light9/effect/sequencer/service_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-import asyncio
-
-from light9.run_local import log
-
-
-def test_import():
-
-    async def go():
-        # this sets up some watcher tasks
-        from light9.effect.sequencer.service import app
-        print(app)
-
-    asyncio.run(go(), debug=True)
\ No newline at end of file
--- a/light9/effect/sequencer/web/Light9SequencerUi.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,166 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { getTopGraph } from "../../../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../../../web/SyncedGraph";
-
-debug.enable("*");
-const log = debug("sequencer");
-
-interface Note {
-  note: string;
-  nonZero: boolean;
-  rowClass?: string; // added in message handler below
-  effectClass: string;
-  effectSettings: { [attr: string]: string };
-  effectSettingsPairs: EffectSettingsPair[]; // added in message handler below
-  devicesAffected: number;
-}
-interface Report {
-  song: string;
-  songUri: NamedNode; // added in message handler below
-  t: number;
-  roundT?: number; // added in message handler below
-  recentFps: number;
-  recentDeltas: number[];
-  recentDeltasStyle: string[]; // added in message handler below
-  songNotes: Note[];
-}
-interface EffectSettingsPair {
-  effectAttr: string;
-  value: any;
-}
-@customElement("light9-sequencer-ui")
-export class Light9SequencerUi extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-      }
-      td {
-        white-space: nowrap;
-        padding: 0 10px;
-        vertical-align: top;
-        vertical-align: top;
-        text-align: start;
-      }
-      tr.active {
-        background: #151515;
-      }
-      .inactive > * {
-        opacity: 0.5;
-      }
-      .effectSetting {
-        display: inline-block;
-        background: #1b1e21;
-        margin: 1px 3px;
-      }
-      .chart {
-        height: 40px;
-        background: #222;
-        display: inline-flex;
-        align-items: flex-end;
-      }
-      .chart > div {
-        background: #a4a54f;
-        width: 8px;
-        margin: 0 1px;
-      }
-      .number {
-        display: inline-block;
-        min-width: 4em;
-      }
-    `,
-  ];
-  render() {
-    return [
-      html` <rdfdb-synced-graph></rdfdb-synced-graph>
-
-        <h1>Sequencer <a href="metrics">[metrics]</a></h1>
-
-        <h2>Song</h2>`,
-      this.report
-        ? html`
-
-      <resource-display .uri=${this.graph.Uri(this.report.song)}"></resource-display>
-      t=${this.report.roundT}
-
-      <h3>Notes</h3>
-
-      <table>
-        <tr>
-          <th>Note</th>
-          <th>Effect class</th>
-          <th>Effect settings</th>
-          <th>Devices affected</th>
-        </tr>
-        ${this.report.songNotes.map(
-          (item: Note) => html`
-            <tr class="${item.rowClass}">
-              <td><resource-display .uri="${this.graph.Uri(item.note)}"></resource-display></td>
-              <td><resource-display .uri="${this.graph.Uri(item.effectClass)}"></resource-display></td>
-              <td>
-                ${item.effectSettingsPairs.map(
-                  (item) => html`
-                    <div>
-                      <span class="effectSetting">
-                        <resource-display .uri="${this.graph.Uri(item.effectAttr)}"></resource-display>:
-                        <span class="number">${item.value}</span>
-                      </span>
-                    </div>
-                  `
-                )}
-              </td>
-              <td>${item.devicesAffected}</td>
-            </tr>
-          `
-        )}
-      </table>
-    `
-        : html`waiting for first report...`,
-    ];
-  }
-
-  graph!: SyncedGraph;
-  @property() report!: Report;
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      const source = new EventSource("./api/updates");
-      source.addEventListener("message", this.onMessage.bind(this));
-    });
-  }
-  onMessage(ev: MessageEvent) {
-    const report = JSON.parse(ev.data) as Report;
-    report.roundT = Math.floor((report.t || 0) * 1000) / 1000;
-    report.recentFps = Math.floor((report.recentFps || 0) * 10) / 10;
-    report.recentDeltasStyle = (report.recentDeltas || []).map((dt) => {
-      const height = Math.min(40, (dt / 0.085) * 20);
-      return `height: ${height}px;`;
-    });
-    report.songUri = this.graph.Uri(report.song);
-
-    const fakeUris = (report.songNotes || []).map((obj) => {
-      return { value: obj.note, orig: obj };
-    });
-    const s = this.graph.sortedUris(fakeUris);
-    report.songNotes = s.map((u) => {
-      return u.orig;
-    });
-
-    (report.songNotes || []).forEach((note) => {
-      note.rowClass = note.nonZero ? "active" : "inactive";
-      note.effectSettingsPairs = [];
-
-      const attrs = Object.keys(note.effectSettings);
-      attrs.sort();
-      attrs.forEach((attr) => {
-        note.effectSettingsPairs.push({ effectAttr: attr, value: note.effectSettings[attr] } as EffectSettingsPair);
-      });
-    });
-    this.report = report;
-  }
-}
--- a/light9/effect/sequencer/web/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>effect sequencer</title>
-    <meta charset="utf-8" />
-
-    <link rel="stylesheet" href="./style.css" />
-    <script type="module" src="../effectSequencer/Light9SequencerUi"></script>
-  </head>
-  <body>
-    <light9-sequencer-ui></light9-sequencer-ui>
-  </body>
-</html>
--- a/light9/effect/sequencer/web/vite.config.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8213;
-export default defineConfig({
-  base: "/effectSequencer/",
-  root: "./light9/effect/sequencer/web",
-  publicDir: "../web",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/effect/settings.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,311 +0,0 @@
-"""
-Data structure and convertors for a table of (device,attr,value)
-rows. These might be effect attrs ('strength'), device attrs ('rx'),
-or output attrs (dmx channel).
-
-BareSettings means (attr,value), no device.
-"""
-from __future__ import annotations
-
-import decimal
-import logging
-from dataclasses import dataclass
-from typing import Any, Dict, Iterable, List, Sequence, Set, Tuple, cast
-
-import numpy
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import Literal, URIRef
-
-from light9.collector.device import resolve
-from light9.localsyncedgraph import LocalSyncedGraph
-from light9.namespaces import L9, RDF
-from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, HexColor, VTUnion)
-
-log = logging.getLogger('settings')
-
-
-def parseHex(h):
-    if h[0] != '#':
-        raise ValueError(h)
-    return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
-
-
-def parseHexNorm(h):
-    return [x / 255 for x in parseHex(h)]
-
-
-def toHex(rgbFloat: Sequence[float]) -> HexColor:
-    assert len(rgbFloat) == 3
-    scaled = (max(0, min(255, int(v * 255))) for v in rgbFloat)
-    return HexColor('#%02x%02x%02x' % tuple(scaled))
-
-
-def getVal(graph, subj):
-    lit = graph.value(subj, L9['value']) or graph.value(subj, L9['scaledValue'])
-    ret = lit.toPython()
-    if isinstance(ret, decimal.Decimal):
-        ret = float(ret)
-    return ret
-
-
-GraphType = SyncedGraph | LocalSyncedGraph
-
-
-class _Settings:
-    """
-    Generic for DeviceUri/DeviceAttr/VTUnion or EffectClass/EffectAttr/VTUnion
-
-    default values are 0 or '#000000'. Internal rep must not store zeros or some
-    comparisons will break.
-    """
-    EntityType = DeviceUri
-    AttrType = DeviceAttr
-
-    def __init__(self, graph: GraphType, settingsList: List[Tuple[Any, Any, VTUnion]]):
-        self.graph = graph  # for looking up all possible attrs
-        self._compiled: Dict[self.__class__.EntityType, Dict[self.__class__.AttrType, VTUnion]] = {}
-        for e, a, v in settingsList:
-            attrVals = self._compiled.setdefault(e, {})
-            if a in attrVals:
-                v = resolve(
-                    e,  # Hey, this is supposed to be DeviceClass (which is not convenient for us), but so far resolve() doesn't use that arg
-                    a,
-                    [attrVals[a], v])
-            attrVals[a] = v
-        # self._compiled may not be final yet- see _fromCompiled
-        self._delZeros()
-
-    @classmethod
-    def _fromCompiled(cls, graph: GraphType, compiled: Dict[EntityType, Dict[AttrType, VTUnion]]):
-        obj = cls(graph, [])
-        obj._compiled = compiled
-        obj._delZeros()
-        return obj
-
-    @classmethod
-    def fromList(cls, graph: GraphType, others: List[_Settings]):
-        """note that others may have multiple values for an attr"""
-        self = cls(graph, [])
-        for s in others:
-            # if not isinstance(s, cls):
-            #     raise TypeError(s)
-            for row in s.asList():  # could work straight from s._compiled
-                if row[0] is None:
-                    raise TypeError('bad row %r' % (row,))
-                dev, devAttr, value = row
-                devDict = self._compiled.setdefault(dev, {})
-                if devAttr in devDict:
-                    existingVal: VTUnion = devDict[devAttr]
-                    # raise NotImplementedError('fixme: dev is to be a deviceclass (but it is currently unused)')
-                    value = resolve(dev, devAttr, [existingVal, value])
-                devDict[devAttr] = value
-        self._delZeros()
-        return self
-
-    @classmethod
-    def _mult(cls, weight, row, dd) -> VTUnion:
-        if isinstance(row[2], str):
-            prev = parseHexNorm(dd.get(row[1], '#000000'))
-            return toHex(prev + weight * numpy.array(parseHexNorm(row[2])))
-        else:
-            return dd.get(row[1], 0) + weight * row[2]
-
-    @classmethod
-    def fromBlend(cls, graph: GraphType, others: List[Tuple[float, _Settings]]):
-        """others is a list of (weight, Settings) pairs"""
-        out = cls(graph, [])
-        for weight, s in others:
-            if not isinstance(s, cls):
-                raise TypeError(s)
-            for row in s.asList():  # could work straight from s._compiled
-                if row[0] is None:
-                    raise TypeError('bad row %r' % (row,))
-                dd = out._compiled.setdefault(row[0], {})
-
-                newVal = cls._mult(weight, row, dd)
-                dd[row[1]] = newVal
-        out._delZeros()
-        return out
-
-    def _zeroForAttr(self, attr: AttrType) -> VTUnion:
-        if attr == L9['color']:
-            return HexColor('#000000')
-        return 0.0
-
-    def _delZeros(self):
-        for dev, av in list(self._compiled.items()):
-            for attr, val in list(av.items()):
-                if val == self._zeroForAttr(attr):
-                    del av[attr]
-            if not av:
-                del self._compiled[dev]
-
-    def __hash__(self):
-        itemed = tuple([(d, tuple([(a, v) for a, v in sorted(av.items())])) for d, av in sorted(self._compiled.items())])
-        return hash(itemed)
-
-    def __eq__(self, other):
-        if not issubclass(other.__class__, self.__class__):
-            raise TypeError("can't compare %r to %r" % (self.__class__, other.__class__))
-        return self._compiled == other._compiled
-
-    def __ne__(self, other):
-        return not self == other
-
-    def __bool__(self):
-        return bool(self._compiled)
-
-    def __repr__(self):
-        words = []
-
-        def accum():
-            for dev, av in self._compiled.items():
-                for attr, val in sorted(av.items()):
-                    words.append('%s.%s=%s' % (dev.rsplit('/')[-1], attr.rsplit('/')[-1], val))
-                    if len(words) > 5:
-                        words.append('...')
-                        return
-
-        accum()
-        if not words:
-            words = ['(no settings)']
-        return '<%s %s>' % (self.__class__.__name__, ' '.join(words))
-
-    def getValue(self, dev: EntityType, attr: AttrType, defaultToZero=True):
-        x = self._compiled.get(dev, {})
-        if defaultToZero:
-            return x.get(attr, self._zeroForAttr(attr))
-        else:
-            return x[attr]
-
-    def _vectorKeys(self, deviceAttrFilter=None):
-        """stable order of all the dev,attr pairs for this type of settings"""
-        raise NotImplementedError
-
-    def asList(self) -> List[Tuple[EntityType, AttrType, VTUnion]]:
-        """old style list of (dev, attr, val) tuples"""
-        out = []
-        for dev, av in self._compiled.items():
-            for attr, val in av.items():
-                out.append((dev, attr, val))
-        return out
-
-    def devices(self) -> List[EntityType]:
-        return list(self._compiled.keys())
-
-    def toVector(self, deviceAttrFilter=None) -> List[float]:
-        out: List[float] = []
-        for dev, attr in self._vectorKeys(deviceAttrFilter):
-            v = self.getValue(dev, attr)
-            if attr == L9['color']:
-                out.extend(parseHexNorm(v))
-            else:
-                if not isinstance(v, float):
-                    raise TypeError(f'{attr=} value was {v=}')
-                out.append(v)
-        return out
-
-    def byDevice(self) -> Iterable[Tuple[EntityType, _Settings]]:
-        for dev, av in self._compiled.items():
-            yield dev, self.__class__._fromCompiled(self.graph, {dev: av})
-
-    def ofDevice(self, dev: EntityType) -> _Settings:
-        return self.__class__._fromCompiled(self.graph, {dev: self._compiled.get(dev, {})})
-
-    def distanceTo(self, other):
-        diff = numpy.array(self.toVector()) - other.toVector()
-        d = numpy.linalg.norm(diff, ord=None)
-        log.info('distanceTo %r - %r = %g', self, other, d)
-        return d
-
-    def statements(self, subj: EntityType, ctx: URIRef, settingRoot: URIRef, settingsSubgraphCache: Set):
-        """
-        settingRoot can be shared across images (or even wider if you want)
-        """
-        # ported from live.coffee
-        add = []
-        for i, (dev, attr, val) in enumerate(self.asList()):
-            # hopefully a unique number for the setting so repeated settings converge
-            settingHash = hash((dev, attr, val)) % 9999999
-            setting = URIRef('%sset%s' % (settingRoot, settingHash))
-            add.append((subj, L9['setting'], setting, ctx))
-            if setting in settingsSubgraphCache:
-                continue
-
-            scaledAttributeTypes = [L9['color'], L9['brightness'], L9['uv']]
-            settingType = L9['scaledValue'] if attr in scaledAttributeTypes else L9['value']
-            if not isinstance(val, URIRef):
-                val = Literal(val)
-            add.extend([
-                (setting, L9['device'], dev, ctx),
-                (setting, L9['deviceAttr'], attr, ctx),
-                (setting, settingType, val, ctx),
-            ])
-            settingsSubgraphCache.add(setting)
-
-        return add
-
-
-class DeviceSettings(_Settings):
-    EntityType = DeviceUri
-    AttrType = DeviceAttr
-
-    def _vectorKeys(self, deviceAttrFilter=None):
-        with self.graph.currentState() as g:
-            devs = set()  # devclass, dev
-            for dc in g.subjects(RDF.type, L9['DeviceClass']):
-                for dev in g.subjects(RDF.type, dc):
-                    devs.add((dc, dev))
-
-            keys = []
-            for dc, dev in sorted(devs):
-                for attr in sorted(g.objects(dc, L9['deviceAttr'])):
-                    key = (dev, attr)
-                    if deviceAttrFilter and key not in deviceAttrFilter:
-                        continue
-                    keys.append(key)
-        return keys
-
-    @classmethod
-    def fromResource(cls, graph: GraphType, subj: EntityType):
-        settingsList = []
-        with graph.currentState() as g:
-            for s in g.objects(subj, L9['setting']):
-                d = g.value(s, L9['device'])
-                da = g.value(s, L9['deviceAttr'])
-                v = getVal(g, s)
-                settingsList.append((d, da, v))
-        return cls(graph, settingsList)
-
-    @classmethod
-    def fromVector(cls, graph, vector, deviceAttrFilter=None):
-        compiled: Dict[DeviceSettings.EntityType, Dict[DeviceSettings.AttrType, VTUnion]] = {}
-        i = 0
-        for (d, a) in cls(graph, [])._vectorKeys(deviceAttrFilter):
-            if a == L9['color']:
-                v = toHex(vector[i:i + 3])
-                i += 3
-            else:
-                v = vector[i]
-                i += 1
-            compiled.setdefault(d, {})[a] = v
-        return cls._fromCompiled(graph, compiled)
-
-    @classmethod
-    def merge(cls, graph: SyncedGraph, others: List[DeviceSettings]) -> DeviceSettings:
-        return cls.fromList(graph, cast(List[_Settings], others))
-
-
-@dataclass
-class BareEffectSettings:
-    # settings for an already-selected EffectClass
-    s: Dict[EffectAttr, VTUnion]
-
-    def withStrength(self, strength: float) -> BareEffectSettings:
-        out = self.s.copy()
-        out[EffectAttr(L9['strength'])] = strength
-        return BareEffectSettings(s=out)
-
-
-class EffectSettings(_Settings):
-    pass
--- a/light9/effect/settings_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,151 +0,0 @@
-import unittest
-from typing import cast
-
-from rdfdb.patch import Patch
-from rdflib import Literal
-
-from light9.effect.settings import DeviceSettings
-from light9.localsyncedgraph import LocalSyncedGraph
-from light9.namespaces import DEV, L9
-from light9.newtypes import DeviceAttr, DeviceUri, HexColor, VTUnion, decimalLiteral
-
-
-class TestDeviceSettings(unittest.TestCase):
-
-    def setUp(self):
-        self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-
-    def testToVectorZero(self):
-        ds = DeviceSettings(self.graph, [])
-        self.assertEqual([0] * 30, ds.toVector())
-
-    def testEq(self):
-        s1 = DeviceSettings(self.graph, [
-            (L9['light1'], L9['attr1'], 0.5),
-            (L9['light1'], L9['attr2'], 0.3),
-        ])
-        s2 = DeviceSettings(self.graph, [
-            (L9['light1'], L9['attr2'], 0.3),
-            (L9['light1'], L9['attr1'], 0.5),
-        ])
-        self.assertTrue(s1 == s2)
-        self.assertFalse(s1 != s2)
-
-    def testMissingFieldsEqZero(self):
-        self.assertEqual(DeviceSettings(self.graph, [
-            (L9['aura1'], L9['rx'], 0),
-        ]), DeviceSettings(self.graph, []))
-
-    def testFalseIfZero(self):
-        self.assertTrue(DeviceSettings(self.graph, [(L9['aura1'], L9['rx'], 0.1)]))
-        self.assertFalse(DeviceSettings(self.graph, []))
-
-    def testFromResource(self):
-        ctx = L9['']
-        self.graph.patch(
-            Patch(addQuads=[
-                (L9['foo'], L9['setting'], L9['foo_set0'], ctx),
-                (L9['foo_set0'], L9['device'], L9['light1'], ctx),
-                (L9['foo_set0'], L9['deviceAttr'], L9['brightness'], ctx),
-                (L9['foo_set0'], L9['value'], decimalLiteral(0.1), ctx),
-                (L9['foo'], L9['setting'], L9['foo_set1'], ctx),
-                (L9['foo_set1'], L9['device'], L9['light1'], ctx),
-                (L9['foo_set1'], L9['deviceAttr'], L9['speed'], ctx),
-                (L9['foo_set1'], L9['scaledValue'], decimalLiteral(0.2), ctx),
-            ]))
-        s = DeviceSettings.fromResource(self.graph, DeviceUri(L9['foo']))
-
-        self.assertEqual(DeviceSettings(self.graph, [
-            (L9['light1'], L9['brightness'], 0.1),
-            (L9['light1'], L9['speed'], 0.2),
-        ]), s)
-
-    def testToVector(self):
-        v = DeviceSettings(self.graph, [
-            (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
-            (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
-        ]).toVector()
-        self.assertEqual([0, 1, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], v)
-
-    def testFromVector(self):
-        s = DeviceSettings.fromVector(self.graph, [0, 1, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
-
-        self.assertEqual(
-            DeviceSettings(self.graph, [
-                (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
-                (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
-            ]), s)
-
-    def testAsList(self):
-        sets = [
-            (DeviceUri(L9['light1']), DeviceAttr(L9['attr2']), cast(VTUnion, 0.3)),
-            (DeviceUri(L9['light1']), DeviceAttr(L9['attr1']), 0.5),
-        ]
-        self.assertCountEqual(sets, DeviceSettings(self.graph, sets).asList())
-
-    def testDevices(self):
-        s = DeviceSettings(self.graph, [
-            (DEV['aura1'], L9['rx'], 0),
-            (DEV['aura2'], L9['rx'], 0.1),
-        ])
-        # aura1 is all defaults (zeros), so it doesn't get listed
-        self.assertCountEqual([DEV['aura2']], s.devices())
-
-    def testAddStatements(self):
-        s = DeviceSettings(self.graph, [
-            (DEV['aura2'], L9['rx'], 0.1),
-        ])
-        stmts = s.statements(DeviceUri(L9['foo']), L9['ctx1'], L9['s_'], set())
-        self.maxDiff = None
-        setting = sorted(stmts)[-1][0]
-        self.assertCountEqual([
-            (L9['foo'], L9['setting'], setting, L9['ctx1']),
-            (setting, L9['device'], DEV['aura2'], L9['ctx1']),
-            (setting, L9['deviceAttr'], L9['rx'], L9['ctx1']),
-            (setting, L9['value'], Literal(0.1), L9['ctx1']),
-        ], stmts)
-
-    def testDistanceTo(self):
-        s1 = DeviceSettings(self.graph, [
-            (DEV['aura1'], L9['rx'], 0.1),
-            (DEV['aura1'], L9['ry'], 0.6),
-        ])
-        s2 = DeviceSettings(self.graph, [
-            (DEV['aura1'], L9['rx'], 0.3),
-            (DEV['aura1'], L9['ry'], 0.3),
-        ])
-        self.assertEqual(0.36055512754639896, s1.distanceTo(s2))
-
-
-L1 = L9['light1']
-ZOOM = L9['zoom']
-
-
-class TestFromBlend(unittest.TestCase):
-
-    def setUp(self):
-        self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-
-    def testSingle(self):
-        self.assertEqual(DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]),
-                         DeviceSettings.fromBlend(self.graph, [(1, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))]))
-
-    def testScale(self):
-        self.assertEqual(DeviceSettings(self.graph, [(L1, ZOOM, 0.1)]),
-                         DeviceSettings.fromBlend(self.graph, [(.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))]))
-
-    def testMixFloats(self):
-        self.assertEqual(
-            DeviceSettings(self.graph, [(L1, ZOOM, 0.4)]),
-            DeviceSettings.fromBlend(self.graph, [
-                (.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)])),
-                (.3, DeviceSettings(self.graph, [(L1, ZOOM, 1.0)])),
-            ]))
-
-    def testMixColors(self):
-        self.assertEqual(
-            DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#503000'))]),
-            DeviceSettings.fromBlend(self.graph, [
-                (.25, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#800000'))])),
-                (.5, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#606000'))])),
-            ]))
--- a/light9/effecteval/__init__.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-
--- a/light9/effecteval/effect-components.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,109 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer.html">
-<script src="/websocket.js"></script>
-<script src="/lib/jquery/dist/jquery.min.js"></script>
-
-<dom-module id="song-effect-list">
-  <template>
-    <template is="dom-repeat" items="{{songs}}" as="song">
-      <li>
-        <a class="song"
-           href="{{song.uri}}"
-           on-click="focusSong">Song <span>{{song.label}}</span></a>
-        <ul>
-          <template is="dom-repeat" items="{{song.effects}}" as="effect">
-            <li>
-              <l9-effect uri="{{effect.uri}}"
-                         label="{{effect.label}}"></l9-effect>
-            </li>
-          </template>
-       <!--    <li>
-          <effect-drop-target song-uri="{{song.uri}}"></effect-drop-target>
-      </li>
-      -->
-        </ul>
-      </li>
-    </template>
-  </template>
-</dom-module>
-<script>
- Polymer({
-   is: "song-effect-list",
-   properties: {
-     songs: Object
-   },
-   ready: function() {
-     reconnectingWebSocket("songEffectsUpdates", function(msg) {
-       var m, s;
-       m = window.location.search.match(/song=(http[^&]+)/);
-       if (m) {
-         var match = decodeURIComponent(m[1]);
-         this.songs = msg.songs.filter(function(s) { return s.uri == match; });
-       } else {
-         this.songs = msg.songs;
-       }
-     }.bind(this));
-   },
-   focusSong: function(ev) {
-     ev.preventDefault()
-     window.location.search = '?' + $.param({song: ev.model.song.uri});
-   }
- });
-</script>
-
-<dom-module id="l9-effect">
-  <template>
-    <a class="effect" href="{{href}}">{{label}}</a>
-    
-  </template>
-</dom-module>
-<script>
- Polymer({
-   is: "l9-effect",
-   properties: {
-     uri: String,
-     label: String,
-     href: {
-       type: String,
-       computed: 'computeHref(uri)'
-     }
-   },
-   computeHref: function(uri) {
-     return 'effect?'+jQuery.param({uri: uri});
-   },
-   deleteEffect: function() {
-     $.ajax({
-       type: 'DELETE',
-       url: 'effect?' + $.param({uri: this.uri})
-     });
-     console.log("del", this.uri);
-   }
- });
-</script>
-
-<dom-module id="effect-drop-target">
-  <template>
-    <div class="dropTarget"
-        on-dragenter="dragover"
-        on-dragover="dragover"
-        on-drop="drop">Add another (drop a sub or effect class)</div>
-  </template>
-</dom-module>
-<script>
- Polymer({
-   is: "effect-drop-target",
-   properties: {
-     songUri: String
-   },
-   dragover: function(event) {
-     event.preventDefault()
-     event.dataTransfer.dropEffect = 'copy'
-   },
-   drop: function(event) {
-     event.preventDefault()
-     $.post('songEffects', {
-       uri: this.songUri,
-       drop: event.dataTransfer.getData('text/uri-list')
-     });
-   }
- });
-</script>
--- a/light9/effecteval/effect.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-qs = new QueryString()
-model =
-  toSave: 
-    uri: ko.observable(qs.value('uri'))
-    codeLines: ko.observableArray([])
-  
-socket = reconnectingWebSocket "../effectUpdates" + window.location.search, (msg) ->
-  console.log('effectData ' + JSON.stringify(msg))
-  model.toSave.codeLines(msg.codeLines.map((x) -> {text: ko.observable(x)})) if msg.codeLines?
-
-model.saveCode = ->
-  $.ajax
-    type: 'PUT'
-    url: 'code'
-    data: ko.toJS(model.toSave)
-
-writeBack = ko.computed(model.saveCode)
-  
-ko.applyBindings(model)
-  
\ No newline at end of file
--- a/light9/effecteval/effect.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>effect</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="/style.css">
-
-  </head>
-  <body>
-    <div id="status">starting...</div>
-
-    <a href="./">Effects</a> / <a class="effect" data-bind="attr: {href: toSave.uri}, text: toSave.uri"></a>
-
-    <div data-bind="foreach: toSave.codeLines">
-      <div>
-        code:
-        <input type="text" size="160" data-bind="value: text"></input>
-      </div>
-    </div>
-    
-    <script src="/lib/jquery/dist/jquery.min.js"></script>
-    <script src="/lib/knockout/dist/knockout.js"></script>
-    <script src="/websocket.js"></script>
-    <script src="/lib/QueryString/index.js"></script>
-    <script src="effect.js"></script>
-  </body>
-</html>
--- a/light9/effecteval/effect.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,192 +0,0 @@
-import re, logging
-import toposort
-from rdflib import URIRef
-from light9.namespaces import L9, RDF
-from light9.curvecalc.curve import CurveResource
-from light9 import prof
-from light9 import Submaster
-from light9 import Effects  # gets reload() later
-log = logging.getLogger('effect')
-
-# consider http://waxeye.org/ for a parser that can be used in py and js
-
-
-class CouldNotConvert(TypeError):
-    pass
-
-
-class CodeLine:
-    """code string is immutable"""
-
-    def __init__(self, graph, code):
-        self.graph, self.code = graph, code
-
-        self.outName, self.inExpr, self.expr, self.resources = self._asPython()
-        self.pyResources = self._resourcesAsPython(self.resources)
-        self.possibleVars = self.findVars(self.inExpr)
-
-    @prof.logTime
-    def _asPython(self):
-        """
-        out = sub(<uri1>, intensity=<curveuri2>)
-        becomes
-          'out',
-          'sub(_u1, intensity=curve(_u2, t))',
-          {'_u1': URIRef('uri1'), '_u2': URIRef('uri2')}
-        """
-        lname, expr = [s.strip() for s in self.code.split('=', 1)]
-        self.uriCounter = 0
-        resources = {}
-
-        def alreadyInFunc(prefix, s, i):
-            return i >= len(prefix) and s[i - len(prefix):i] == prefix
-
-        def repl(m):
-            v = '_res%s' % self.uriCounter
-            self.uriCounter += 1
-            r = resources[v] = URIRef(m.group(1))
-            for uriTypeMatches, wrapFuncName, addlArgs in [
-                (self._uriIsCurve(r), 'curve', ', t'),
-                    # I'm pretty sure this shouldn't be auto-applied: it's reasonable to refer to a sub and not want its current value
-                    #(self._uriIsSub(r), 'currentSubLevel', ''),
-            ]:
-                if uriTypeMatches:
-                    if not alreadyInFunc(wrapFuncName + '(', m.string,
-                                         m.start()):
-                        return '%s(%s%s)' % (wrapFuncName, v, addlArgs)
-            return v
-
-        outExpr = re.sub(r'<(http\S*?)>', repl, expr)
-        return lname, expr, outExpr, resources
-
-    def findVars(self, expr):
-        """may return some more strings than just the vars"""
-        withoutUris = re.sub(r'<(http\S*?)>', 'None', expr)
-        tokens = set(re.findall(r'\b([a-zA-Z_]\w*)\b', withoutUris))
-        tokens.discard('None')
-        return tokens
-
-    def _uriIsCurve(self, uri):
-        # this result could vary with graph changes (rare)
-        return self.graph.contains((uri, RDF.type, L9['Curve']))
-
-    def _uriIsSub(self, uri):
-        return self.graph.contains((uri, RDF.type, L9['Submaster']))
-
-    @prof.logTime
-    def _resourcesAsPython(self, resources):
-        """
-        mapping of the local names for uris in the code to high-level
-        objects (Submaster, Curve)
-        """
-        out = {}
-        subs = prof.logTime(Submaster.get_global_submasters)(self.graph)
-        for localVar, uri in list(resources.items()):
-
-            for rdfClass in self.graph.objects(uri, RDF.type):
-                if rdfClass == L9['Curve']:
-                    cr = CurveResource(self.graph, uri)
-                    # this is slow- pool these curves somewhere, maybe just with curveset
-                    prof.logTime(cr.loadCurve)()
-                    out[localVar] = cr.curve
-                    break
-                elif rdfClass == L9['Submaster']:
-                    out[localVar] = subs.get_sub_by_uri(uri)
-                    break
-                else:
-                    out[localVar] = CouldNotConvert(uri)
-                    break
-            else:
-                out[localVar] = CouldNotConvert(uri)
-
-        return out
-
-
-class EffectNode:
-
-    def __init__(self, graph, uri):
-        self.graph, self.uri = graph, uri
-        # this is not expiring at the right time, when an effect goes away
-        self.graph.addHandler(self.prepare)
-
-    @prof.logTime
-    def prepare(self):
-        log.info("prepare effect %s", self.uri)
-        # maybe there can be multiple lines of code as multiple
-        # objects here, and we sort them by dependencies
-        codeStrs = list(self.graph.objects(self.uri, L9['code']))
-        if not codeStrs:
-            raise ValueError("effect %s has no code" % self.uri)
-
-        self.codes = [CodeLine(self.graph, s) for s in codeStrs]
-
-        self.sortCodes()
-
-        #reload(Effects)
-        self.otherFuncs = prof.logTime(Effects.configExprGlobals)()
-
-    def sortCodes(self):
-        """put self.codes in a working evaluation order"""
-        codeFromOutput = dict((c.outName, c) for c in self.codes)
-        deps = {}
-        for c in self.codes:
-            outName = c.outName
-            inNames = c.possibleVars.intersection(list(codeFromOutput.keys()))
-            inNames.discard(outName)
-            deps[outName] = inNames
-        self.codes = [
-            codeFromOutput[n] for n in toposort.toposort_flatten(deps)
-        ]
-
-    def _currentSubSettingValues(self, sub):
-        """what KC subSettings are setting levels right now?"""
-        cs = self.graph.currentState
-        with cs(tripleFilter=(None, L9['sub'], sub)) as g1:
-            for subj in g1.subjects(L9['sub'], sub):
-                with cs(tripleFilter=(subj, None, None)) as g2:
-                    if (subj, RDF.type, L9['SubSetting']) in g2:
-                        v = g2.value(subj, L9['level']).toPython()
-                        yield v
-
-    def currentSubLevel(self, uri):
-        """what's the max level anyone (probably KC) is
-        holding this sub to right now?"""
-        if isinstance(uri, Submaster.Submaster):
-            # likely the uri was spotted and replaced
-            uri = uri.uri
-        if not isinstance(uri, URIRef):
-            raise TypeError("got %r" % uri)
-
-        foundLevels = list(self._currentSubSettingValues(uri))
-
-        if not foundLevels:
-            return 0
-
-        return max(foundLevels)
-
-    def eval(self, songTime):
-        ns = {'t': songTime}
-        ns.update(self.otherFuncs)
-
-        ns.update(
-            dict(
-                curve=lambda c, t: c.eval(t),
-                currentSubLevel=self.currentSubLevel,
-            ))
-
-        # I think this is slowing effecteval. Could we cache results
-        # that we know haven't changed, like if a curve returns 0
-        # again, we can skip an eval() call on the line that uses it
-
-        for c in self.codes:
-            codeNs = ns.copy()
-            codeNs.update(c.pyResources)
-            try:
-                lineOut = eval(c.expr, codeNs)
-            except Exception as e:
-                e.expr = c.expr
-                raise e
-            ns[c.outName] = lineOut
-        if 'out' not in ns:
-            log.error("ran code for %s, didn't make an 'out' value", self.uri)
-        return ns['out']
--- a/light9/effecteval/effectloop.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,322 +0,0 @@
-import time, logging, traceback
-
-from rdflib import URIRef
-from twisted.internet import reactor
-from twisted.internet.defer import inlineCallbacks, returnValue, succeed
-from twisted.internet.error import TimeoutError
-import numpy
-import serial
-import treq
-
-from light9 import Effects
-from light9 import Submaster
-from light9 import dmxclient
-from light9 import networking
-from light9.effecteval.effect import EffectNode
-from light9.namespaces import L9, RDF
-from light9.metrics import metrics
-
-log = logging.getLogger('effectloop')
-
-
-class EffectLoop:
-    """maintains a collection of the current EffectNodes, gets time from
-    music player, sends dmx"""
-
-    def __init__(self, graph):
-        self.graph = graph
-        self.currentSong = None
-        self.currentEffects = [
-        ]  # EffectNodes for the current song plus the submaster ones
-        self.lastLogTime = 0
-        self.lastLogMsg = ""
-        self.lastErrorLog = 0
-        self.graph.addHandler(self.setEffects)
-        self.period = 1 / 30
-        self.coastSecs = .3  # main reason to keep this low is to notice play/pause
-        self.songTimeFetch = 0
-        self.songIsPlaying = False
-        self.songTimeFromRequest = 0
-        self.requestTime = 0  # unix sec for when we fetched songTime
-        self.initOutput()
-
-    def initOutput(self):
-        pass
-
-    def startLoop(self):
-        log.info("startLoop")
-        self.lastSendLevelsTime = 0
-        reactor.callLater(self.period, self.sendLevels)
-        reactor.callLater(self.period, self.updateTimeFromMusic)
-
-    def setEffects(self):
-        self.currentEffects = []
-        log.info('setEffects currentSong=%s', self.currentSong)
-        if self.currentSong is None:
-            return
-
-        for effectUri in self.graph.objects(self.currentSong, L9['effect']):
-            self.currentEffects.append(EffectNode(self.graph, effectUri))
-
-        for sub in self.graph.subjects(RDF.type, L9['Submaster']):
-            for effectUri in self.graph.objects(sub, L9['drivesEffect']):
-                self.currentEffects.append(EffectNode(self.graph, effectUri))
-
-        log.info('now we have %s effects', len(self.currentEffects))
-
-    @inlineCallbacks
-    def getSongTime(self):
-        now = time.time()
-        old = now - self.requestTime
-        if old > self.coastSecs:
-            try:
-                r = yield treq.get(networking.musicPlayer.path('time'),
-                                   timeout=.5)
-                response = yield r.json_content()
-            except TimeoutError:
-                log.warning("TimeoutError: using stale time from %.1f ago", old)
-            else:
-                self.requestTime = now
-                self.currentPlaying = response['playing']
-                self.songTimeFromRequest = response['t']
-                returnValue((response['t'], (response['song'] and
-                                             URIRef(response['song']))))
-
-        estimated = self.songTimeFromRequest
-        if self.currentSong is not None and self.currentPlaying:
-            estimated += now - self.requestTime
-        returnValue((estimated, self.currentSong))
-
-    @inlineCallbacks
-    def updateTimeFromMusic(self):
-        t1 = time.time()
-        with metrics('get_music').time():
-            self.songTime, song = yield self.getSongTime()
-            self.songTimeFetch = time.time()
-
-        if song != self.currentSong:
-            self.currentSong = song
-            # this may be piling on the handlers
-            self.graph.addHandler(self.setEffects)
-
-        elapsed = time.time() - t1
-        reactor.callLater(max(0, self.period - elapsed),
-                          self.updateTimeFromMusic)
-
-    def estimatedSongTime(self):
-        now = time.time()
-        t = self.songTime
-        if self.currentPlaying:
-            t += max(0, now - self.songTimeFetch)
-        return t
-
-    @inlineCallbacks
-    def sendLevels(self):
-        t1 = time.time()
-        log.debug("time since last call: %.1f ms" %
-                  (1000 * (t1 - self.lastSendLevelsTime)))
-        self.lastSendLevelsTime = t1
-        try:
-            with metrics('send_levels').time():
-                if self.currentSong is not None:
-                    log.debug('allEffectOutputs')
-                    with metrics('evals').time():
-                        outputs = self.allEffectOutputs(
-                            self.estimatedSongTime())
-                    log.debug('combineOutputs')
-                    combined = self.combineOutputs(outputs)
-                    self.logLevels(t1, combined)
-                    log.debug('sendOutput')
-                    with metrics('send_output').time():
-                        yield self.sendOutput(combined)
-
-                elapsed = time.time() - t1
-                dt = max(0, self.period - elapsed)
-        except Exception:
-            metrics('errors').incr()
-            traceback.print_exc()
-            dt = .5
-
-        reactor.callLater(dt, self.sendLevels)
-
-    def combineOutputs(self, outputs):
-        """pick usable effect outputs and reduce them into one for sendOutput"""
-        outputs = [x for x in outputs if isinstance(x, Submaster.Submaster)]
-        out = Submaster.sub_maxes(*outputs)
-
-        return out
-
-    @inlineCallbacks
-    def sendOutput(self, combined):
-        dmx = combined.get_dmx_list()
-        yield dmxclient.outputlevels(dmx, twisted=True)
-
-    def allEffectOutputs(self, songTime):
-        outputs = []
-        for e in self.currentEffects:
-            try:
-                out = e.eval(songTime)
-                if isinstance(out, (list, tuple)):
-                    outputs.extend(out)
-                else:
-                    outputs.append(out)
-            except Exception as exc:
-                now = time.time()
-                if now > self.lastErrorLog + 5:
-                    if hasattr(exc, 'expr'):
-                        log.exception('in expression %r', exc.expr)
-                    log.error("effect %s: %s" % (e.uri, exc))
-                    self.lastErrorLog = now
-        log.debug('eval %s effects, got %s outputs', len(self.currentEffects),
-                  len(outputs))
-
-        return outputs
-
-    def logLevels(self, now, out):
-        # this would look nice on the top of the effecteval web pages too
-        if log.isEnabledFor(logging.DEBUG):
-            log.debug(self.logMessage(out))
-        else:
-            if now > self.lastLogTime + 5:
-                msg = self.logMessage(out)
-                if msg != self.lastLogMsg:
-                    log.info(msg)
-                    self.lastLogMsg = msg
-                self.lastLogTime = now
-
-    def logMessage(self, out):
-        return ("send dmx: {%s}" %
-                ", ".join("%r: %.3g" % (str(k), v)
-                          for k, v in list(out.get_levels().items())))
-
-
-Z = numpy.zeros((50, 3), dtype=numpy.float16)
-
-
-class ControlBoard:
-
-    def __init__(
-            self,
-            dev='/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A7027NYX-if00-port0'
-    ):
-        log.info('opening %s', dev)
-        self._dev = serial.Serial(dev, baudrate=115200)
-
-    def _8bitMessage(self, floatArray):
-        px255 = (numpy.clip(floatArray, 0, 1) * 255).astype(numpy.uint8)
-        return px255.reshape((-1,)).tostring()
-
-    def setStrip(self, which, pixels):
-        """
-        which: 0 or 1 to pick the strip
-        pixels: (50, 3) array of 0..1 floats
-        """
-        command = {0: '\x00', 1: '\x01'}[which]
-        if pixels.shape != (50, 3):
-            raise ValueError("pixels was %s" % pixels.shape)
-        self._dev.write('\x60' + command + self._8bitMessage(pixels))
-        self._dev.flush()
-
-    def setUv(self, which, level):
-        """
-        which: 0 or 1
-        level: 0 to 1
-        """
-        command = {0: '\x02', 1: '\x03'}[which]
-        self._dev.write('\x60' + command +
-                        chr(int(max(0, min(1, level)) * 255)))
-        self._dev.flush()
-
-    def setRgb(self, color):
-        """
-        color: (1, 3) array of 0..1 floats
-        """
-        if color.shape != (1, 3):
-            raise ValueError("color was %s" % color.shape)
-        self._dev.write('\x60\x04' + self._8bitMessage(color))
-        self._dev.flush()
-
-
-class LedLoop(EffectLoop):
-
-    def initOutput(self):
-        self.board = ControlBoard()
-        self.lastSent = {}  # what's in arduino's memory
-
-    def combineOutputs(self, outputs):
-        combined = {
-            'L': Z,
-            'R': Z,
-            'blacklight0': 0,
-            'blacklight1': 0,
-            'W': numpy.zeros((1, 3), dtype=numpy.float16)
-        }
-
-        for out in outputs:
-            log.debug('combine output %r', out)
-
-            # workaround- somehow these subs that drive fx aren't
-            # sending their fx during playback (KC only), so we react
-            # to the sub itself
-            if isinstance(out, Submaster.Submaster) and '*' in out.name:
-                level = float(out.name.split('*')[1])
-                n = out.name.split('*')[0]
-                if n == 'widered':
-                    out = Effects.Strip.solid('LRW', (1, 0, 0)) * level
-                if n == 'widegreen':
-                    out = Effects.Strip.solid('LRW', (0, 1, 0)) * level
-                if n == 'wideblue':
-                    out = Effects.Strip.solid('LRW', (0, 0, 1)) * level
-                if n == 'whiteled':
-                    out = Effects.Strip.solid('LRW', (1, .7, .7)) * level
-                if n == 'blacklight':
-                    out = Effects.Blacklight(level)  # missing blues!
-
-            if isinstance(out, Effects.Blacklight):
-                # no picking yet
-                #key = 'blacklight%s' % out.which
-                for key in ['blacklight0', 'blacklight1']:
-                    combined[key] = max(combined[key], out)
-            elif isinstance(out, Effects.Strip):
-                pixels = numpy.array(out.pixels, dtype=numpy.float16)
-                for w in out.which:
-                    combined[w] = numpy.maximum(
-                        combined[w], pixels[:1, :] if w == 'W' else pixels)
-
-        return combined
-
-    @inlineCallbacks
-    def sendOutput(self, combined):
-        for meth, selectArgs, value in [
-            ('setStrip', (0,), combined['L']),
-            ('setStrip', (1,), combined['R']),
-            ('setUv', (0,), combined['blacklight0']),
-            ('setUv', (1,), combined['blacklight1']),
-            ('setRgb', (), combined['W']),
-        ]:
-            key = (meth, selectArgs)
-            compValue = value.tolist() if isinstance(value,
-                                                     numpy.ndarray) else value
-
-            if self.lastSent.get(key) == compValue:
-                continue
-
-            log.debug('value changed: %s(%s %s)', meth, selectArgs, value)
-
-            getattr(self.board, meth)(*(selectArgs + (value,)))
-            self.lastSent[key] = compValue
-
-        yield succeed(None)  # there was an attempt at an async send
-
-    def logMessage(self, out):
-        return str([(w, p.tolist() if isinstance(p, numpy.ndarray) else p)
-                    for w, p in list(out.items())])
-
-
-def makeEffectLoop(graph, outputWhere):
-    if outputWhere == 'dmx':
-        return EffectLoop(graph)
-    elif outputWhere == 'leds':
-        return LedLoop(graph)
-    else:
-        raise NotImplementedError("unknown output system %r" % outputWhere)
--- a/light9/effecteval/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>effecteval</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="/style.css">
-    <link rel="import" href="effect-components.html">
-  </head>
-  <body>
-    <div id="status">starting...</div>
-    <h1>Effect instances <a href="stats/">[stats]</a></h1>
-    <div><a href=".">View all songs</a></div>
-    <!-- subscribe to a query of all effects and their songs -->
-    <song-effect-list></song-effect-list>
-  </body>
-</html>
--- a/light9/effecteval/test_effect.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-import unittest
-import mock
-import sys
-sys.path.insert(0, 'bin')  # for run_local
-
-from .effect import CodeLine
-from rdflib import URIRef
-
-
-def isCurve(self, uri):
-    return 'curve' in uri
-
-
-def isSub(self, uri):
-    return 'sub' in uri
-
-
-@mock.patch('light9.effecteval.effect.CodeLine._uriIsCurve', new=isCurve)
-@mock.patch('light9.effecteval.effect.CodeLine._uriIsSub', new=isSub)
-@mock.patch('light9.effecteval.effect.CodeLine._resourcesAsPython',
-            new=lambda self, r: self.expr)
-class TestAsPython(unittest.TestCase):
-
-    def test_gets_lname(self):
-        ec = CodeLine(graph=None, code='x = y+1')
-        self.assertEqual('x', ec.outName)
-
-    def test_gets_simple_code(self):
-        ec = CodeLine(graph=None, code='x = y+1')
-        self.assertEqual('y+1', ec._asPython()[2])
-        self.assertEqual({}, ec._asPython()[3])
-
-    def test_converts_uri_to_var(self):
-        ec = CodeLine(graph=None, code='x = <http://example.com/>')
-        _, inExpr, expr, uris = ec._asPython()
-        self.assertEqual('_res0', expr)
-        self.assertEqual({'_res0': URIRef('http://example.com/')}, uris)
-
-    def test_converts_multiple_uris(self):
-        ec = CodeLine(graph=None,
-                      code='x = <http://example.com/> + <http://other>')
-        _, inExpr, expr, uris = ec._asPython()
-        self.assertEqual('_res0 + _res1', expr)
-        self.assertEqual(
-            {
-                '_res0': URIRef('http://example.com/'),
-                '_res1': URIRef('http://other')
-            }, uris)
-
-    def test_doesnt_fall_for_brackets(self):
-        ec = CodeLine(graph=None, code='x = 1<2>3< h')
-        _, inExpr, expr, uris = ec._asPython()
-        self.assertEqual('1<2>3< h', expr)
-        self.assertEqual({}, uris)
-
-    def test_curve_uri_expands_to_curve_eval_func(self):
-        ec = CodeLine(graph=None, code='x = <http://example/curve1>')
-        _, inExpr, expr, uris = ec._asPython()
-        self.assertEqual('curve(_res0, t)', expr)
-        self.assertEqual({'_res0': URIRef('http://example/curve1')}, uris)
-
-    def test_curve_doesnt_double_wrap(self):
-        ec = CodeLine(graph=None,
-                      code='x = curve(<http://example/curve1>, t+.01)')
-        _, inExpr, expr, uris = ec._asPython()
-        self.assertEqual('curve(_res0, t+.01)', expr)
-        self.assertEqual({'_res0': URIRef('http://example/curve1')}, uris)
-
-
-@mock.patch('light9.effecteval.effect.CodeLine._uriIsCurve', new=isCurve)
-@mock.patch('light9.effecteval.effect.CodeLine._resourcesAsPython',
-            new=lambda self, r: self.expr)
-class TestPossibleVars(unittest.TestCase):
-
-    def test1(self):
-        self.assertEqual(set([]), CodeLine(None, 'a1 = 1').possibleVars)
-
-    def test2(self):
-        self.assertEqual({'a2'}, CodeLine(None, 'a1 = a2').possibleVars)
-
-    def test3(self):
-        self.assertEqual({'a2', 'a3'},
-                         CodeLine(None, 'a1 = a2 + a3').possibleVars)
--- a/light9/gtkpyconsole.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-from lib.ipython_view import IPythonView
-import gi  # noqa
-from gi.repository import Gtk
-from gi.repository import Pango
-
-
-def togglePyConsole(self, item, user_ns):
-    """
-    toggles a toplevel window with an ipython console inside.
-
-    self is an object we can stick the pythonWindow attribute on
-
-    item is a checkedmenuitem
-
-    user_ns is a dict you want to appear as locals in the console
-    """
-    if item.get_active():
-        if not hasattr(self, 'pythonWindow'):
-            self.pythonWindow = Gtk.Window()
-            S = Gtk.ScrolledWindow()
-            S.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
-            V = IPythonView(user_ns=user_ns)
-            V.modify_font(Pango.FontDescription("luxi mono 8"))
-            V.set_wrap_mode(Gtk.WrapMode.CHAR)
-            S.add(V)
-            self.pythonWindow.add(S)
-            self.pythonWindow.show_all()
-            self.pythonWindow.set_size_request(750, 550)
-            self.pythonWindow.set_resizable(True)
-
-            def onDestroy(*args):
-                item.set_active(False)
-                del self.pythonWindow
-
-            self.pythonWindow.connect("destroy", onDestroy)
-    else:
-        if hasattr(self, 'pythonWindow'):
-            self.pythonWindow.destroy()
--- a/light9/homepage/write_config.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-'''
-
- goal (everything under localhost:8200):
-   /                          light9/web/index.html
-   /effects                   light9/web/effects/index.html
-   /collector/                light9/web/collector/index.html
-   /show/dance2023/URI        light9/show/dance2023/URI
-   /service/collector/        localhost:8302
-   /service/collector/metrics localhost:8302/metrics
-'''
-import sys
-from pathlib import Path
-from urllib.parse import urlparse
-
-from light9 import showconfig
-from light9.namespaces import L9
-from light9.run_local import log
-
-
-def main():
-    [outPath] = sys.argv[1:]
-
-    log.info(f'writing nginx config to {outPath}')
-    graph = showconfig.getGraph()
-    netHome = graph.value(showconfig.showUri(), L9['networking'])
-    webServer = graph.value(netHome, L9['webServer'])
-    if not webServer:
-        raise ValueError('no %r :webServer' % netHome)
-    mime_types = Path(__file__).parent.parent / 'web/mime.types'
-    nginx_port = urlparse(str(webServer)).port
-    with open(outPath, 'wt') as out:
-        print(f'''
-worker_processes 1;
-
-daemon off;
-error_log /tmp/light9_homepage.err;
-pid /dev/null;
-
-events {{
-  worker_connections 1024;
-}}
-
-http {{
- include {mime_types};
-
-  proxy_buffering off;
-  proxy_http_version 1.1;
-
-  # for websocket
-  proxy_set_header Upgrade $http_upgrade;
-  proxy_set_header Connection "upgrade";
-
- server {{
-  listen {nginx_port};
-  access_log off;
-  autoindex on;''',
-              file=out)
-
-        for role, server in sorted(graph.predicate_objects(netHome)):
-            if not str(server).startswith('http') or role == L9['webServer']:
-                continue
-            path = graph.value(role, L9['urlPath'])
-            if not path:
-                continue
-            server = str(server).rstrip('/')
-            print(f'''
-  location = /{path} {{ rewrite (.*) $1/ permanent; }}
-  location /service/{path}/ {{
-    rewrite ^/service/{path}(/.*) $1 break;
-    proxy_pass {server};
-  }}''',
-                  file=out)
-
-        showPath = showconfig.showUri().split('/', 3)[-1]
-        root = showconfig.root()[:-len(showPath)].decode('ascii')
-        print(f'''
-  location /show/ {{
-    root {root};
-  }}
-
-  location / {{
-    proxy_pass http://localhost:8300;
-  }}
- }}
-}}''', file=out)
-
-
-if __name__ == '__main__':
-    main()
\ No newline at end of file
--- a/light9/homepage/write_config_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-def test_import():
-    import write_config
-    # no crash
--- a/light9/io/Makefile	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-LIB=/usr/local/lib
-INC=-I/usr/local/include/python2.3
-
-go: _parport.so _serport.so
-	result="your modules and links are now up to date"
-
-_parport.so: parport_wrap.c
-	gcc -shared -g ${INC} parport_wrap.c parport.c -o _parport.so 
-
-parport_wrap.c: parport.c parport.i
-	swig -python parport.i
-
-_serport.so: serport_wrap.c
-	gcc -shared -O ${INC} serport_wrap.c -o _serport.so 
-
-serport_wrap.c: serport.i
-	swig -python serport.i
-
-clean:
-	rm -f parport_wrap.c serport_wrap.c *.o *.so
--- a/light9/io/__init__.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-import sys
-
-
-class BaseIO(object):
-
-    def __init__(self):
-        self.dummy = 1
-        self.__name__ = 'BaseIO'
-        # please override and set __name__ to your class name
-
-    def golive(self):
-        """call this if you want to promote the dummy object becomes a live object"""
-        print("IO: %s is going live" % self.__name__)
-        self.dummy = 0
-        # you'd override with additional startup stuff here,
-        # perhaps even loading a module and saving it to a class
-        # attr so the subclass-specific functions can use it
-
-    def godummy(self):
-        print("IO: %s is going dummy" % self.__name__)
-        self.dummy = 1
-        # you might override this to close ports, etc
-
-    def isdummy(self):
-        return self.dummy
-
-    def __repr__(self):
-        if self.dummy:
-            return "<dummy %s instance>" % self.__name__
-        else:
-            return "<live %s instance>" % self.__name__
-
-    # the derived class will have more methods to do whatever it does,
-    # and they should return dummy values if self.dummy==1.
-
-
-class ParportDMX(BaseIO):
-
-    def __init__(self, dimmers=68):
-        BaseIO.__init__(self)
-        self.__name__ = 'ParportDMX'
-        self.dimmers = dimmers
-
-    def golive(self):
-        BaseIO.golive(self)
-        from . import parport
-        self.parport = parport
-        self.parport.getparport()
-
-    def sendlevels(self, levels):
-        if self.dummy:
-            return
-
-        levels = list(levels) + [0]
-        # if levels[14] > 0: levels[14] = 100 # non-dim
-        self.parport.outstart()
-        for p in range(1, self.dimmers + 2):
-            self.parport.outbyte(levels[p - 1] * 255 / 100)
-
-
-class UsbDMX(BaseIO):
-
-    def __init__(self, dimmers=72, port='/dev/dmx0'):
-        BaseIO.__init__(self)
-        self.__name__ = "UsbDMX"
-        self.port = port
-        self.out = None
-        self.dimmers = dimmers
-
-    def _dmx(self):
-        if self.out is None:
-            if self.port == 'udmx':
-                from .udmx import Udmx
-                self.out = Udmx()
-                self.out.write = self.out.SendDMX
-            else:
-                sys.path.append("dmx_usb_module")
-                from dmx import Dmx
-                self.out = Dmx(self.port)
-        return self.out
-
-    def sendlevels(self, levels):
-        if self.dummy:
-            return
-        # I was outputting on 76 and it was turning on the light at
-        # dmx75. So I added the 0 byte.
-        packet = '\x00' + ''.join([chr(int(lev * 255 / 100)) for lev in levels
-                                  ]) + "\x55"
-        self._dmx().write(packet)
--- a/light9/io/motordrive	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,144 +0,0 @@
-#!/usr/bin/python
-
-from __future__ import division
-from twisted.internet import reactor, tksupport
-import Tkinter as tk
-import time, atexit
-from louie import dispatcher
-import parport
-parport.getparport()
-
-
-class Ctl:
-    def __init__(self):
-        self.blade = False
-        self.xpos = 0
-        self.ypos = 0
-
-        dispatcher.connect(self.dragTo, "dragto")
-        self.path = [] # future points to walk to
-        self.lastByteTime = 0
-
-    def dragTo(self, x, y):
-        self.path.append((x,y))
-        #print "drag to", x, y, len(self.path)
-        dispatcher.send("new path", path=self.path)
-
-    def step(self):
-        start = time.time()
-        while time.time() - start < .05:
-            self._step()
-
-    def _step(self):
-        if not self.path:
-            return
-        goal = self.path[0]
-        if (self.xpos, self.ypos) == goal:
-            self.path.pop(0)
-            dispatcher.send("new path", path=self.path)
-            return
-        self.move(cmp(goal[0], self.xpos),
-                  cmp(goal[1], self.ypos))
-
-    def move(self, dx, dy):
-        self.xpos += dx
-        self.ypos += dy
-        dispatcher.send("coords", x=self.xpos, y=self.ypos)
-        #print "x=%d y=%d" % (self.xpos, self.ypos)
-        self.update()
-
-    def update(self):
-        byte = 0
-        if self.blade:
-            byte |= 0x80
-
-        byte |= (0x01, 0x03, 0x02, 0x00)[self.xpos % 4] * 0x20
-        byte |= (0x01, 0x03, 0x02, 0x00)[self.ypos % 4] * 0x04
-
-        byte |= 0x01 # power pin
-        byte |= 0x02 | 0x10 # enable dirs
-
-        now = time.time()
-        print "%.1fms delay between bytes" % ((now - self.lastByteTime) * 1000)
-        self.out(byte)
-        self.lastByteTime = now
-        
-    def out(self, byte):
-        #print hex(byte)
-        parport.outdata(byte)
-        time.sleep(.003)
-
-    def toggleBlade(self):
-        self.blade = not self.blade
-        if self.blade:
-            # blade needs full power to go down
-            self.out(0x80)
-            time.sleep(.05)
-        self.update()
-
-class Canv(tk.Canvas):
-    def __init__(self, master, **kw):
-        tk.Canvas.__init__(self, master, **kw)
-        self.create_line(0,0,0,0, tags='cursorx')
-        self.create_line(0,0,0,0, tags='cursory')
-        dispatcher.connect(self.updateCursor, "coords")
-        dispatcher.connect(self.drawPath, "new path")
-        self.bind("<B1-Motion>", self.b1motion)
-
-    def canFromWorld(self, wx, wy):
-        return -wx / 5 + 300, wy / 5 + 300
-
-    def worldFromCan(self, cx, cy):
-        return -(cx - 300) * 5, (cy - 300) * 5
-
-    def updateCursor(self, x, y):
-        x,y = self.canFromWorld(x, y)
-        self.coords('cursorx', x-10, y, x+10, y)
-        self.coords('cursory', x, y-10, x, y+10)
-
-    def b1motion(self, ev):
-        wx,wy = self.worldFromCan(ev.x, ev.y)
-        dispatcher.send("dragto", x=wx, y=wy)
-
-    def drawPath(self, path):
-        self.delete('path')
-        pts = []
-        for pt in path:
-            pts.extend(self.canFromWorld(*pt))
-        if len(pts) >= 4:
-            self.create_line(*pts, **dict(tag='path'))
-        
-root = tk.Tk()
-
-
-ctl = Ctl()
-
-can = Canv(root, width=900, height=900)
-can.pack()
-
-for key, byte in [
-    ('0', 0),
-    ]:
-    root.bind("<Key-%s>" % key, lambda ev, byte=byte: ctl.out(byte))
-
-for key, xy in [('Left', (-1, 0)),
-                ('Right', (1, 0)),
-                ('Up', (0, -1)),
-                ('Down', (0, 1))]:
-    root.bind("<Key-%s>" % key, lambda ev, xy=xy: ctl.move(*xy))
-
-root.bind("<Key-space>", lambda ev: ctl.toggleBlade())
-
-ctl.move(0,0)
-
-atexit.register(lambda: ctl.out(0))
-
-def loop():
-    ctl.step()
-    root.after(10, loop)
-loop()
-
-tksupport.install(root, ms=5)
-root.protocol('WM_DELETE_WINDOW', reactor.stop)
-reactor.run()
-
--- a/light9/io/parport.c	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-#include <stdio.h>
-#include <stdlib.h>
-#include <unistd.h>
-#include <sys/ioctl.h>
-#include <asm/io.h>
-#include <fcntl.h>
-#include <Python.h>
-
-int getparport() {
-  printf("parport - ver 4\n");
-    if( ioperm(888,3,1) ) {
-      printf("Couldn't get parallel port at 888-890\n");
-
-      // the following doesn't have any effect!
-      PyErr_SetString(PyExc_IOError,"Couldn't get parallel port at 888-890");
-      return 0;
-    } 
-    return 1;
-}
-
-void outdata(unsigned char val) {
-  outb(val,888);
-}
-
-void outcontrol( unsigned char val ) {
-  outb(val,890);
-}
-
-void outbyte( unsigned char val ) {
-  int i;
-  // set data, raise clock, lower clock
-  outdata(val);
-
-  /* this was originally 26 outcontrol calls, but on new dash that
-     leads to screwed up dmx about once a minute. I tried doing 26*4
-     outcontrol calls, but it still screwed up. I suspect the athlon64
-     or my new kernel version is sending the parport really fast,
-     sometimes faster than the pic sees the bits. Then I put a 1ms
-     sleep after the outcontrol(2)'s and that didn't help either, so
-     I'm not sure what's going on. Putting the parallel cable on miles
-     seems to work. 
-
-     todo:
-     try a little pause after outcontrol(3) to make sure pic sees that
-  */
-
-  for (i=0; i<26*4; i++) {
-    outcontrol(2);
-  }
-  outcontrol(3);
-}
-void outstart() {
-  // send start code: pin 14 high, 5ms to let a dmx cycle finish,
-  // then pin14 low (pin1 stays low)
-  outcontrol(1);
-  usleep(5000);
-  outcontrol(3);
-}
--- a/light9/io/parport.i	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-%module parport
-
-
-extern void getparport();
-extern void outdata( unsigned char val);
-extern void outcontrol( unsigned char val );
-extern void outbyte( unsigned char val );
-extern void outstart();
-
-
-
--- a/light9/io/serport.i	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,50 +0,0 @@
-%module serport
-
-%{
-#include <sys/types.h>
-#include <sys/stat.h>
-#include <sys/ioctl.h>
-#include <fcntl.h>
-#include <linux/i2c.h>
-#include <linux/i2c-dev.h>
-#include <unistd.h>
-%}
-
-
-%typemap(python,in) __u8 {
-  if( !PyInt_Check($input)) {
-    PyErr_SetString(PyExc_TypeError,"not an integer");
-    return NULL;
-  }
-  $1 = ($type) PyInt_AsLong($input);
-}
-
-%typemap(python,out) __s32 {
-  $result = Py_BuildValue("i", ($type) $1);
-}
-
-%inline %{
-
-  __s32 i2c_smbus_write_byte(int file, __u8 value);
-  __s32 i2c_smbus_read_byte(int file);
-
-  PyObject *read_all_adc(int file) {
-    PyObject *t=PyTuple_New(4);
-    
-    #define CHAN_TO_TUPLE_POS(chan,idx) i2c_smbus_write_byte(file, chan);\
-    PyTuple_SetItem(t,idx,PyInt_FromLong(i2c_smbus_read_byte(file)));
-
-    /*
-      these are shuffled here to match the way the pots read in. in
-      the returned tuple, 0=left pot..3=right pot.
-    */
-    CHAN_TO_TUPLE_POS(1,0)
-    CHAN_TO_TUPLE_POS(2,1)
-    CHAN_TO_TUPLE_POS(3,2)
-    CHAN_TO_TUPLE_POS(0,3)
-      
-    return t;
-
-  }
-
-%}
--- a/light9/io/udmx.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-import logging
-import usb.core
-from usb.util import CTRL_TYPE_VENDOR, CTRL_RECIPIENT_DEVICE, CTRL_OUT
-
-log = logging.getLogger('udmx')
-"""
-Send dmx to one of these:
-http://www.amazon.com/Interface-Adapter-Controller-Lighting-Freestyler/dp/B00W52VIOS
-
-[4520784.059479] usb 1-2.3: new low-speed USB device number 6 using xhci_hcd
-[4520784.157410] usb 1-2.3: New USB device found, idVendor=16c0, idProduct=05dc
-[4520784.157416] usb 1-2.3: New USB device strings: Mfr=1, Product=2, SerialNumber=3
-[4520784.157419] usb 1-2.3: Product: uDMX
-[4520784.157422] usb 1-2.3: Manufacturer: www.anyma.ch
-[4520784.157424] usb 1-2.3: SerialNumber: ilLUTZminator001
-
-See https://www.illutzmination.de/udmxfirmware.html?&L=1
-    sources/commandline/uDMX.c
-or https://github.com/markusb/uDMX-linux/blob/master/uDMX.c
-"""
-
-cmd_SetChannelRange = 0x0002
-
-
-class Udmx:
-
-    def __init__(self, bus):
-        self.dev = None
-        for dev in usb.core.find(idVendor=0x16c0,
-                                 idProduct=0x05dc,
-                                 find_all=True):
-            print("udmx device at %r" % dev.bus)
-            if bus is None or bus == dev.bus:
-                self.dev = dev
-        if not self.dev:
-            raise IOError('no matching udmx device found for requested bus %r' %
-                          bus)
-        log.info('found udmx at %r', self.dev)
-
-    def SendDMX(self, buf):
-        ret = self.dev.ctrl_transfer(bmRequestType=CTRL_TYPE_VENDOR |
-                                     CTRL_RECIPIENT_DEVICE | CTRL_OUT,
-                                     bRequest=cmd_SetChannelRange,
-                                     wValue=len(buf),
-                                     wIndex=0,
-                                     data_or_wLength=buf)
-        if ret < 0:
-            raise ValueError("ctrl_transfer returned %r" % ret)
-
-
-def demo(chan, fps=44):
-    import time, math
-    u = Udmx()
-    while True:
-        nsin = math.sin(time.time() * 6.28) / 2.0 + 0.5
-        nsin8 = int(255 * nsin)
-        try:
-            u.SendDMX('\x00' * (chan - 1) + chr(210) + chr(nsin8) + chr(nsin8) +
-                      chr(nsin8))
-        except usb.core.USBError as e:
-            print("err", time.time(), repr(e))
-        time.sleep(1 / fps)
--- a/light9/localsyncedgraph.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-from rdflib import ConjunctiveGraph
-
-from rdfdb.syncedgraph.currentstategraphapi import CurrentStateGraphApi
-from rdfdb.syncedgraph.autodepgraphapi import AutoDepGraphApi
-from rdfdb.syncedgraph.grapheditapi import GraphEditApi
-from rdfdb.rdflibpatch import patchQuads
-
-
-class LocalSyncedGraph(AutoDepGraphApi, GraphEditApi):
-    """for tests"""
-
-    def __init__(self, files=None):
-        self._graph = ConjunctiveGraph()
-        for f in files or []:
-            self._graph.parse(f, format='n3')
-
-    def patch(self, p):
-        patchQuads(self._graph, deleteQuads=p.delQuads, addQuads=p.addQuads, perfect=True)
-        # no deps
--- a/light9/metrics.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,120 +0,0 @@
-"""for easier porting, and less boilerplate, allow these styles using the
-form of the call to set up the right type of metric automatically:
-
-  from metrics import metrics
-  metrics.setProcess('pretty_name')
-
-  @metrics('loop').time()              # a common one to get the fps of each service. Gets us qty and time
-  def frame():
-      if err:
-         metrics('foo_errors').incr()  # if you incr it, it's a counter
-
-  @metrics('foo_calls').time()         # qty & time because it's a decorator
-  def foo(): 
-
-  metrics('goal_fps').set(f)           # a gauge because we called set()
-
-  with metrics('recompute'): ...       # ctxmgr also makes a timer
-     time_this_part()
-
-I don't see a need for labels yet, but maybe some code will want like
-metrics('foo', label1=one). Need histogram? Info?
-
-"""
-from typing import Dict, Tuple, Callable, Type, TypeVar, cast
-from prometheus_client import Counter, Gauge, Metric, Summary
-from prometheus_client.exposition import generate_latest
-from prometheus_client.registry import REGISTRY
-
-_created: Dict[str, Metric] = {}
-
-# _process=sys.argv[0]
-# def setProcess(name: str):
-#   global _process
-#   _process = name
-
-MT = TypeVar("MT")
-
-
-class _MetricsRequest:
-
-    def __init__(self, name: str, **labels):
-        self.name = name
-        self.labels = labels
-
-    def _ensure(self, cls: Type[MT]) -> MT:
-        if self.name not in _created:
-            _created[self.name] = cls(name=self.name, documentation=self.name, labelnames=self.labels.keys())
-        m = _created[self.name]
-        if self.labels:
-            m = m.labels(**self.labels)
-        return m
-
-    def __call__(self, fn) -> Callable:
-        return timed_fn
-
-    def set(self, v: float):
-        self._ensure(Gauge).set(v)
-
-    def inc(self):
-        self._ensure(Counter).inc()
-
-    def offset(self, amount: float):
-        self._ensure(Gauge).inc(amount)
-
-    def time(self):
-        return self._ensure(Summary).time()
-
-    def observe(self, x: float):
-        return self._ensure(Summary).observe(x)
-
-    def __enter__(self):
-        return self._ensure(Summary).__enter__()
-
-
-def metrics(name: str, **labels):
-    return _MetricsRequest(name, **labels)
-
-
-
-
-"""
-stuff we used to have in greplin. Might be nice to get (client-side-computed) min/max/stddev back.
-
-class PmfStat(Stat):
-  A stat that stores min, max, mean, standard deviation, and some
-  percentiles for arbitrary floating-point data. This is potentially a
-  bit expensive, so its child values are only updated once every
-  twenty seconds.
-
-
-
-
-
-i think prometheus covers this one:
-
-import psutil
-def gatherProcessStats():
-    procStats = scales.collection('/process',
-                                  scales.DoubleStat('time'),
-                                  scales.DoubleStat('cpuPercent'),
-                                  scales.DoubleStat('memMb'),
-    )
-    proc = psutil.Process()
-    lastCpu = [0.]
-    def updateTimeStat():
-        now = time.time()
-        procStats.time = round(now, 3)
-        if now - lastCpu[0] > 3:
-            procStats.cpuPercent = round(proc.cpu_percent(), 6) # (since last call)
-            lastCpu[0] = now
-        procStats.memMb = round(proc.memory_info().rss / 1024 / 1024, 6)
-    task.LoopingCall(updateTimeStat).start(.1)
-
-"""
-
-
-class M:
-
-    def __call__(self, name):
-        return
--- a/light9/midifade/midifade.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,240 +0,0 @@
-#!bin/python
-"""
-Read midi events, write fade levels to graph
-"""
-import asyncio
-import logging
-import traceback
-from typing import Dict, List, cast
-from light9.effect.edit import clamp
-
-import mido
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import RDF, ConjunctiveGraph, Literal, URIRef
-from rdfdb.syncedgraph.readonly_graph import ReadOnlyConjunctiveGraph
-from light9 import networking
-from light9.namespaces import L9
-from light9.newtypes import decimalLiteral
-from light9.run_local import log
-from light9.showconfig import showUri
-
-mido.set_backend('alsa_midi.mido_backend')
-MAX_SEND_RATE = 30
-
-_lastSet = {}  #midictlchannel:value7bit
-
-currentFaders = {}  # midi control channel num : FaderUri
-ctx = URIRef(showUri() + '/fade')
-
-
-def compileCurrents(graph):
-    currentFaders.clear()
-    try:
-        new = getChansToFaders(graph)
-    except ValueError:
-        return  # e.g. empty-graph startup
-    currentFaders.update(new)
-
-
-def getGraphMappingNode(g: ReadOnlyConjunctiveGraph | SyncedGraph) -> URIRef:
-    mapping = g.value(L9['midiControl'], L9['map'])
-    if mapping is None:
-        raise ValueError('no :midiControl :map ?mapping')
-    midiDev = g.value(mapping, L9['midiDev'])
-    ourDev = 'bcf2000'
-    if midiDev != Literal(ourDev):
-        raise NotImplementedError(f'need {mapping} to have :midiDev {ourDev!r}')
-    return mapping
-
-
-def getCurMappedPage(g: SyncedGraph):
-    mapping = getGraphMappingNode(g)
-    return g.value(mapping, L9['outputs'])
-
-
-def setCurMappedPage(g: SyncedGraph, mapping: URIRef, newPage: URIRef):
-    g.patchObject(ctx, mapping, L9.outputs, newPage)
-
-
-def getChansToFaders(g: SyncedGraph) -> Dict[int, URIRef]:
-    fadePage = getCurMappedPage(g)
-    ret = []
-    for f in g.objects(fadePage, L9.fader):
-        columnLit = cast(Literal, g.value(f, L9['column']))
-        col = int(columnLit.toPython())
-        ret.append((col, f))
-
-    ret.sort()
-    ctl_channels = list(range(81, 88 + 1))
-    out = {}
-    for chan, (col, f) in zip(ctl_channels, ret):
-        out[chan] = f
-    return out
-
-
-def changePage(g: SyncedGraph, dp: int):
-    """dp==-1, make the previous page active, etc. Writes to graph"""
-
-    with g.currentState() as current:
-        allPages = sorted(current.subjects(RDF.type, L9.FadePage), key=lambda fp: str(fp))
-        mapping = getGraphMappingNode(current)
-        curPage = current.value(mapping, L9.outputs)
-    if curPage is None:
-        curPage = allPages[0]
-    idx = allPages.index(curPage)
-    newIdx = clamp(idx + dp, 0, len(allPages) - 1)
-    print('change from ', idx, newIdx)
-    newPage = allPages[newIdx]
-    setCurMappedPage(g, mapping, newPage)
-
-
-def writeHwValueToGraph(graph: SyncedGraph, ctx, fader: URIRef, strength: float):
-    log.info(f'setFader(fader={fader}, strength={strength:.03f}')
-    valueLit = decimalLiteral(round(strength, 3))
-    with graph.currentState() as g:
-        fadeSet = g.value(fader, L9['setting'])
-    if fadeSet is None:
-        raise ValueError(f'fader {fader} has no :setting')
-    graph.patchObject(ctx, fadeSet, L9['value'], valueLit)
-
-
-def changeGrandMaster(graph: SyncedGraph, newValue: float):
-    graph.patchObject(ctx, L9.grandMaster, L9['value'], decimalLiteral(newValue))
-
-
-def onMessage(graph: SyncedGraph, ctx: URIRef, m: Dict):
-    if m['type'] == 'active_sensing':
-        return
-    if m['type'] == 'control_change':
-        if m['dev'] == 'bcf2000' and m['control'] == 91:
-            changePage(graph, -1)
-            return
-        if m['dev'] == 'bcf2000' and m['control'] == 92:
-            changePage(graph, 1)
-            return
-        if m['dev'] == 'bcf2000' and m['control'] == 8:
-            changeGrandMaster(graph, clamp(m['value'] / 127 * 1.5, 0, 1))
-            return
-
-        try:
-            fader = {
-                'quneo': {
-                    44: L9['show/dance2023/fadePage1f0'],
-                    45: L9['show/dance2023/fadePage1f0'],
-                    46: L9['show/dance2023/fadePage1f0'],
-                },
-                'bcf2000': currentFaders,
-            }[m['dev']][m['control']]
-        except KeyError:
-            log.info(f'unknown control {m}')
-            return
-        try:
-            writeHwValueToGraph(graph, ctx, fader, m['value'] / 127)
-            _lastSet[m['control']] = m['value']
-        except ValueError as e:
-            log.warning(f'{e!r} - ignoring')
-    else:
-        log.info(f'unhandled message {m}')
-
-
-def reduceToLatestValue(ms: List[Dict]) -> List[Dict]:
-    merge = {}
-    for m in ms:
-        normal_key = tuple(sorted(dict((k, v) for k, v in m.items() if k != 'value')))
-        merge[normal_key] = m
-    return merge.values()
-
-
-class WriteBackFaders:
-
-    def __init__(self, graph: SyncedGraph, bcf_out, getCurrentValue):
-        self.graph = graph
-        self.bcf_out = bcf_out
-        self.getCurrentValue = getCurrentValue
-
-    def update(self):
-        try:
-            self._update()
-        except ValueError as e:
-            log.warning(repr(e))
-
-    def _update(self):
-        g = self.graph
-        nupdated = 0
-        m = getChansToFaders(g)
-        for midi_ctl_addr, f in m.items():
-            fset = g.value(f, L9.setting)
-            # could split this to a separate handler per fader
-            value = g.value(fset, L9.value).toPython()
-            hwcurrent = self.getCurrentValue(midi_ctl_addr)
-            hwgoal = int(value * 127)
-            print(f'{f} {hwcurrent=} {hwgoal=}')
-            if abs(hwcurrent - hwgoal) > 2:
-                self.sendToBcf(midi_ctl_addr, hwgoal)
-                nupdated += 1
-        log.info(f'wrote to {nupdated} of {len(m)} mapped faders')
-
-    def sendToBcf(self, control, value):
-        _lastSet[control] = value
-        msg = mido.Message('control_change', control=control, value=value)
-        self.bcf_out.send(msg)
-
-
-async def main():
-    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
-    logging.getLogger('syncedgraph').setLevel(logging.INFO)
-    logging.getLogger('graphedit').setLevel(logging.INFO)
-
-    graph = SyncedGraph(networking.rdfdb.url, "midifade")
-    ctx = URIRef(showUri() + '/fade')
-
-    msgs = asyncio.Queue()
-    loop = asyncio.get_event_loop()
-
-    def onMessageMidoThread(dev, message):
-        loop.call_soon_threadsafe(msgs.put_nowait, message.dict() | {'dev': dev})
-
-    async def reader():
-        while True:
-            recents = [await msgs.get()]
-            while not msgs.empty():
-                recents.append(msgs.get_nowait())
-            try:
-                for msg in reduceToLatestValue(recents):
-                    onMessage(graph, ctx, msg)
-            except Exception as e:
-                traceback.print_exc()
-                log.warning("error in onMessage- continuing anyway")
-            await asyncio.sleep(1 / MAX_SEND_RATE)
-
-    asyncio.create_task(reader())
-    openPorts = []
-    for inputName in mido.get_input_names():  # type: ignore
-        if inputName.startswith('Keystation'):
-            dev = "keystation"
-        elif inputName.startswith('BCF2000'):
-            dev = 'bcf2000'
-        elif inputName.startswith('QUNEO'):
-            dev = 'quneo'
-        else:
-            continue
-        log.info(f'listening on input {inputName} {dev=}')
-        openPorts.append(mido.open_input(  # type: ignore
-            inputName,  #
-            callback=lambda message, dev=dev: onMessageMidoThread(dev, message)))
-
-    graph.addHandler(lambda: compileCurrents(graph))
-
-    for outputName in mido.get_output_names():  # type: ignore
-        if outputName.startswith('BCF2000'):
-            bcf_out = mido.open_output(outputName)  # type: ignore
-            wb = WriteBackFaders(graph, bcf_out, getCurrentValue=lambda f: _lastSet.get(f, 0))
-            graph.addHandler(wb.update)
-            break
-
-    while True:
-        await asyncio.sleep(1)
-
-
-if __name__ == '__main__':
-    asyncio.run(main())
--- a/light9/midifade/midifade_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-from light9.run_local import log
-
-
-def test_import():
-    import light9.midifade.midifade
--- a/light9/mock_syncedgraph.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,59 +0,0 @@
-from rdflib import Graph, RDF, RDFS
-from rdflib.parser import StringInputSource
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-
-
-class MockSyncedGraph(SyncedGraph):
-    """
-    Lets users of SyncedGraph mostly work. Doesn't yet help with any
-    testing of the rerun-upon-graph-change behavior.
-    """
-
-    def __init__(self, n3Content):
-        self._graph = Graph()
-        self._graph.parse(StringInputSource(n3Content), format='n3')
-
-    def addHandler(self, func):
-        func()
-
-    def value(self,
-              subject=None,
-              predicate=RDF.value,
-              object=None,
-              default=None,
-              any=True):
-        if object is not None:
-            raise NotImplementedError()
-        return self._graph.value(subject,
-                                 predicate,
-                                 object=object,
-                                 default=default,
-                                 any=any)
-
-    def objects(self, subject=None, predicate=None):
-        return self._graph.objects(subject, predicate)
-
-    def label(self, uri):
-        return self.value(uri, RDFS.label)
-
-    def subjects(self, predicate=None, object=None):
-        return self._graph.subjects(predicate, object)
-
-    def predicate_objects(self, subject):
-        return self._graph.predicate_objects(subject)
-
-    def items(self, listUri):
-        """generator. Having a chain of watchers on the results is not
-        well-tested yet"""
-        chain = set([listUri])
-        while listUri:
-            item = self.value(listUri, RDF.first)
-            if item:
-                yield item
-            listUri = self.value(listUri, RDF.rest)
-            if listUri in chain:
-                raise ValueError("List contains a recursive rdf:rest reference")
-            chain.add(listUri)
-
-    def contains(self, triple):
-        return triple in self._graph
--- a/light9/namespaces.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-from rdflib import URIRef, Namespace, RDF, RDFS  # noqa
-from typing import Dict
-
-
-# Namespace was showing up in profiles
-class FastNs:
-
-    def __init__(self, base):
-        self.ns = Namespace(base)
-        self.cache: Dict[str, URIRef] = {}
-
-    def __getitem__(self, term) -> URIRef:
-        if term not in self.cache:
-            self.cache[term] = self.ns[term]
-        return self.cache[term]
-
-    __getattr__ = __getitem__
-
-
-L9 = FastNs("http://light9.bigasterisk.com/")
-FUNC = FastNs("http://light9.bigasterisk.com/effectFunction/")
-MUS = Namespace("http://light9.bigasterisk.com/music/")
-XSD = Namespace("http://www.w3.org/2001/XMLSchema#")
-DCTERMS = Namespace("http://purl.org/dc/terms/")
-DEV = Namespace("http://light9.bigasterisk.com/device/")
--- a/light9/networking.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,61 +0,0 @@
-from urllib.parse import urlparse
-
-from rdflib import URIRef
-
-from .showconfig import getGraph, showUri
-from .namespaces import L9
-
-
-class ServiceAddress:
-
-    def __init__(self, service):
-        self.service = service
-
-    def _url(self) -> URIRef:
-        graph = getGraph()
-        net = graph.value(showUri(), L9['networking'])
-        ret = graph.value(net, self.service)
-        if ret is None:
-            raise ValueError("no url for %s -> %s -> %s" %
-                             (showUri(), L9['networking'], self.service))
-        assert isinstance(ret, URIRef)
-        return ret
-
-    @property
-    def port(self):
-        return urlparse(self._url()).port
-
-    @property
-    def host(self):
-        return urlparse(self._url()).hostname
-
-    @property
-    def url(self) -> URIRef:
-        return self._url()
-
-    value = url
-
-    def path(self, more: str) -> URIRef:
-        return URIRef(self.url + more)
-
-
-captureDevice = ServiceAddress(L9['captureDevice'])
-curveCalc = ServiceAddress(L9['curveCalc'])
-dmxServer = ServiceAddress(L9['dmxServer'])
-dmxServerZmq = ServiceAddress(L9['dmxServerZmq'])
-collector = ServiceAddress(L9['collector'])
-collectorZmq = ServiceAddress(L9['collectorZmq'])
-effectEval = ServiceAddress(L9['effectEval'])
-effectSequencer = ServiceAddress(L9['effectSequencer'])
-keyboardComposer = ServiceAddress(L9['keyboardComposer'])
-musicPlayer = ServiceAddress(L9['musicPlayer'])
-oscDmxServer = ServiceAddress(L9['oscDmxServer'])
-paintServer = ServiceAddress(L9['paintServer'])
-picamserve = ServiceAddress(L9['picamserve'])
-rdfdb = ServiceAddress(L9['rdfdb'])
-subComposer = ServiceAddress(L9['subComposer'])
-subServer = ServiceAddress(L9['subServer'])
-vidref = ServiceAddress(L9['vidref'])
-timeline = ServiceAddress(L9['timeline'])
-
-patchReceiverUpdateHost = ServiceAddress(L9['patchReceiverUpdateHost'])
--- a/light9/newtypes.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,43 +0,0 @@
-from typing import NewType, Tuple, TypeVar, Union
-
-from rdflib import Literal, URIRef
-
-ClientType = NewType('ClientType', str)
-ClientSessionType = NewType('ClientSessionType', str)
-Curve = NewType('Curve', URIRef)
-OutputUri = NewType('OutputUri', URIRef)  # e.g. dmxA
-DeviceUri = NewType('DeviceUri', URIRef)  # e.g. :aura2
-DeviceClass = NewType('DeviceClass', URIRef)  # e.g. :Aura
-DmxIndex = NewType('DmxIndex', int)  # 1..512
-DmxMessageIndex = NewType('DmxMessageIndex', int)  # 0..511
-DeviceAttr = NewType('DeviceAttr', URIRef)  # e.g. :rx
-EffectFunction = NewType('EffectFunction', URIRef)  # e.g. func:strobe
-EffectUri = NewType('EffectUri', URIRef)  # unclear when to use this vs EffectClass
-EffectAttr = NewType('EffectAttr', URIRef)  # e.g. :chaseSpeed
-NoteUri = NewType('NoteUri', URIRef)
-OutputAttr = NewType('OutputAttr', URIRef)  # e.g. :xFine
-OutputValue = NewType('OutputValue', int)  # byte in dmx message
-Song = NewType('Song', URIRef)
-UnixTime = NewType('UnixTime', float)
-
-VT = TypeVar('VT', float, int, str)  # remove
-HexColor = NewType('HexColor', str)
-VTUnion = Union[float, int, HexColor]  # rename to ValueType
-DeviceSetting = Tuple[DeviceUri, DeviceAttr,
-                      # currently, floats and hex color strings
-                      VTUnion]
-
-# Alternate output range for a device. Instead of outputting 0.0 to
-# 1.0, you can map that range into, say, 0.2 to 0.7
-OutputRange = NewType('OutputRange', Tuple[float, float])
-
-
-def uriTail(u: URIRef) -> str:
-    tail = u.rstrip('/').rsplit('/', 1)[1]
-    if not tail:
-        tail = str(u)
-    return tail
-
-
-def decimalLiteral(value):
-    return Literal(value, datatype='http://www.w3.org/2001/XMLSchema#decimal')
--- a/light9/observable.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-import logging
-log = logging.getLogger('observable')
-
-
-class _NoNewVal:
-    pass
-
-
-class Observable:
-    """
-    like knockout's observable. Hopefully this can be replaced by a
-    better python one
-
-    compare with:
-    http://knockoutjs.com/documentation/observables.html
-    https://github.com/drpancake/python-observable/blob/master/observable/observable.py
-    """
-
-    def __init__(self, val):
-        self.val = val
-        self.subscribers = set()
-
-    def __call__(self, newVal=_NoNewVal):
-        if newVal is _NoNewVal:
-            return self.val
-        if newVal == self.val:
-            log.debug("%r unchanged from %r", newVal, self.val)
-            return
-        self.val = newVal
-        for s in self.subscribers:
-            s(newVal)
-
-    def subscribe(self, cb, callNow=True):
-        """cb is called with new values, and also right now with the
-        current value unless you opt out"""
-        self.subscribers.add(cb)
-        if callNow:
-            cb(self.val)
--- a/light9/paint/capture.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,50 +0,0 @@
-import os
-from rdflib import URIRef
-from light9 import showconfig
-from rdfdb.patch import Patch
-from light9.namespaces import L9, RDF
-from light9.paint.solve import loadNumpy
-
-
-def writeCaptureDescription(graph, ctx, session, uri, dev, outPath,
-                            settingsSubgraphCache, settings):
-    graph.patch(
-        Patch(addQuads=settings.statements(
-            uri,
-            ctx=ctx,
-            settingRoot=URIRef('/'.join(
-                [showconfig.showUri(), 'capture',
-                 dev.rsplit('/')[1]])),
-            settingsSubgraphCache=settingsSubgraphCache)))
-    graph.patch(
-        Patch(addQuads=[
-            (dev, L9['capture'], uri, ctx),
-            (session, L9['capture'], uri, ctx),
-            (uri, RDF.type, L9['LightSample'], ctx),
-            (uri, L9['imagePath'],
-             URIRef('/'.join([showconfig.showUri(), outPath])), ctx),
-        ]))
-    graph.suggestPrefixes(
-        ctx, {
-            'cap': uri.rsplit('/', 1)[0] + '/',
-            'showcap': showconfig.showUri() + '/capture/'
-        })
-
-
-class CaptureLoader(object):
-
-    def __init__(self, graph):
-        self.graph = graph
-
-    def loadImage(self, pic, thumb=(100, 100)):
-        ip = self.graph.value(pic, L9['imagePath'])
-        if not ip.startswith(showconfig.show()):
-            raise ValueError(repr(ip))
-        diskPath = os.path.join(showconfig.root(), ip[len(self.show):])
-        return loadNumpy(diskPath, thumb)
-
-    def devices(self):
-        """devices for which we have any captured data"""
-
-    def capturedSettings(self, device):
-        """list of (pic, settings) we know for this device"""
--- a/light9/paint/solve.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,307 +0,0 @@
-from typing import List
-
-from rdflib import URIRef
-import imageio
-from light9.namespaces import L9, DEV
-from PIL import Image
-import numpy
-import scipy.misc, scipy.ndimage, scipy.optimize
-import cairo
-import logging
-
-from light9.effect.settings import DeviceSettings, parseHex
-
-log = logging.getLogger('solve')
-
-# numpy images in this file are (x, y, c) layout.
-
-
-def numpyFromCairo(surface):
-    w, h = surface.get_width(), surface.get_height()
-    a = numpy.frombuffer(surface.get_data(), numpy.uint8)
-    a.shape = h, w, 4
-    a = a.transpose((1, 0, 2))
-    return a[:w, :h, :3]
-
-
-def numpyFromPil(img: Image.Image):
-    return numpy.asarray(img).transpose((1, 0, 2))
-
-
-def loadNumpy(path, thumb=(100, 100)):
-    img = Image.open(path)
-    img.thumbnail(thumb)
-    return numpyFromPil(img)
-
-
-def saveNumpy(path, img):
-    # maybe this should only run if log level is debug?
-    imageio.imwrite(path, img.transpose((1, 0, 2)))
-
-
-def scaledHex(h, scale):
-    rgb = parseHex(h)
-    rgb8 = (rgb * scale).astype(numpy.uint8)
-    return '#%02x%02x%02x' % tuple(rgb8)
-
-
-def colorRatio(col1, col2):
-    rgb1 = parseHex(col1)
-    rgb2 = parseHex(col2)
-
-    def div(x, y):
-        if y == 0:
-            return 0
-        return round(x / y, 3)
-
-    return tuple([div(a, b) for a, b in zip(rgb1, rgb2)])
-
-
-def brightest(img):
-    return numpy.amax(img, axis=(0, 1))
-
-
-class ImageDist(object):
-
-    def __init__(self, img1):
-        self.a = img1.reshape((-1,))
-        self.d = 255 * 255 * self.a.shape[0]
-
-    def distanceTo(self, img2):
-        b = img2.reshape((-1,))
-        return 1 - numpy.dot(self.a, b) / self.d
-
-
-class ImageDistAbs(object):
-
-    def __init__(self, img1):
-        self.a = img1
-        self.maxDist = img1.shape[0] * img1.shape[1] * img1.shape[2] * 255
-
-    def distanceTo(self, img2):
-        return numpy.sum(numpy.absolute(self.a - img2), axis=None) / self.maxDist
-
-
-class Solver(object):
-
-    def __init__(self, graph, sessions:List[URIRef]|None=None, imgSize=(100, 53)):
-        self.graph = graph
-        self.sessions = sessions  # URIs of capture sessions to load
-        self.imgSize = imgSize
-        self.samples = {}  # uri: Image array (float 0-255)
-        self.fromPath = {}  # imagePath: image array
-        self.path = {}  # sample: path
-        self.blurredSamples = {}
-        self.sampleSettings = {}  # sample: DeviceSettings
-        self.samplesForDevice = {}  # dev : [(sample, img)]
-
-    def loadSamples(self):
-        """learn what lights do from images"""
-
-        log.info('loading...')
-
-        with self.graph.currentState() as g:
-            for sess in self.sessions or []:
-                for cap in g.objects(sess, L9['capture']):
-                    self._loadSample(g, cap)
-        log.info('loaded %s samples', len(self.samples))
-
-    def _loadSample(self, g, samp):
-        pathUri = g.value(samp, L9['imagePath'])
-        img = loadNumpy(pathUri.replace(L9[''], '')).astype(float)
-        settings = DeviceSettings.fromResource(self.graph, samp)
-
-        self.samples[samp] = img
-        self.fromPath[pathUri] = img
-        self.blurredSamples[samp] = self._blur(img)
-
-        self.path[samp] = pathUri
-        assert samp not in self.sampleSettings
-        self.sampleSettings[samp] = settings
-        devs = settings.devices()
-        if len(devs) == 1:
-            self.samplesForDevice.setdefault(devs[0], []).append((samp, img))
-
-    def _blur(self, img):
-        return scipy.ndimage.gaussian_filter(img, 10, 0, mode='nearest')
-
-    def draw(self, painting):
-        return self._draw(painting, self.imgSize[0], self.imgSize[1])
-
-    def _draw(self, painting, w, h):
-        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
-        ctx = cairo.Context(surface)
-        ctx.rectangle(0, 0, w, h)
-        ctx.fill()
-
-        ctx.set_line_cap(cairo.LINE_CAP_ROUND)
-        ctx.set_line_width(w / 15)  # ?
-        for stroke in painting['strokes']:
-            for pt in stroke['pts']:
-                op = ctx.move_to if pt is stroke['pts'][0] else ctx.line_to
-                op(pt[0] * w, pt[1] * h)
-
-            r, g, b = parseHex(stroke['color'])
-            ctx.set_source_rgb(r / 255, g / 255, b / 255)
-            ctx.stroke()
-
-        #surface.write_to_png('/tmp/surf.png')
-        return numpyFromCairo(surface)
-
-    def bestMatch(self, img, device=None):
-        """the one sample that best matches this image"""
-        #img = self._blur(img)
-        results = []
-        dist = ImageDist(img)
-        if device is None:
-            items = self.samples.items()
-        else:
-            items = self.samplesForDevice[device]
-        for uri, img2 in sorted(items):
-            if img.shape != img2.shape:
-                log.warn("mismatch %s %s", img.shape, img2.shape)
-                continue
-            results.append((dist.distanceTo(img2), uri, img2))
-        results.sort()
-        topDist, topUri, topImg = results[0]
-        print('tops2')
-        for row in results[:4]:
-            print('%.5f' % row[0], row[1][-20:], self.sampleSettings[row[1]])
-
-        #saveNumpy('/tmp/best_in.png', img)
-        #saveNumpy('/tmp/best_out.png', topImg)
-        #saveNumpy('/tmp/mult.png', topImg / 255 * img)
-        return topUri, topDist
-
-    def bestMatches(self, img, devices:List[URIRef]|None=None):
-        """settings for the given devices that point them each
-        at the input image"""
-        dist = ImageDist(img)
-        devSettings = []
-        for dev in devices or []:
-            results = []
-            for samp, img2 in self.samplesForDevice[dev]:
-                results.append((dist.distanceTo(img2), samp))
-            results.sort()
-
-            s = self.blendResults([(d, self.sampleSettings[samp]) for d, samp in results[:8]])
-            devSettings.append(s)
-        return DeviceSettings.fromList(self.graph, devSettings)
-
-    def blendResults(self, results):
-        """list of (dist, settings)"""
-
-        dists = [d for d, sets in results]
-        hi = max(dists)
-        lo = min(dists)
-        n = len(results)
-        remappedDists = [1 - (d - lo) / (hi - lo) * n / (n + 1) for d in dists]
-        total = sum(remappedDists)
-
-        #print 'blend'
-        #for o,n in zip(dists, remappedDists):
-        #    print o,n, n / total
-        blend = DeviceSettings.fromBlend(self.graph, [(d / total, sets) for d, (_, sets) in zip(remappedDists, results)])
-        return blend
-
-    def solve(self, painting):
-        """
-        given strokes of colors on a photo of the stage, figure out the
-        best light DeviceSettings to match the image
-        """
-        pic0 = self.draw(painting).astype(numpy.float64)
-        pic0Blur = self._blur(pic0)
-        saveNumpy('/tmp/sample_paint_%s.png' % len(painting['strokes']), pic0Blur)
-        sampleDist = {}
-        dist = ImageDist(pic0Blur)
-        for sample, picSample in sorted(self.blurredSamples.items()):
-            #saveNumpy('/tmp/sample_%s.png' % sample.split('/')[-1],
-            #          f(picSample))
-            sampleDist[sample] = dist.distanceTo(picSample)
-        results = sorted([(d, uri) for uri, d in sampleDist.items()])
-
-        sample = results[0][1]
-
-        # this is wrong; some wrong-alignments ought to be dimmer than full
-        brightest0 = brightest(pic0)
-        #brightestSample = brightest(self.samples[sample])
-
-        if max(brightest0) < 1 / 255:
-            return DeviceSettings(self.graph, [])
-
-        #scale = brightest0 / brightestSample
-
-        s = DeviceSettings.fromResource(self.graph, sample)
-        # missing color scale, but it was wrong to operate on all devs at once
-        return s
-
-    def solveBrute(self, painting):
-        pic0 = self.draw(painting).astype(numpy.float64)
-
-        colorSteps = 2
-        colorStep = 1. / colorSteps
-
-        # use toVector then add ranges
-        dims = [
-            (DEV['aura1'], L9['rx'], [slice(.2, .7 + .1, .2)]),
-            (DEV['aura1'], L9['ry'], [slice(.573, .573 + 1, 1)]),
-            (DEV['aura1'], L9['color'], [slice(0, 1 + colorStep, colorStep),
-                                         slice(0, 1 + colorStep, colorStep),
-                                         slice(0, 1 + colorStep, colorStep)]),
-        ]
-        deviceAttrFilter = [(d, a) for d, a, s in dims]
-
-        dist = ImageDist(pic0)
-
-        def drawError(x):
-            settings = DeviceSettings.fromVector(self.graph, x, deviceAttrFilter=deviceAttrFilter)
-            preview = self.combineImages(self.simulationLayers(settings))
-            #saveNumpy('/tmp/x_%s.png' % abs(hash(settings)), preview)
-
-            out = dist.distanceTo(preview)
-
-            #print 'measure at', x, 'drawError=', out
-            return out
-
-        x0, fval, grid, Jout = scipy.optimize.brute(func=drawError, ranges=sum([s for dev, da, s in dims], []), finish=None, disp=True, full_output=True)
-        if fval > 30000:
-            raise ValueError('solution has error of %s' % fval)
-        return DeviceSettings.fromVector(self.graph, x0, deviceAttrFilter=deviceAttrFilter)
-
-    def combineImages(self, layers):
-        """make a result image from our self.samples images"""
-        out = (next(iter(self.fromPath.values())) * 0).astype(numpy.uint16)
-        for layer in layers:
-            colorScaled = self.fromPath[layer['path']] * layer['color']
-            out += colorScaled.astype(numpy.uint16)
-        numpy.clip(out, 0, 255, out)
-        return out.astype(numpy.uint8)
-
-    def simulationLayers(self, settings):
-        """
-        how should a simulation preview approximate the light settings
-        (device attribute values) by combining photos we have?
-        """
-        assert isinstance(settings, DeviceSettings)
-        layers = []
-
-        for dev, devSettings in settings.byDevice():
-            requestedColor = devSettings.getValue(dev, L9['color'])
-            candidatePics = []  # (distance, path, picColor)
-            for sample, s in self.sampleSettings.items():
-                path = self.path[sample]
-                otherDevSettings = s.ofDevice(dev)
-                if not otherDevSettings:
-                    continue
-                dist = devSettings.distanceTo(otherDevSettings)
-                log.info('  candidate pic %s %s dist=%s', sample, path, dist)
-                candidatePics.append((dist, path, s.getValue(dev, L9['color'])))
-            candidatePics.sort()
-            # we could even blend multiple top candidates, or omit all
-            # of them if they're too far
-            bestDist, bestPath, bestPicColor = candidatePics[0]
-            log.info('  device best d=%g path=%s color=%s', bestDist, bestPath, bestPicColor)
-
-            layers.append({'path': bestPath, 'color': colorRatio(requestedColor, bestPicColor)})
-
-        return layers
--- a/light9/paint/solve_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,157 +0,0 @@
-import unittest
-import numpy.testing
-from . import solve
-from rdflib import Namespace
-from light9.namespaces import L9, DEV
-from light9.localsyncedgraph import LocalSyncedGraph
-from light9.effect.settings import DeviceSettings
-
-
-class TestSolve(unittest.TestCase):
-
-    def setUp(self):
-        self.graph = LocalSyncedGraph(
-            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-        self.solver = solve.Solver(self.graph,
-                                   imgSize=(100, 48),
-                                   sessions=[L9['session0']])
-        self.solver.loadSamples()
-        self.solveMethod = self.solver.solve
-
-    @unittest.skip('solveBrute unfinished')
-    def testBlack(self):
-        devAttrs = self.solveMethod({'strokes': []})
-        self.assertEqual(DeviceSettings(self.graph, []), devAttrs)
-
-    @unittest.skip("unfinished")
-    def testSingleLightCloseMatch(self):
-        devAttrs = self.solveMethod({
-            'strokes': [{
-                'pts': [[224, 141], [223, 159]],
-                'color': '#ffffff'
-            }]
-        })
-        self.assertEqual(
-            DeviceSettings(self.graph, [
-                (DEV['aura1'], L9['color'], "#ffffff"),
-                (DEV['aura1'], L9['rx'], 0.5),
-                (DEV['aura1'], L9['ry'], 0.573),
-            ]), devAttrs)
-
-
-class TestSolveBrute(TestSolve):
-
-    def setUp(self):
-        super(TestSolveBrute, self).setUp()
-        self.solveMethod = self.solver.solveBrute
-
-
-CAM_TEST = Namespace('http://light9.bigasterisk.com/test/cam/')
-
-
-class TestSimulationLayers(unittest.TestCase):
-
-    def setUp(self):
-        self.graph = LocalSyncedGraph(
-            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-        self.solver = solve.Solver(self.graph,
-                                   imgSize=(100, 48),
-                                   sessions=[L9['session0']])
-        self.solver.loadSamples()
-
-    def testBlack(self):
-        self.assertEqual([],
-                         self.solver.simulationLayers(
-                             settings=DeviceSettings(self.graph, [])))
-
-    def testPerfect1Match(self):
-        layers = self.solver.simulationLayers(
-            settings=DeviceSettings(self.graph, [(
-                DEV['aura1'], L9['color'],
-                "#ffffff"), (DEV['aura1'], L9['rx'],
-                             0.5), (DEV['aura1'], L9['ry'], 0.573)]))
-        self.assertEqual([{
-            'path': CAM_TEST['bg2-d.jpg'],
-            'color': (1., 1., 1.)
-        }], layers)
-
-    def testPerfect1MatchTinted(self):
-        layers = self.solver.simulationLayers(
-            settings=DeviceSettings(self.graph, [(
-                DEV['aura1'], L9['color'],
-                "#304050"), (DEV['aura1'], L9['rx'],
-                             0.5), (DEV['aura1'], L9['ry'], 0.573)]))
-        self.assertEqual([{
-            'path': CAM_TEST['bg2-d.jpg'],
-            'color': (.188, .251, .314)
-        }], layers)
-
-    def testPerfect2Matches(self):
-        layers = self.solver.simulationLayers(
-            settings=DeviceSettings(self.graph, [
-                (DEV['aura1'], L9['color'], "#ffffff"),
-                (DEV['aura1'], L9['rx'], 0.5),
-                (DEV['aura1'], L9['ry'], 0.573),
-                (DEV['aura2'], L9['color'], "#ffffff"),
-                (DEV['aura2'], L9['rx'], 0.7),
-                (DEV['aura2'], L9['ry'], 0.573),
-            ]))
-        self.assertEqual([
-            {
-                'path': CAM_TEST['bg2-d.jpg'],
-                'color': (1, 1, 1)
-            },
-            {
-                'path': CAM_TEST['bg2-f.jpg'],
-                'color': (1, 1, 1)
-            },
-        ], layers)
-
-
-class TestCombineImages(unittest.TestCase):
-
-    def setUp(self):
-        graph = LocalSyncedGraph(
-            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-        self.solver = solve.Solver(graph,
-                                   imgSize=(100, 48),
-                                   sessions=[L9['session0']])
-        self.solver.loadSamples()
-
-    def fixme_test(self):
-        out = self.solver.combineImages(layers=[
-            {
-                'path': CAM_TEST['bg2-d.jpg'],
-                'color': (.2, .2, .3)
-            },
-            {
-                'path': CAM_TEST['bg2-a.jpg'],
-                'color': (.888, 0, .3)
-            },
-        ])
-        solve.saveNumpy('/tmp/t.png', out)
-        golden = solve.loadNumpy('test/cam/layers_out1.png')
-        numpy.testing.assert_array_equal(golden, out)
-
-
-class TestBestMatch(unittest.TestCase):
-
-    def setUp(self):
-        graph = LocalSyncedGraph(
-            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
-        self.solver = solve.Solver(graph,
-                                   imgSize=(100, 48),
-                                   sessions=[L9['session0']])
-        self.solver.loadSamples()
-
-    def testRightSide(self):
-        drawingOnRight = {
-            "strokes": [{
-                "pts": [[0.875, 0.64], [0.854, 0.644]],
-                "color": "#aaaaaa"
-            }]
-        }
-        drawImg = self.solver.draw(drawingOnRight)
-        match, dist = self.solver.bestMatch(drawImg)
-        self.assertEqual(L9['sample5'], match)
-        self.assertAlmostEqual(0.983855965, dist, places=1)
--- a/light9/prof.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-import sys, traceback, time, logging
-from typing import Any, Dict
-log = logging.getLogger()
-
-
-def run(main, profile=None):
-    if not profile:
-        main()
-        return
-
-    if profile == 'hotshot':
-        import hotshot, hotshot.stats
-        p = hotshot.Profile("/tmp/pro")
-        p.runcall(main)
-        p.close()
-        hotshot.stats.load("/tmp/pro").sort_stats('cumulative').print_stats()
-    elif profile == 'stat':
-        import statprof
-        statprof.start()
-        try:
-            main()
-        finally:
-            statprof.stop()
-            statprof.display()
-
-
-def watchPoint(filename, lineno, event="call"):
-    """whenever we hit this line, print a stack trace. event='call'
-    for lines that are function definitions, like what a profiler
-    gives you.
-
-    Switch to 'line' to match lines inside functions. Execution speed
-    will be much slower."""
-    seenTraces: Dict[Any, int] = {}  # trace contents : count
-
-    def trace(frame, ev, arg):
-        if ev == event:
-            if (frame.f_code.co_filename, frame.f_lineno) == (filename, lineno):
-                stack = ''.join(traceback.format_stack(frame))
-                if stack not in seenTraces:
-                    print("watchPoint hit")
-                    print(stack)
-                    seenTraces[stack] = 1
-                else:
-                    seenTraces[stack] += 1
-
-        return trace
-
-    sys.settrace(trace)
-
-    # atexit, print the frequencies?
-
-
-def logTime(func):
-
-    def inner(*args, **kw):
-        t1 = time.time()
-        try:
-            ret = func(*args, **kw)
-        finally:
-            log.info("Call to %s took %.1f ms" % (func.__name__, 1000 *
-                                                  (time.time() - t1)))
-        return ret
-
-    return inner
--- a/light9/rdfdb/service.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,28 +0,0 @@
-import logging
-import os
-from pathlib import Path
-
-from light9.run_local import log
-
-import rdfdb.service
-from rdflib import URIRef
-
-from light9 import showconfig
-logging.getLogger('rdfdb').setLevel(logging.INFO)
-logging.getLogger('rdfdb.file').setLevel(logging.INFO)
-logging.getLogger('rdfdb.graph').setLevel(logging.INFO)
-logging.getLogger('rdfdb.net').setLevel(logging.INFO)
-rdfRoot = Path(os.environ['LIGHT9_SHOW'].rstrip('/') + '/')
-showUri = URIRef(showconfig.showUri() + '/')
-
-app = rdfdb.service.makeApp(  #
-    dirUriMap={rdfRoot: showUri},
-    prefixes={
-        'show': showUri,
-        '': URIRef('http://light9.bigasterisk.com/'),
-        'rdf': URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#'),
-        'rdfs': URIRef('http://www.w3.org/2000/01/rdf-schema#'),
-        'xsd': URIRef('http://www.w3.org/2001/XMLSchema#'),
-        'effect': URIRef('http://light9.bigasterisk.com/effect/'),
-        'dev': URIRef('http://light9.bigasterisk.com/theater/skyline/device/'),
-    })
--- a/light9/rdfdb/service_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-import asyncio
-from light9.run_local import log
-
-
-def test_import():
-
-    async def go():
-        # this sets up some watcher tasks
-        from light9.rdfdb.service import app
-
-    asyncio.run(go(), debug=True)
--- a/light9/recentfps.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,30 +0,0 @@
-# server side version of what the metrics consumer does with changing counts
-import time
-
-class RecentFps:
-  def __init__(self, window=20):
-    self.window = window
-    self.recentTimes = []
-
-  def mark(self):
-    now = time.time()
-    self.recentTimes.append(now)
-    self.recentTimes = self.recentTimes[-self.window:]
-
-  def rate(self):
-    def dec(innerFunc):
-      def f(*a, **kw):
-        self.mark()
-        return innerFunc(*a, **kw)
-      return f
-    return dec
-
-  def __call__(self):
-    if len(self.recentTimes) < 2:
-      return {}
-    recents = sorted(round(1 / (b - a), 3)
-                      for a, b in zip(self.recentTimes[:-1],
-                                      self.recentTimes[1:]))
-    avg = (len(self.recentTimes) - 1) / (
-      self.recentTimes[-1] - self.recentTimes[0])
-    return {'average': round(avg, 5), 'recents': recents}
--- a/light9/run_local.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-# bootstrap
-
-import re
-import logging
-import os
-import socket
-import sys
-from colorsys import hsv_to_rgb
-
-import coloredlogs
-
-try:
-    import faulthandler
-    faulthandler.enable()
-except ImportError:
-    pass
-
-if 0:
-    from IPython.core import ultratb
-    sys.excepthook = ultratb.FormattedTB(mode='Verbose', color_scheme='Linux', call_pdb=1)
-
-progName = sys.argv[0].split('/')[-1]
-log = logging.getLogger()  # this has to get the root logger
-log.name = progName  # but we can rename it for clarity
-
-coloredlogs.install(
-    level='DEBUG',
-    milliseconds=True,
-    datefmt='t=%H:%M:%S',
-    fmt='%(asctime)s.%(msecs)03d %(levelname)1.1s [%(filename)s:%(lineno)s] %(name)s %(message)s',
-    # try `pdm run humanfriendly --demo`
-    field_styles=dict(
-        asctime=dict(color=30),
-        levelname=dict(color=247),
-        name=dict(color='blue'),
-    ),
-    level_styles={
-        'debug': dict(color=115),
-        'info': dict(color=250),
-        'warning': dict(color=208),
-        'error': dict(color=161),
-        'critical': dict(color=196),
-    },
-)
-
-
-def setTerminalTitle(s):
-    if os.environ.get('TERM', '') in ['xterm', 'rxvt', 'rxvt-unicode-256color']:
-        m = re.search(r'(light9\..*):', s)
-        if m is not None:
-            s = m.group(1)
-        s = s.replace('/home/drewp/own/proj/','')
-        print("\033]0;%s\007" % s)  # not escaped/protected correctly
-        hue = (hash(progName) % 255) / 255
-        r, g, b = [int(x * 255) for x in hsv_to_rgb(hue, s=.2, v=.1)]
-        print(f"\033]11;#{r:02x}{g:02x}{b:02x}\007")
-
-
-if 'listsongs' not in sys.argv[0] and 'homepageConfig' not in sys.argv[0]:
-    setTerminalTitle('[%s] %s' % (socket.gethostname(), ' '.join(sys.argv).replace('bin/', '')))
-
-# see http://www.youtube.com/watch?v=3cIOT9kM--g for commands that make
-# profiles and set background images
--- a/light9/showconfig.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,97 +0,0 @@
-import logging, warnings
-from twisted.python.filepath import FilePath
-from os import path, getenv
-from rdflib import Graph
-from rdflib import URIRef, Literal
-from .namespaces import L9
-from typing import List, cast
-log = logging.getLogger('showconfig')
-
-_config = None  # graph
-
-
-def getGraph() -> Graph:
-    warnings.warn(
-        "code that's using showconfig.getGraph should be "
-        "converted to use the sync graph",
-        stacklevel=2)
-    global _config
-    if _config is None:
-        graph = Graph()
-        # note that logging is probably not configured the first time
-        # we're in here
-        warnings.warn("reading n3 files around %r" % root())
-        for f in FilePath(root()).globChildren("*.n3") + FilePath(
-                root()).globChildren("build/*.n3"):
-            graph.parse(location=f.path, format='n3')
-        _config = graph
-    return _config
-
-
-def root() -> bytes:
-    r = getenv("LIGHT9_SHOW")
-    if r is None:
-        raise OSError(
-            "LIGHT9_SHOW env variable has not been set to the show root")
-    return r.encode('ascii')
-
-
-_showUri = None
-
-
-def showUri() -> URIRef:
-    """Return the show URI associated with $LIGHT9_SHOW."""
-    global _showUri
-    if _showUri is None:
-        _showUri = URIRef(open(path.join(root(), b'URI')).read().strip())
-    return _showUri
-
-
-def songOnDisk(song: URIRef) -> bytes:
-    """given a song URI, where's the on-disk file that mpd would read?"""
-    graph = getGraph()
-    root = graph.value(showUri(), L9['musicRoot'])
-    if not root:
-        raise ValueError("%s has no :musicRoot" % showUri())
-
-    name = graph.value(song, L9['songFilename'])
-    if not name:
-        raise ValueError("Song %r has no :songFilename" % song)
-
-    return path.abspath(
-        path.join(
-            cast(Literal, root).toPython(),
-            cast(Literal, name).toPython()))
-
-
-def songFilenameFromURI(uri: URIRef) -> bytes:
-    """
-    'http://light9.bigasterisk.com/show/dance2007/song8' -> 'song8'
-
-    everything that uses this should be deprecated for real URIs
-    everywhere"""
-    assert isinstance(uri, URIRef)
-    return str(uri).split('/')[-1].encode('ascii')
-
-
-def getSongsFromShow(graph: Graph, show: URIRef) -> List[URIRef]:
-    playList = graph.value(show, L9['playList'])
-    if not playList:
-        raise ValueError("%r has no l9:playList" % show)
-    # The patch in https://github.com/RDFLib/rdflib/issues/305 fixed a
-    # serious bug here.
-    songs = list(graph.items(playList))
-
-    return songs
-
-
-def curvesDir():
-    return path.join(root(), b"curves")
-
-
-def subFile(subname):
-    return path.join(root(), b"subs", subname)
-
-
-def subsDir():
-    return path.join(root(), b'subs')
--- a/light9/subclient.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-from light9.collector.collector_client import sendToCollector
-from twisted.internet import reactor
-from twisted.internet.defer import Deferred
-import traceback
-import time
-import logging
-from rdflib import URIRef
-from rdfdb.syncedgraph import SyncedGraph
-log = logging.getLogger()
-
-
-class SubClient:
-    graph: SyncedGraph
-    session: URIRef
-
-    def __init__(self):
-        """assumed that your init saves self.graph"""
-        pass  # we may later need init code for network setup
-
-    def get_levels_as_sub(self):
-        """Subclasses must implement this method and return a Submaster
-        object."""
-
-    def send_levels_loop(self, periodSec=1.) -> None:
-        sendStartTime = time.time()
-
-        def done(sec):
-            delay = max(0, (sendStartTime + periodSec) - time.time())
-            reactor.callLater(delay, self.send_levels_loop, periodSec)
-
-        def err(e):
-            log.warn('subclient loop: %r', e)
-            reactor.callLater(2, self.send_levels_loop, periodSec)
-
-        d = self._send_sub()
-        d.addCallbacks(done, err)
-
-    def _send_sub(self) -> Deferred:
-        try:
-            with self.graph.currentState() as g:
-                outputSettings = self.get_output_settings(_graph=g)
-        except Exception:
-            traceback.print_exc()
-            raise
-
-        return sendToCollector(
-            'subclient',
-            self.session,
-            outputSettings,
-            # when KC uses zmq, we get message
-            # pileups and delays on collector (even
-            # at 20fps). When sequencer uses zmp,
-            # it runs great at 40fps. Not sure the
-            # difference- maybe Tk main loop?
-            useZmq=False)
--- a/light9/subcomposer/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>subcomposer</title>
-    <meta charset="utf-8" />
-    <style>
-     button {
-       min-width: 200px;
-       min-height: 50px;
-       display: block;
-     }
-    </style>
-  </head>
-  <body>
-    <div>Toggle channel in current sub</div>
-
-
-    
-
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b11">b11</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b12">b12</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b13">b13</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b14">b14</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b15">b15</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b16">b16</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b21">b21</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b22">b22</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b23">b23</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b24">b24</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b25">b25</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b26">b26</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b31">b31</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b32">b32</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b33">b33</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b34">b34</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b35">b35</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b36">b36</button>
-    <hr>
-    
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f1">f1-l</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f2-out">f2-c</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f3">f3-r</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f4">f4-purp</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f5">f5-rose-x2</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/sharlyn">sharlyn</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f7">f7-c</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f8">f8-blue-x2</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f9">f9-purp</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f10">f10-l</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f11">f11-c</button>
-    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f12-out">f12-r</button>
-
-    <script src="/lib/jquery/dist/jquery.min.js"></script>
-    <script>
-      $(document).on("click", "button", function (ev) {
-          var chan = ev.target.getAttribute('data-chan');
-          $.ajax({
-              type: 'POST',
-              url: 'toggle',
-              data: {chan: chan}
-          });
-      });
-    </script>
-  </body>
-</html>
--- a/light9/subcomposer/subcomposerweb.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-import logging
-
-from rdflib import URIRef, Literal
-from twisted.internet import reactor
-import cyclone.web
-
-from cycloneerr import PrettyErrorHandler
-from light9 import networking
-
-log = logging.getLogger('web')
-
-
-def init(graph, session, currentSub):
-    SFH = cyclone.web.StaticFileHandler
-    app = cyclone.web.Application(handlers=[
-        (r'/()', SFH, {
-            'path': 'light9/subcomposer',
-            'default_filename': 'index.html'
-        }),
-        (r'/toggle', Toggle),
-    ],
-                                  debug=True,
-                                  graph=graph,
-                                  currentSub=currentSub)
-    reactor.listenTCP(networking.subComposer.port, app)
-    log.info("listening on %s" % networking.subComposer.port)
-
-
-class Toggle(PrettyErrorHandler, cyclone.web.RequestHandler):
-
-    def post(self):
-        chan = URIRef(self.get_argument('chan'))
-        sub = self.settings.currentSub()
-
-        chanKey = Literal(chan.rsplit('/', 1)[1])
-        old = sub.get_levels().get(chanKey, 0)
-
-        sub.editLevel(chan, 0 if old else 1)
--- a/light9/tkdnd.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-from glob import glob
-from os.path import join, basename
-from typing import Dict, Any
-
-
-class TkdndEvent(object):
-    """
-    see http://www.ellogon.org/petasis/tcltk-projects/tkdnd/tkdnd-man-page
-    for details on the fields
-
-    The longer attribute names (action instead of %A) were made up for
-    this API.
-
-    Not all attributes are visible yet, since I have not thought
-    through what conversions they should receive and I don't want to
-    unnecessarily change their types later.
-    """
-    substitutions = {
-        "%A": "action",
-        "%b": "button",
-        "%D": "data",
-        "%m": "modifiers",
-        "%T": "type",
-        "%W": "targetWindow",
-        "%X": "mouseX",
-        "%Y": "mouseY",
-    }
-
-    @classmethod
-    def makeEvent(cls, *args):
-        ev = cls()
-        for (k, v), arg in zip(sorted(TkdndEvent.substitutions.items()), args):
-            setattr(ev, v, arg)
-        # it would be cool for this to decode text data according to the charset in the type
-        for attr in ['button', 'mouseX', 'mouseY']:
-            setattr(ev, attr, int(getattr(ev, attr)))
-        return (ev,)
-
-    tclSubstitutions = ' '.join(sorted(substitutions.keys()))
-
-    def __repr__(self):
-        return "<TkdndEvent %r>" % self.__dict__
-
-
-class Hover(object):
-
-    def __init__(self, widget, style):
-        self.widget, self.style = widget, style
-        self.oldStyle: Dict[Any, Any] = {}
-
-    def set(self, ev):
-        for k, v in list(self.style.items()):
-            self.oldStyle[k] = self.widget.cget(k)
-        self.widget.configure(**self.style)
-        return ev.action
-
-    def restore(self, ev):
-        self.widget.configure(**self.oldStyle)
-
-
-def initTkdnd(tk, tkdndBuildDir):
-    """
-    pass the 'tk' attribute of any Tkinter object, and the top dir of
-    your built tkdnd package
-    """
-    tk.call('source', join(tkdndBuildDir, 'library/tkdnd.tcl'))
-    for dll in glob(
-            join(tkdndBuildDir,
-                 '*tkdnd*' + tk.call('info', 'sharedlibextension'))):
-        tk.call('tkdnd::initialise', join(tkdndBuildDir, 'library'),
-                join('..', basename(dll)), 'tkdnd')
-
-
-def dragSourceRegister(widget, action='copy', datatype='text/uri-list',
-                       data=''):
-    """
-    if the 'data' param is callable, it will be called every time to
-    look up the current data.
-
-    If the callable returns None (or data is None to begin with), the drag
-    """
-    widget.tk.call('tkdnd::drag_source', 'register', widget._w)
-
-    # with normal Tkinter bind(), the result of your handler isn't
-    # actually returned so the drag doesn't get launched. This is a
-    # corrected version of what bind() does when you pass a function,
-    # but I don't block my tuple from getting returned (as a tcl list)
-
-    def init():
-        dataValue = data() if callable(data) else data
-        if dataValue is None:
-            return
-        return (action, datatype, dataValue)
-
-    funcId = widget._register(
-        init,
-        widget._substitute,
-        1  # needscleanup
-    )
-    widget.bind("<<DragInitCmd>>", funcId)
-
-
-def dropTargetRegister(
-        widget,
-        typeList=None,
-        onDropEnter=None,
-        onDropPosition=None,
-        onDropLeave=None,
-        onDrop=None,
-        hoverStyle=None,
-):
-    """
-    the optional callbacks will be called with a TkdndEvent
-    argument.
-
-    onDropEnter, onDropPosition, and onDrop are supposed to return an
-    action (perhaps the value in event.action). The return value seems
-    to have no effect, but it might just be that errors are getting
-    silenced.
-
-    Passing hoverStyle sets onDropEnter to call
-    widget.configure(**hoverStyle) and onDropLeave to restore the
-    widget's style. onDrop is also wrapped to do a restore.
-    """
-
-    if hoverStyle is not None:
-        hover = Hover(widget, hoverStyle)
-
-        def wrappedDrop(ev):
-            hover.restore(ev)
-            if onDrop:
-                return onDrop(ev)
-
-        return dropTargetRegister(widget,
-                                  typeList=typeList,
-                                  onDropEnter=hover.set,
-                                  onDropLeave=hover.restore,
-                                  onDropPosition=onDropPosition,
-                                  onDrop=wrappedDrop)
-
-    if typeList is None:
-        typeList = ['*']
-    widget.tk.call(*(['tkdnd::drop_target', 'register', widget._w] + typeList))
-
-    for sequence, handler in [
-        ('<<DropEnter>>', onDropEnter),
-        ('<<DropPosition>>', onDropPosition),
-        ('<<DropLeave>>', onDropLeave),
-        ('<<Drop>>', onDrop),
-    ]:
-        if not handler:
-            continue
-        func = widget._register(handler,
-                                subst=TkdndEvent.makeEvent,
-                                needcleanup=1)
-        widget.bind(sequence, func + " " + TkdndEvent.tclSubstitutions)
--- a/light9/typedgraph.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,87 +0,0 @@
-from typing import List, Type, TypeVar, cast, get_args
-
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import XSD, BNode, Graph, Literal, URIRef
-from rdflib.term import Node
-
-# todo: this ought to just require a suitable graph.value method
-EitherGraph = Graph | SyncedGraph
-
-_ObjType = TypeVar('_ObjType')
-
-
-class ConversionError(ValueError):
-    """graph had a value, but it does not safely convert to any of the requested types"""
-
-
-def _expandUnion(t: Type) -> List[Type]:
-    if hasattr(t, '__args__'):
-        return list(get_args(t))
-    return [t]
-
-
-def _typeIncludes(t1: Type, t2: Type) -> bool:
-    """same as issubclass but t1 can be a NewType"""
-    if t2 is None:
-        t2 = type(None)
-    if t1 == t2:
-        return True
-
-    if getattr(t1, '__supertype__', None) == t2:
-        return True
-
-    ts = _expandUnion(t1)
-    if len(ts) > 1:
-        return any(_typeIncludes(t, t2) for t in ts)
-
-    return False
-
-
-def _convLiteral(objType: Type[_ObjType], x: Literal) -> _ObjType:
-    if _typeIncludes(objType, Literal):
-        return cast(objType, x)
-
-    for outType, dtypes in [
-        (float, (XSD['integer'], XSD['double'], XSD['decimal'])),
-        (int, (XSD['integer'],)),
-        (str, ()),
-    ]:
-        for t in _expandUnion(objType):
-            if _typeIncludes(t, outType) and (not dtypes or x.datatype in dtypes):
-                # e.g. user wants float and we have xsd:double
-                return cast(objType, outType(x.toPython()))
-    raise ConversionError
-
-
-def typedValue(objType: Type[_ObjType], graph: EitherGraph, subj: Node, pred: URIRef) -> _ObjType:
-    """graph.value(subj, pred) with a given return type.
-    If objType is not an rdflib.Node, we toPython() the value.
-
-    Allow objType to include None if you want a None return for not-found.
-    """
-    if objType is None:
-        raise TypeError('must allow non-None result type')
-    obj = graph.value(subj, pred)
-    if obj is None:
-        if _typeIncludes(objType, None):
-            return cast(objType, None)
-        raise ValueError(f'No obj for {subj=} {pred=}')
-
-    ConvFrom: Type[Node] = type(obj)
-    ConvTo = objType
-    try:
-        if ConvFrom == URIRef and _typeIncludes(ConvTo, URIRef):
-            conv = obj
-        elif ConvFrom == URIRef and issubclass(URIRef, ConvTo) and not issubclass(str, ConvTo):  # rewrite please
-            conv = obj
-        elif ConvFrom == BNode and issubclass(BNode, ConvTo):
-            conv = obj
-        elif ConvFrom == Literal:
-            conv = _convLiteral(objType, cast(Literal, obj))
-        else:
-            raise ConversionError
-    except ConversionError:
-        raise ConversionError(f'graph contains {type(obj)}, caller requesting {objType}')
-    # if objType is float and isinstance(conv, decimal.Decimal):
-    #     conv = float(conv)
-    return cast(objType, conv)
\ No newline at end of file
--- a/light9/typedgraph_test.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,161 +0,0 @@
-from typing import NewType, Optional, cast
-
-import pytest
-from rdflib import BNode, Graph, Literal, URIRef
-from rdflib.term import Node
-from light9.mock_syncedgraph import MockSyncedGraph
-from light9.namespaces import L9, XSD
-from light9.typedgraph import ConversionError, _typeIncludes, typedValue
-
-g = cast(
-    Graph,
-    MockSyncedGraph('''
-    @prefix : <http://light9.bigasterisk.com/> .
-    :subj
-        :uri :c;
-        :bnode [];
-        # see https://w3c.github.io/N3/spec/#literals for syntaxes.
-        :int 0;
-        :float1 0.0;
-        :float2 1.0e1;
-        :float3 0.5;
-        :color "#ffffff"^^:hexColor;
-        :definitelyAString "hello" .
-'''))
-
-subj = L9['subj']
-
-
-class TestTypeIncludes:
-
-    def test_includesItself(self):
-        assert _typeIncludes(str, str)
-
-    def test_includesUnionMember(self):
-        assert _typeIncludes(int | str, str)
-
-    def test_notIncludes(self):
-        assert not _typeIncludes(int | str, None)
-
-    def test_explicitOptionalWorks(self):
-        assert _typeIncludes(Optional[int], None)
-
-    def test_3WayUnionWorks(self):
-        assert _typeIncludes(int | str | float, int)
-
-
-class TestTypedValueReturnsBasicTypes:
-
-    def test_getsUri(self):
-        assert typedValue(URIRef, g, subj, L9['uri']) == L9['c']
-
-    def test_getsAsNode(self):
-        assert typedValue(Node, g, subj, L9['uri']) == L9['c']
-
-    def test_getsBNode(self):
-        # this is unusual usage since users ought to always be able to replace BNode with URIRef
-        assert typedValue(BNode, g, subj, L9['bnode']) == g.value(subj,  L9['bnode'])
-
-    def test_getsBNodeAsNode(self):
-        assert typedValue(Node, g, subj, L9['bnode']) == g.value(subj,  L9['bnode'])
-
-
-    def test_getsNumerics(self):
-        assert typedValue(float, g, subj, L9['int']) == 0
-        assert typedValue(float, g, subj, L9['float1']) == 0
-        assert typedValue(float, g, subj, L9['float2']) == 10
-        assert typedValue(float, g, subj, L9['float3']) == 0.5
-
-        assert typedValue(int, g, subj, L9['int']) == 0
-        # These retrieve rdf floats that happen to equal
-        # ints, but no one should be relying on that.
-        with pytest.raises(ConversionError):
-            typedValue(int, g, subj, L9['float1'])
-        with pytest.raises(ConversionError):
-            typedValue(int, g, subj, L9['float2'])
-        with pytest.raises(ConversionError):
-            typedValue(int, g, subj, L9['float3'])
-
-    def test_getsString(self):
-        tv = typedValue(str, g, subj, L9['color'])
-        assert tv == '#ffffff'
-
-    def test_getsLiteral(self):
-        tv = typedValue(Literal, g, subj, L9['float2'])
-        assert type(tv) == Literal
-        assert tv.datatype == XSD['double']
-
-        tv = typedValue(Literal, g, subj, L9['color'])
-        assert type(tv) == Literal
-        assert tv.datatype == L9['hexColor']
-
-
-class TestTypedValueDoesntDoInappropriateUriStringConversions:
-
-    def test_noUriToString(self):
-        with pytest.raises(ConversionError):
-            typedValue(str, g, subj, L9['uri'])
-
-    def test_noUriToLiteral(self):
-        with pytest.raises(ConversionError):
-            typedValue(Literal, g, subj, L9['uri'])
-
-    def test_noStringToUri(self):
-        with pytest.raises(ConversionError):
-            typedValue(URIRef, g, subj, L9['definitelyAString'])
-
-
-class TestTypedValueOnMissingValues:
-
-    def test_errorsOnMissingValue(self):
-        with pytest.raises(ValueError):
-            typedValue(float, g, subj, L9['missing'])
-
-    def test_returnsNoneForMissingValueIfCallerPermits(self):
-        assert (float | None) == Optional[float]
-        assert typedValue(float | None, g, subj, L9['float1']) == 0
-        assert typedValue(float | None, g, subj, L9['missing']) == None
-        assert typedValue(str | float | None, g, subj, L9['missing']) == None
-
-    def test_cantJustPassNone(self):
-        with pytest.raises(TypeError):
-            typedValue(None, g, subj, L9['float1'])  # type: ignore
-
-
-class TestTypedValueConvertsToNewTypes:
-
-    def test_castsUri(self):
-        DeviceUri = NewType('DeviceUri', URIRef)
-        assert typedValue(DeviceUri, g, subj, L9['uri']) == DeviceUri(L9['c'])
-
-    def test_castsLiteralToNewType(self):
-        HexColor = NewType('HexColor', str)
-        assert typedValue(HexColor, g, subj, L9['color']) == HexColor('#ffffff')
-
-
-class TestTypedValueAcceptsUnionTypes:
-
-    def test_getsMemberTypeOfUnion(self):
-        tv1 = typedValue(float | str, g, subj, L9['float1'])
-        assert type(tv1) == float
-        assert tv1 == 0.0
-
-        tv2 = typedValue(float | str, g, subj, L9['color'])
-        assert type(tv2) == str
-        assert tv2 == '#ffffff'
-
-    def test_failsIfNoUnionTypeMatches(self):
-        with pytest.raises(ConversionError):
-            typedValue(float | URIRef, g, subj, L9['color'])
-
-    def test_combinesWithNone(self):
-        assert typedValue(float | URIRef | None, g, subj, L9['uri']) == L9['c']
-
-    def test_combinedWithNewType(self):
-        HexColor = NewType('HexColor', str)
-        assert typedValue(float | HexColor, g, subj, L9['float1']) == 0
-        assert typedValue(float | HexColor, g, subj, L9['color']) == HexColor('#ffffff')
-
-    def test_whenOneIsUri(self):
-        assert typedValue(str | URIRef, g, subj, L9['color']) == '#ffffff'
-        assert typedValue(str | URIRef, g, subj, L9['uri']) == L9['c']
--- a/light9/uihelpers.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,298 +0,0 @@
-"""all the tiny tk helper functions"""
-
-#from Tkinter import Button
-import logging, time
-from rdflib import Literal
-from tkinter.tix import Button, Toplevel, Tk, IntVar, Entry, DoubleVar
-import tkinter
-from light9.namespaces import L9
-from typing import Dict
-
-log = logging.getLogger("toplevel")
-
-windowlocations = {
-    'sub': '425x738+00+00',
-    'console': '168x24+848+000',
-    'leveldisplay': '144x340+870+400',
-    'cuefader': '314x212+546+741',
-    'effect': '24x24+0963+338',
-    'stage': '823x683+37+030',
-    'scenes': '504x198+462+12',
-}
-
-
-def bindkeys(root, key, func):
-    root.bind(key, func)
-    for w in root.winfo_children():
-        w.bind(key, func)
-
-
-def toplevel_savegeometry(tl, name):
-    try:
-        geo = tl.geometry()
-        if not geo.startswith("1x1"):
-            f = open(".light9-window-geometry-%s" % name.replace(' ', '_'), 'w')
-            f.write(tl.geometry())
-        # else the window never got mapped
-    except Exception:
-        # it's ok if there's no saved geometry
-        pass
-
-
-def toplevelat(name, existingtoplevel=None, graph=None, session=None):
-    tl = existingtoplevel or Toplevel()
-    tl.title(name)
-
-    lastSaved = [None]
-    setOnce = [False]
-    graphSetTime = [0.0]
-
-    def setPosFromGraphOnce():
-        """
-        the graph is probably initially empty, but as soon as it gives
-        us one window position, we stop reading them
-        """
-        if setOnce[0]:
-            return
-        geo = graph.value(session, L9['windowGeometry'])
-        log.debug("setPosFromGraphOnce %s", geo)
-
-        setOnce[0] = True
-        graphSetTime[0] = time.time()
-        if geo is not None and geo != lastSaved[0]:
-            tl.geometry(geo)
-            lastSaved[0] = geo
-
-    def savePos(ev):
-        geo = tl.geometry()
-        if not isinstance(ev.widget, (Tk, tkinter.Tk)):
-            # I think these are due to internal widget size changes,
-            # not the toplevel changing
-            return
-        # this is trying to not save all the startup automatic window
-        # sizes. I don't have a better plan for this yet.
-        if graphSetTime[0] == 0 or time.time() < graphSetTime[0] + 3:
-            return
-        if not setOnce[0]:
-            return
-        lastSaved[0] = geo
-        log.debug("saving position %s", geo)
-        graph.patchObject(session, session, L9['windowGeometry'], Literal(geo))
-
-    if graph is not None and session is not None:
-        graph.addHandler(setPosFromGraphOnce)
-
-    if name in windowlocations:
-        tl.geometry(positionOnCurrentDesktop(windowlocations[name]))
-
-    if graph is not None:
-        tl._toplevelat_funcid = tl.bind(
-            "<Configure>", lambda ev, tl=tl, name=name: savePos(ev))
-
-    return tl
-
-
-def positionOnCurrentDesktop(xform, screenWidth=1920, screenHeight=1440):
-    size, x, y = xform.split('+')
-    x = int(x) % screenWidth
-    y = int(y) % screenHeight
-    return "%s+%s+%s" % (size, x, y)
-
-
-def toggle_slider(s):
-    if s.get() == 0:
-        s.set(100)
-    else:
-        s.set(0)
-
-
-# for lambda callbacks
-def printout(t):
-    print('printout', t)
-
-
-def printevent(ev):
-    for k in dir(ev):
-        if not k.startswith('__'):
-            print('ev', k, getattr(ev, k))
-
-
-def eventtoparent(ev, sequence):
-    "passes an event to the parent, screws up TixComboBoxes"
-
-    wid_class = str(ev.widget.__class__)
-    if wid_class == 'Tix.ComboBox' or wid_class == 'Tix.TixSubWidget':
-        return
-
-    evdict = {}
-    for x in ['state', 'time', 'y', 'x', 'serial']:
-        evdict[x] = getattr(ev, x)
-
-
-#    evdict['button']=ev.num
-    par = ev.widget.winfo_parent()
-    if par != ".":
-        ev.widget.nametowidget(par).event_generate(sequence, **evdict)
-    #else the event made it all the way to the top, unhandled
-
-
-def colorlabel(label):
-    """color a label based on its own text"""
-    txt = label['text'] or "0"
-    lev = float(txt) / 100
-    low = (80, 80, 180)
-    high = (255, 55, 0o50)
-    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
-    col = "#%02X%02X%02X" % tuple(out)  # type: ignore
-    label.config(bg=col)
-
-
-# TODO: get everyone to use this
-def colorfade(low, high, percent):
-    '''not foolproof.  make sure 0 < percent < 1'''
-    out = [int(l + percent * (h - l)) for h, l in zip(high, low)]
-    col = "#%02X%02X%02X" % tuple(out)  # type: ignore
-    return col
-
-
-def colortotuple(anytkobj, colorname):
-    'pass any tk object and a color name, like "yellow"'
-    rgb = anytkobj.winfo_rgb(colorname)
-    return [v / 256 for v in rgb]
-
-
-class Togglebutton(Button):
-    """works like a single radiobutton, but it's a button so the
-    label's on the button face, not to the side. the optional command
-    callback is called on button set, not on unset. takes a variable
-    just like a checkbutton"""
-
-    def __init__(self,
-                 parent,
-                 variable=None,
-                 command=None,
-                 downcolor='red',
-                 **kw):
-
-        self.oldcommand = command
-        Button.__init__(self, parent, command=self.invoke, **kw)
-
-        self._origbkg = self.cget('bg')
-        self.downcolor = downcolor
-
-        self._variable = variable
-        if self._variable:
-            self._variable.trace('w', self._varchanged)
-            self._setstate(self._variable.get())
-        else:
-            self._setstate(0)
-
-        self.bind("<Return>", self.invoke)
-        self.bind("<1>", self.invoke)
-        self.bind("<space>", self.invoke)
-
-    def _varchanged(self, *args):
-        self._setstate(self._variable.get())
-
-    def invoke(self, *ev):
-        if self._variable:
-            self._variable.set(not self.state)
-        else:
-            self._setstate(not self.state)
-
-        if self.oldcommand and self.state:  # call command only when state goes to 1
-            self.oldcommand()
-        return "break"
-
-    def _setstate(self, newstate):
-        self.state = newstate
-        if newstate:  # set
-            self.config(bg=self.downcolor, relief='sunken')
-        else:  # unset
-            self.config(bg=self._origbkg, relief='raised')
-        return "break"
-
-
-class FancyDoubleVar(DoubleVar):
-
-    def __init__(self, master=None):
-        DoubleVar.__init__(self, master)
-        self.callbacklist: Dict[str, str] = {}  # cbname : mode
-        self.namedtraces: Dict[str, str] = {}  # name : cbname
-
-    def trace_variable(self, mode, callback):
-        """Define a trace callback for the variable.
-
-        MODE is one of "r", "w", "u" for read, write, undefine.
-        CALLBACK must be a function which is called when
-        the variable is read, written or undefined.
-
-        Return the name of the callback.
-        """
-        cbname = self._master._register(callback)
-        self._tk.call("trace", "variable", self._name, mode, cbname)
-
-        # we build a list of the trace callbacks (the py functrions and the tcl functionnames)
-        self.callbacklist[cbname] = mode
-        #        print "added trace:",callback,cbname
-
-        return cbname
-
-    trace = trace_variable
-
-    def disable_traces(self):
-        for cb, mode in list(self.callbacklist.items()):
-            #            DoubleVar.trace_vdelete(self,v[0],k)
-            self._tk.call("trace", "vdelete", self._name, mode, cb)
-            # but no master delete!
-
-    def recreate_traces(self):
-        for cb, mode in list(self.callbacklist.items()):
-            #            self.trace_variable(v[0],v[1])
-            self._tk.call("trace", "variable", self._name, mode, cb)
-
-    def trace_named(self, name, callback):
-        if name in self.namedtraces:
-            print(
-                "FancyDoubleVar: already had a trace named %s - replacing it" %
-                name)
-            self.delete_named(name)
-
-        cbname = self.trace_variable(
-            'w', callback)  # this will register in self.callbacklist too
-
-        self.namedtraces[name] = cbname
-        return cbname
-
-    def delete_named(self, name):
-        if name in self.namedtraces:
-
-            cbname = self.namedtraces[name]
-
-            self.trace_vdelete('w', cbname)
-            #self._tk.call("trace","vdelete",self._name,'w',cbname)
-            print("FancyDoubleVar: successfully deleted trace named %s" % name)
-        else:
-            print(
-                "FancyDoubleVar: attempted to delete named %s which wasn't set to any function"
-                % name)
-
-
-def get_selection(listbox):
-    'Given a listbox, returns first selection as integer'
-    selection = int(listbox.curselection()[0])  # blech
-    return selection
-
-
-if __name__ == '__main__':
-    root = Tk()
-    root.tk_focusFollowsMouse()
-    iv = IntVar()
-
-    def cb():
-        print("cb!")
-
-    t = Togglebutton(root, text="testbutton", command=cb, variable=iv)
-    t.pack()
-    Entry(root, textvariable=iv).pack()
-    root.mainloop()
--- a/light9/updatefreq.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-"""calculates your updates-per-second"""
-
-import time
-
-
-class Updatefreq:
-    """make one of these, call update() on it as much as you want,
-    and then float() or str() the object to learn the updates per second.
-
-    the samples param to __init__ specifies how many past updates will
-    be stored.  """
-
-    def __init__(self, samples=20):
-        self.times = [0]
-        self.samples = samples
-
-    def update(self):
-        """call this every time you do an update"""
-        self.times = self.times[-self.samples:]
-        self.times.append(time.time())
-
-    def __float__(self):
-        """a cheap algorithm, for now, which looks at the first and
-        last times only"""
-
-        try:
-            hz = len(self.times) / (self.times[-1] - self.times[0])
-        except ZeroDivisionError:
-            return 0.0
-        return hz
-
-    def __str__(self):
-        return "%.2fHz" % float(self)
--- a/light9/vidref/gui.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-var model = {
-    shutters: [],
-};
-for (var s=0; s < 1; s+=.04) {
-    var micro = Math.floor(Math.pow(s, 3) * 100000)
-    if (micro == 0) {
-        continue;
-    }
-    model.shutters.push(micro);
-}
-ko.applyBindings(model)
--- a/light9/vidref/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,57 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>vidref</title>
-    <meta charset="utf-8" />
-     <link rel="stylesheet" href="/style.css">
-
-    <script src="/lib/debug/debug-build.js"></script>
-    <script>
-     debug.enable('*');
-    </script>
-    <script src="/websocket.js"></script>
-    <script type="module" src="/light9-vidref-live.js"></script>
-    <script type="module" src="/light9-vidref-replay-stack.js"></script>
-  </head>
-  <body>
-    <style>
-     #rs {
-         width: 100%;
-     }
-    </style>
-    <h1>vidref</h1>
-    <div>
-      <light9-vidref-live></light9-vidref-live>
-    </div>
-    <light9-vidref-replay-stack id="rs"></light9-vidref-replay-stack>
-    <div class="keys">Keys:
-      <span class="keyCap">s</span> stop,
-      <span class="keyCap">p</span> play,
-      <span class="keyCap">,/.</span> step
-    </div>
-      <script>
-       const log = debug('index');
-       document.addEventListener('keypress', (ev) => {
-         const nudge = (dt) => {
-           const newTime = document.querySelector('#rs').songTime + dt;
-           fetch('/ascoltami/seekPlayOrPause', {
-             method: 'POST',
-             body: JSON.stringify({scrub: newTime}),
-           });
-         };
-         
-         if (ev.code == 'KeyP') {
-           fetch('/ascoltami/seekPlayOrPause',
-                 {method: 'POST', body: JSON.stringify({action: 'play'})}); 
-         } else if (ev.code == 'KeyS') {
-           fetch('/ascoltami/seekPlayOrPause',
-                 {method: 'POST', body: JSON.stringify({action: 'pause'})});
-         } else if (ev.code == 'Comma') {
-           nudge(-.1);
-         } else if (ev.code == 'Period') {
-           nudge(.1);
-         }
-       });
-      </script>
-  </body>
-</html>
--- a/light9/vidref/main.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,110 +0,0 @@
-#!/usr/bin/python
-"""
-
-dvcam test
-gst-launch dv1394src ! dvdemux name=d ! dvdec ! ffmpegcolorspace ! hqdn3d ! xvimagesink
-
-"""
-import gobject, logging, traceback
-import gtk
-from twisted.python.util import sibpath
-from light9.vidref.replay import ReplayViews, framerate
-from light9.ascoltami.musictime_client import MusicTime
-from light9.vidref.videorecorder import Pipeline
-from light9.vidref import remotepivideo
-log = logging.getLogger()
-
-
-class Gui(object):
-
-    def __init__(self, graph):
-        wtree = gtk.Builder()
-        wtree.add_from_file(sibpath(__file__, "vidref.glade"))
-        mainwin = wtree.get_object("MainWindow")
-        mainwin.connect("destroy", gtk.main_quit)
-        wtree.connect_signals(self)
-        gtk.rc_parse("theme/marble-ice/gtk-2.0/gtkrc")
-
-        self.recordingTo = wtree.get_object('recordingTo')
-        self.musicScale = wtree.get_object("musicScale")
-        self.musicScale.connect("value-changed", self.onMusicScaleValue)
-        # tiny race here if onMusicScaleValue tries to use musicTime right away
-        self.musicTime = MusicTime(onChange=self.onMusicTimeChange,
-                                   pollCurvecalc=False)
-        self.ignoreScaleChanges = False
-        # self.attachLog(wtree.get_object("lastLog")) # disabled due to crashing
-
-        # wtree.get_object("replayPanel").show() # demo only
-        rp = wtree.get_object("replayVbox")
-        self.replayViews = ReplayViews(rp)
-
-        mainwin.show_all()
-        vid3 = wtree.get_object("vid3")
-
-        if 0:
-            self.pipeline = Pipeline(liveVideoXid=vid3.window.xid,
-                                     musicTime=self.musicTime,
-                                     recordingTo=self.recordingTo)
-        else:
-            self.pipeline = remotepivideo.Pipeline(liveVideo=vid3,
-                                                   musicTime=self.musicTime,
-                                                   recordingTo=self.recordingTo,
-                                                   graph=graph)
-
-        vid3.props.width_request = 360
-        vid3.props.height_request = 220
-        wtree.get_object("frame1").props.height_request = 220
-
-        self.pipeline.setInput('v4l')  # auto seems to not search for dv
-
-        gobject.timeout_add(1000 // framerate, self.updateLoop)
-
-    def snapshot(self):
-        return self.pipeline.snapshot()
-
-    def attachLog(self, textBuffer):
-        """write log lines to this gtk buffer"""
-
-        class ToBuffer(logging.Handler):
-
-            def emit(self, record):
-                textBuffer.set_text(record.getMessage())
-
-        h = ToBuffer()
-        h.setLevel(logging.INFO)
-        log.addHandler(h)
-
-    def updateLoop(self):
-        position = self.musicTime.getLatest()
-        try:
-            with gtk.gdk.lock:
-                self.replayViews.update(position)
-        except Exception:
-            traceback.print_exc()
-        return True
-
-    def getInputs(self):
-        return ['auto', 'dv', 'video0']
-
-    def on_liveVideoEnabled_toggled(self, widget):
-        self.pipeline.setLiveVideo(widget.get_active())
-
-    def on_liveFrameRate_value_changed(self, widget):
-        print(widget.get_value())
-
-    def onMusicTimeChange(self, position):
-        self.ignoreScaleChanges = True
-        try:
-            self.musicScale.set_range(0, position['duration'])
-            self.musicScale.set_value(position['t'])
-        finally:
-            self.ignoreScaleChanges = False
-
-    def onMusicScaleValue(self, scaleRange):
-        """the scale position has changed. if it was by the user, send
-        it back to music player"""
-        if not self.ignoreScaleChanges:
-            self.musicTime.sendTime(scaleRange.get_value())
-
-    def incomingTime(self, t, source):
-        self.musicTime.lastHoverTime = t
--- a/light9/vidref/moviestore.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,106 +0,0 @@
-import os
-from bisect import bisect_left
-from rdflib import URIRef
-import sys
-sys.path.append(
-    '/home/drewp/Downloads/moviepy/lib/python2.7/site-packages')  # for moviepy
-from moviepy.video.io.ffmpeg_writer import FFMPEG_VideoWriter
-from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader
-
-
-class _ResourceDir(object):
-    """the disk files for a resource"""
-
-    def __init__(self, root, uri):
-        self.root, self.uri = root, uri
-        u = self.uri.replace('http://', '').replace('/', '_')
-        self.topDir = os.path.join(self.root, u)
-        try:
-            os.makedirs(self.topDir)
-        except OSError:
-            pass
-
-    def videoPath(self):
-        return os.path.join(self.topDir, 'video.avi')
-
-    def indexPath(self):
-        return os.path.join(self.topDir, 'frame_times')
-
-
-class Writer(object):
-    """saves a video of a resource, receiving a frame at a time. Frame timing does not have to be regular."""
-
-    def __init__(self, root, uri):
-        self.rd = _ResourceDir(root, uri)
-        self.ffmpegWriter = None  # lazy since we don't know the size yet
-        self.index = open(self.rd.indexPath(), 'w')
-        self.framesWritten = 0
-
-    def save(self, t, img):
-        if self.ffmpegWriter is None:
-            self.ffmpegWriter = FFMPEG_VideoWriter(
-                filename=self.rd.videoPath(),
-                size=img.size,
-                fps=10,  # doesn't matter, just for debugging playbacks
-                codec='libx264')
-        self.ffmpegWriter.write_frame(img)
-        self.framesWritten = self.framesWritten + 1
-        self.index.write('%d %g\n' % (self.framesWritten, t))
-
-    def close(self):
-        if self.ffmpegWriter is not None:
-            self.ffmpegWriter.close()
-        self.index.close()
-
-
-class Reader(object):
-
-    def __init__(self, resourceDir):
-        self.timeFrame = []
-        for line in open(resourceDir.indexPath()):
-            f, t = line.strip().split()
-            self.timeFrame.append((float(t), int(f)))
-        self._reader = FFMPEG_VideoReader(resourceDir.videoPath())
-
-    def getFrame(self, t):
-        i = bisect_left(self.timeFrame, (t, None))
-        i = min(i, len(self.timeFrame) - 1)
-        f = self.timeFrame[i][1]
-        return self._reader.get_frame(f)
-
-
-class MultiReader(object):
-    """loads the nearest existing frame of a resource's video. Supports random access of multiple resources."""
-
-    def __init__(self, root):
-        self.root = root
-        # these should cleanup when they haven't been used in a while
-        self.readers = {}  # uri: Reader
-
-    def getFrame(self, uri, t):
-        if uri not in self.readers:
-            #self.readers.close all and pop them
-            self.readers[uri] = Reader(_ResourceDir(self.root, uri))
-        return self.readers[uri].getFrame(t)
-
-
-if __name__ == '__main__':
-    from PIL import Image
-    take = URIRef(
-        'http://light9.bigasterisk.com/show/dance2015/song10/1434249076/')
-    if 0:
-        w = Writer('/tmp/ms', take)
-        for fn in sorted(
-                os.listdir(
-                    '/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2015_song10/1434249076'
-                )):
-            t = float(fn.replace('.jpg', ''))
-            jpg = Image.open(
-                '/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2015_song10/1434249076/%08.03f.jpg'
-                % t)
-            jpg = jpg.resize((450, 176))
-            w.save(t, jpg)
-        w.close()
-    else:
-        r = MultiReader('/tmp/ms')
-        print((r.getFrame(take, 5.6)))
--- a/light9/vidref/remotepivideo.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,143 +0,0 @@
-"""
-like videorecorder.py, but talks to a bin/picamserve instance
-"""
-import os, time, logging
-import gtk
-import numpy
-import treq
-from twisted.internet import defer
-from light9.vidref.replay import songDir, takeDir, snapshotDir
-from light9 import showconfig
-from light9.namespaces import L9
-from PIL import Image
-from io import StringIO
-log = logging.getLogger('remotepi')
-
-
-class Pipeline(object):
-
-    def __init__(self, liveVideo, musicTime, recordingTo, graph):
-        self.musicTime = musicTime
-        self.recordingTo = recordingTo
-
-        self.liveVideo = self._replaceLiveVideoWidget(liveVideo)
-
-        self._snapshotRequests = []
-        self.graph = graph
-        self.graph.addHandler(self.updateCamUrl)
-
-    def updateCamUrl(self):
-        show = showconfig.showUri()
-        self.picsUrl = self.graph.value(show, L9['vidrefCamRequest'])
-        log.info("picsUrl now %r", self.picsUrl)
-        if not self.picsUrl:
-            return
-
-        # this cannot yet survive being called a second time
-        self._startRequest(str(self.picsUrl))
-
-    def _replaceLiveVideoWidget(self, liveVideo):
-        aspectFrame = liveVideo.get_parent()
-        liveVideo.destroy()
-        img = gtk.Image()
-        img.set_visible(True)
-        #img.set_size_request(320, 240)
-        aspectFrame.add(img)
-        return img
-
-    def _startRequest(self, url):
-        self._buffer = ''
-        log.info('start request to %r', url)
-        d = treq.get(url)
-        d.addCallback(treq.collect, self._dataReceived)
-        # not sure how to stop this
-        return d
-
-    def _dataReceived(self, chunk):
-        self._buffer += chunk
-        if len(self._buffer) < 100:
-            return
-        i = self._buffer.index('\n')
-        size, frameTime = self._buffer[:i].split()
-        size = int(size)
-        if len(self._buffer) - i - 1 < size:
-            return
-        jpg = self._buffer[i + 1:i + 1 + size]
-        self.onFrame(jpg, float(frameTime))
-        self._buffer = self._buffer[i + 1 + size:]
-
-    def snapshot(self):
-        """
-        returns deferred to the path (which is under snapshotDir()) where
-        we saved the image.
-        """
-        filename = "%s/%s.jpg" % (snapshotDir(), time.time())
-        d = defer.Deferred()
-        self._snapshotRequests.append((d, filename))
-        return d
-
-    def setInput(self, name):
-        pass
-
-    def setLiveVideo(self, on):
-        print("setLiveVideo", on)
-
-    def onFrame(self, jpg, frameTime):
-        # We could pass frameTime here to try to compensate for lag,
-        # but it ended up looking worse in a test. One suspect is the
-        # rpi clock drift might be worse than the lag. The value of
-        # (now - frameTime) stutters regularly between 40ms, 140ms,
-        # and 200ms.
-        position = self.musicTime.getLatest()
-
-        for d, filename in self._snapshotRequests:
-            with open(filename, 'w') as out:
-                out.write(jpg)
-            d.callback(filename)
-        self._snapshotRequests[:] = []
-
-        if not position['song']:
-            self.updateLiveFromTemp(jpg)
-            return
-        outDir = takeDir(songDir(position['song']), position['started'])
-        outFilename = "%s/%08.03f.jpg" % (outDir, position['t'])
-        if os.path.exists(outFilename):  # we're paused on one time
-            self.updateLiveFromTemp(jpg)
-            return
-        try:
-            os.makedirs(outDir)
-        except OSError:
-            pass
-        with open(outFilename, 'w') as out:
-            out.write(jpg)
-
-        self.updateLiveFromFile(outFilename)
-
-        # if you're selecting the text while gtk is updating it,
-        # you can get a crash in xcb_io
-        if getattr(self, '_lastRecText', None) != outDir:
-            with gtk.gdk.lock:
-                self.recordingTo.set_text(outDir)
-            self._lastRecText = outDir
-
-    def updateLiveFromFile(self, outFilename):
-        self.liveVideo.set_from_file(outFilename)
-
-    def updateLiveFromTemp(self, jpg):
-        try:
-            img = Image.open(StringIO(jpg))
-            if not hasattr(self, 'livePixBuf'):
-                self.livePixBuf = gtk.gdk.pixbuf_new_from_data(
-                    img.tobytes(), gtk.gdk.COLORSPACE_RGB, False, 8,
-                    img.size[0], img.size[1], img.size[0] * 3)
-                log.info("live images are %r", img.size)
-            else:
-                # don't leak pixbufs; update the one we have
-                a = self.livePixBuf.pixel_array
-                newImg = numpy.fromstring(img.tobytes(), dtype=numpy.uint8)
-                a[:, :, :] = newImg.reshape(a.shape)
-            self.liveVideo.set_from_pixbuf(self.livePixBuf)
-
-        except Exception:
-            import traceback
-            traceback.print_exc()
--- a/light9/vidref/setup.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>vidref setup</title>
-    <meta charset="utf-8" />
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <link rel="stylesheet" href="/style.css">
-    <script src="/lib/jquery/dist/jquery.slim.min.js"></script>
-
-    <script src="/websocket.js"></script>
-    <script type="module"  src="/light9-vidref-live.js"></script>
-
-  </head>
-  <body>
-    Live:
-    <light9-vidref-live></light9-vidref-live>
-
-    
-  </body>
-</html>
--- a/light9/vidref/videorecorder.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,338 +0,0 @@
-from dataclasses import dataclass
-from io import BytesIO
-from typing import Optional
-import time, logging, os, traceback
-
-import gi
-gi.require_version('Gst', '1.0')
-gi.require_version('GstBase', '1.0')
-
-from gi.repository import Gst
-from rdflib import URIRef
-from rx.subject import BehaviorSubject
-from twisted.internet import threads
-import PIL.Image
-import moviepy.editor
-import numpy
-
-from light9 import showconfig
-from light9.ascoltami.musictime_client import MusicTime
-from light9.newtypes import Song
-from light9.metrics import metrics
-log = logging.getLogger()
-
-
-@dataclass
-class CaptureFrame:
-    img: PIL.Image
-    song: Song
-    t: float
-    isPlaying: bool
-    imgJpeg: Optional[bytes] = None
-
-    @metrics('jpeg_encode').time()
-    def asJpeg(self):
-        if not self.imgJpeg:
-            output = BytesIO()
-            self.img.save(output, 'jpeg', quality=80)
-            self.imgJpeg = output.getvalue()
-        return self.imgJpeg
-
-
-def songDir(song: Song) -> bytes:
-    return os.path.join(
-        showconfig.root(), b'video',
-        song.replace('http://', '').replace('/', '_').encode('ascii'))
-
-
-def takeUri(songPath: bytes) -> URIRef:
-    p = songPath.decode('ascii').split('/')
-    take = p[-1].replace('.mp4', '')
-    song = p[-2].split('_')
-    return URIRef('/'.join(
-        ['http://light9.bigasterisk.com/show', song[-2], song[-1], take]))
-
-
-def deleteClip(uri: URIRef):
-    # uri http://light9.bigasterisk.com/show/dance2019/song6/take_155
-    # path show/dance2019/video/light9.bigasterisk.com_show_dance2019_song6/take_155.*
-    w = uri.split('/')[-4:]
-    path = '/'.join([
-        w[0], w[1], 'video', f'light9.bigasterisk.com_{w[0]}_{w[1]}_{w[2]}',
-        w[3]
-    ])
-    log.info(f'deleting {uri} {path}')
-    metrics('deletes').incr()
-    for fn in [path + '.mp4', path + '.timing']:
-        os.remove(fn)
-
-
-class FramesToVideoFiles:
-    """
-
-    nextWriteAction: 'ignore'
-    currentOutputClip: None
-
-    (frames come in for new video)
-    nextWriteAction: 'saveFrame'
-    currentOutputClip: new VideoClip
-    (many frames)
-
-    (music stops or song changes)
-    nextWriteAction: 'close'
-    currentOutputClip: None
-    nextWriteAction: 'ignore'
-    
-    """
-
-    def __init__(self, frames: BehaviorSubject, root: bytes):
-        self.frames = frames
-        self.root = root
-        self.nextImg: Optional[CaptureFrame] = None
-
-        self.currentOutputClip: Optional[moviepy.editor.VideoClip] = None
-        self.currentOutputSong: Optional[Song] = None
-        self.nextWriteAction = 'ignore'
-        self.frames.subscribe(on_next=self.onFrame)
-
-    def onFrame(self, cf: Optional[CaptureFrame]):
-        if cf is None:
-            return
-        self.nextImg = cf
-
-        if self.currentOutputClip is None and cf.isPlaying:
-            # start up
-            self.nextWriteAction = 'saveFrames'
-            self.currentOutputSong = cf.song
-            self.save(
-                os.path.join(songDir(cf.song), b'take_%d' % int(time.time())))
-        elif self.currentOutputClip and cf.isPlaying:
-            self.nextWriteAction = 'saveFrames'
-            # continue recording this
-        elif self.currentOutputClip is None and not cf.isPlaying:
-            self.nextWriteAction = 'notWritingClip'
-            pass  # continue waiting
-        elif self.currentOutputClip and not cf.isPlaying or self.currentOutputSong != cf.song:
-            # stop
-            self.nextWriteAction = 'close'
-        else:
-            raise NotImplementedError(str(vars()))
-
-    def save(self, outBase):
-        """
-        receive frames (infinite) and wall-to-song times (stream ends with
-        the song), and write a video file and a frame map
-        """
-        return threads.deferToThread(self._bg_save, outBase)
-
-    def _bg_save(self, outBase: bytes):
-        os.makedirs(os.path.dirname(outBase), exist_ok=True)
-        self.frameMap = open(outBase + b'.timing', 'wt')
-
-        # todo: see moviestore.py for a better-looking version where
-        # we get to call write_frame on a FFMPEG_VideoWriter instead
-        # of it calling us back.
-
-        self.currentClipFrameCount = 0
-
-        # (immediately calls make_frame)
-        self.currentOutputClip = moviepy.editor.VideoClip(self._bg_make_frame,
-                                                          duration=999.)
-        # The fps recorded in the file doesn't matter much; we'll play
-        # it back in sync with the music regardless.
-        self.currentOutputClip.fps = 10
-        log.info(f'write_videofile {outBase} start')
-        try:
-            self.outMp4 = outBase.decode('ascii') + '.mp4'
-            self.currentOutputClip.write_videofile(self.outMp4,
-                                                   codec='libx264',
-                                                   audio=False,
-                                                   preset='ultrafast',
-                                                   logger=None,
-                                                   ffmpeg_params=['-g', '10'],
-                                                   bitrate='150000')
-        except (StopIteration, RuntimeError):
-            self.frameMap.close()
-
-        log.info('write_videofile done')
-        self.currentOutputClip = None
-
-        if self.currentClipFrameCount < 400:
-            log.info('too small- deleting')
-            deleteClip(takeUri(self.outMp4.encode('ascii')))
-
-    def _bg_make_frame(self, video_time_secs):
-        metrics('encodeFrameFps').incr()
-        if self.nextWriteAction == 'close':
-            raise StopIteration  # the one in write_videofile
-        elif self.nextWriteAction == 'notWritingClip':
-            raise NotImplementedError
-        elif self.nextWriteAction == 'saveFrames':
-            pass
-        else:
-            raise NotImplementedError(self.nextWriteAction)
-
-        # should be a little queue to miss fewer frames
-        t1 = time.time()
-        while self.nextImg is None:
-            time.sleep(.015)
-        metrics('wait_for_next_img').observe(time.time() - t1)
-        cf, self.nextImg = self.nextImg, None
-
-        self.frameMap.write(f'video {video_time_secs:g} = song {cf.t:g}\n')
-        self.currentClipFrameCount += 1
-        return numpy.asarray(cf.img)
-
-
-class GstSource:
-
-    def __init__(self, dev):
-        """
-        make new gst pipeline
-        """
-        Gst.init(None)
-        self.musicTime = MusicTime(pollCurvecalc=False)
-        self.liveImages: BehaviorSubject = BehaviorSubject(
-            None)  # stream of Optional[CaptureFrame]
-
-        # need to use 640,480 on some webcams or they fail mysteriously
-        size = [800, 600]
-
-        log.info("new pipeline using device=%s" % dev)
-
-        # using videocrop breaks the pipeline, may be this issue
-        # https://gitlab.freedesktop.org/gstreamer/gst-plugins-bad/issues/732
-        pipeStr = (
-            f"v4l2src device=\"{dev}\""
-            #            f'autovideosrc'
-            f" ! videoconvert"
-            f" ! appsink emit-signals=true max-buffers=1 drop=true name=end0 caps=video/x-raw,format=RGB,width={size[0]},height={size[1]}"
-        )
-        log.info("pipeline: %s" % pipeStr)
-
-        self.pipe = Gst.parse_launch(pipeStr)
-
-        self.setupPipelineError(self.pipe, self.onError)
-
-        self.appsink = self.pipe.get_by_name('end0')
-        self.appsink.connect('new-sample', self.new_sample)
-
-        self.pipe.set_state(Gst.State.PLAYING)
-        log.info('gst pipeline is recording video')
-
-    def new_sample(self, appsink):
-        try:
-            sample = appsink.emit('pull-sample')
-            caps = sample.get_caps()
-            buf = sample.get_buffer()
-            (result, mapinfo) = buf.map(Gst.MapFlags.READ)
-            try:
-                img = PIL.Image.frombytes(
-                    'RGB', (caps.get_structure(0).get_value('width'),
-                            caps.get_structure(0).get_value('height')),
-                    mapinfo.data)
-                img = self.crop(img)
-            finally:
-                buf.unmap(mapinfo)
-            # could get gst's frame time and pass it to getLatest
-            latest = self.musicTime.getLatest()
-            if 'song' in latest:
-                metrics('queue_gst_frame_fps').incr()
-                self.liveImages.on_next(
-                    CaptureFrame(img=img,
-                                 song=Song(latest['song']),
-                                 t=latest['t'],
-                                 isPlaying=latest['playing']))
-        except Exception:
-            traceback.print_exc()
-        return Gst.FlowReturn.OK
-
-    @metrics('crop').time()
-    def crop(self, img):
-        return img.crop((40, 100, 790, 310))
-
-    def setupPipelineError(self, pipe, cb):
-        bus = pipe.get_bus()
-
-        def onBusMessage(bus, msg):
-
-            print('nusmsg', msg)
-            if msg.type == Gst.MessageType.ERROR:
-                _, txt = msg.parse_error()
-                cb(txt)
-            return True
-
-        # not working; use GST_DEBUG=4 to see errors
-        bus.add_watch(0, onBusMessage)
-        bus.connect('message', onBusMessage)
-
-    def onError(self, messageText):
-        if ('v4l2src' in messageText and
-            ('No such file or directory' in messageText or
-             'Resource temporarily unavailable' in messageText or
-             'No such device' in messageText)):
-            log.error(messageText)
-            os.abort()
-        else:
-            log.error("ignoring error: %r" % messageText)
-
-
-'''
-class oldPipeline(object):
-
-    def __init__(self):
-        self.snapshotRequests = Queue()
-
-    def snapshot(self):
-        """
-        returns deferred to the path (which is under snapshotDir()) where
-        we saved the image. This callback comes from another thread,
-        but I haven't noticed that being a problem yet.
-        """
-        d = defer.Deferred()
-
-        def req(frame):
-            filename = "%s/%s.jpg" % ('todo', time.time())
-            log.debug("received snapshot; saving in %s", filename)
-            frame.save(filename)
-            d.callback(filename)
-
-        log.debug("requesting snapshot")
-        self.snapshotRequests.put(req)
-        return d
-
-
-        self.imagesToSave = Queue()
-        self.startBackgroundImageSaver(self.imagesToSave)
-
-    def startBackgroundImageSaver(self, imagesToSave):
-        """do image saves in another thread to not block gst"""
-
-        def imageSaver():
-            while True:
-                args = imagesToSave.get()
-                self.saveImg(*args)
-                imagesToSave.task_done()
-
-                # this is not an ideal place for snapshotRequests
-                # since imagesToSave is allowed to get backed up with
-                # image writes, yet we would still want the next new
-                # image to be used for the snapshot. chainfunc should
-                # put snapshot images in a separate-but-similar queue
-                # to imagesToSave, and then another watcher could use
-                # those to satisfy snapshot requests
-                try:
-                    req = self.snapshotRequests.get(block=False)
-                except Empty:
-                    pass
-                else:
-                    req(args[1])
-                    self.snapshotRequests.task_done()
-
-        t = Thread(target=imageSaver)
-        t.setDaemon(True)
-        t.start()
-
-    def chainfunc(self, pad, buffer):
-        position = self.musicTime.getLatest()
-'''
--- a/light9/vidref/vidref.glade	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,487 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<interface>
-  <requires lib="gtk+" version="2.16"/>
-  <!-- interface-naming-policy project-wide -->
-  <object class="GtkWindow" id="MainWindow">
-    <property name="can_focus">False</property>
-    <property name="title" translatable="yes">vidref</property>
-    <property name="default_width">690</property>
-    <property name="default_height">500</property>
-    <child>
-      <object class="GtkVBox" id="vbox1">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <child>
-          <object class="GtkHBox" id="hbox3">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkVBox" id="vbox3">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <child>
-                  <object class="GtkFrame" id="frame1">
-                    <property name="width_request">450</property>
-                    <property name="height_request">277</property>
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label_xalign">0</property>
-                    <property name="shadow_type">out</property>
-                    <child>
-                      <object class="GtkAspectFrame" id="aspectframe2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label_xalign">0</property>
-                        <property name="shadow_type">none</property>
-                        <property name="ratio">1.3300000429153442</property>
-                        <child>
-                          <object class="GtkDrawingArea" id="vid3">
-                            <property name="width_request">320</property>
-                            <property name="height_request">240</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                          </object>
-                        </child>
-                      </object>
-                    </child>
-                    <child type="label">
-                      <object class="GtkLabel" id="label2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">&lt;b&gt;Live view&lt;/b&gt;</property>
-                        <property name="use_markup">True</property>
-                      </object>
-                    </child>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">0</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkVBox" id="vbox4">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkToggleButton" id="liveVideoEnabled">
-                        <property name="label" translatable="yes">Enabled</property>
-                        <property name="width_request">110</property>
-                        <property name="height_request">36</property>
-                        <property name="visible">True</property>
-                        <property name="can_focus">True</property>
-                        <property name="receives_default">True</property>
-                        <property name="use_action_appearance">False</property>
-                        <property name="active">True</property>
-                        <signal name="toggled" handler="on_liveVideoEnabled_toggled" swapped="no"/>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">True</property>
-                        <property name="position">0</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkHBox" id="hbox4">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <child>
-                          <object class="GtkLabel" id="label1">
-                            <property name="width_request">75</property>
-                            <property name="height_request">20</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">Frame rate:</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkSpinButton" id="liveFrameRate">
-                            <property name="width_request">52</property>
-                            <property name="height_request">25</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="invisible_char">●</property>
-                            <property name="primary_icon_activatable">False</property>
-                            <property name="secondary_icon_activatable">False</property>
-                            <property name="primary_icon_sensitive">True</property>
-                            <property name="secondary_icon_sensitive">True</property>
-                            <property name="numeric">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">True</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">True</property>
-                        <property name="position">1</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkHBox" id="hbox5">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <child>
-                          <object class="GtkLabel" id="label4">
-                            <property name="width_request">85</property>
-                            <property name="height_request">20</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">Input source:</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkComboBox" id="videoSource">
-                            <property name="width_request">100</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                          </object>
-                          <packing>
-                            <property name="expand">True</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">False</property>
-                        <property name="fill">True</property>
-                        <property name="position">2</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkHBox" id="hbox1">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <child>
-                          <object class="GtkLabel" id="label6">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">Recording
-to:</property>
-                          </object>
-                          <packing>
-                            <property name="expand">True</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkTextView" id="recordingToView">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="editable">False</property>
-                            <property name="wrap_mode">char</property>
-                            <property name="buffer">recordingTo</property>
-                          </object>
-                          <packing>
-                            <property name="expand">True</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">True</property>
-                        <property name="fill">True</property>
-                        <property name="position">3</property>
-                      </packing>
-                    </child>
-                    <child>
-                      <object class="GtkFrame" id="frame2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label_xalign">0</property>
-                        <property name="shadow_type">none</property>
-                        <child>
-                          <object class="GtkTextView" id="logView">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="wrap_mode">char</property>
-                            <property name="buffer">lastLog</property>
-                          </object>
-                        </child>
-                        <child type="label">
-                          <object class="GtkLabel" id="label8">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">&lt;b&gt;Last log&lt;/b&gt;</property>
-                            <property name="use_markup">True</property>
-                          </object>
-                        </child>
-                      </object>
-                      <packing>
-                        <property name="expand">True</property>
-                        <property name="fill">True</property>
-                        <property name="position">4</property>
-                      </packing>
-                    </child>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">1</property>
-                  </packing>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkFrame" id="replayHalf">
-                <property name="width_request">336</property>
-                <property name="height_request">259</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label_xalign">0</property>
-                <property name="shadow_type">out</property>
-                <child>
-                  <object class="GtkScrolledWindow" id="replayScrollWin">
-                    <property name="width_request">571</property>
-                    <property name="height_request">367</property>
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="hscrollbar_policy">automatic</property>
-                    <property name="vscrollbar_policy">automatic</property>
-                    <property name="shadow_type">out</property>
-                    <child>
-                      <object class="GtkViewport" id="replayScroll">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="resize_mode">queue</property>
-                        <child>
-                          <object class="GtkVBox" id="replayVbox">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <child>
-                              <placeholder/>
-                            </child>
-                            <child>
-                              <placeholder/>
-                            </child>
-                            <child>
-                              <placeholder/>
-                            </child>
-                          </object>
-                        </child>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-                <child type="label">
-                  <object class="GtkLabel" id="label3">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label" translatable="yes">&lt;b&gt;Playback 1&lt;/b&gt;</property>
-                    <property name="use_markup">True</property>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">True</property>
-                <property name="fill">True</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkFrame" id="musicPosition">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="label_xalign">0</property>
-            <property name="shadow_type">none</property>
-            <child>
-              <object class="GtkAlignment" id="alignment1">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="left_padding">12</property>
-                <child>
-                  <object class="GtkHScale" id="musicScale">
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="digits">2</property>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child type="label">
-              <object class="GtkLabel" id="label7">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">&lt;b&gt;Music position&lt;/b&gt;</property>
-                <property name="use_markup">True</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
-  <object class="GtkImage" id="image2">
-    <property name="visible">True</property>
-    <property name="can_focus">False</property>
-    <property name="stock">gtk-delete</property>
-  </object>
-  <object class="GtkTextBuffer" id="lastLog"/>
-  <object class="GtkTextBuffer" id="recordingTo">
-    <property name="text" translatable="yes">/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2010_song6/1276582699</property>
-  </object>
-  <object class="GtkWindow" id="replayPanel">
-    <property name="can_focus">False</property>
-    <child>
-      <object class="GtkHBox" id="replayPanel2">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <child>
-          <object class="GtkAspectFrame" id="aspectframe1">
-            <property name="width_request">320</property>
-            <property name="height_request">240</property>
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="label_xalign">0</property>
-            <property name="shadow_type">out</property>
-            <property name="ratio">1.3300000429153442</property>
-            <child>
-              <object class="GtkImage" id="image1">
-                <property name="width_request">320</property>
-                <property name="height_request">240</property>
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="stock">gtk-missing-image</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkVBox" id="vbox2">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <child>
-              <object class="GtkHBox" id="hbox2">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <child>
-                  <object class="GtkLabel" id="label5">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="label" translatable="yes">Started:</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">False</property>
-                    <property name="position">0</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkEntry" id="entry1">
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="editable">False</property>
-                    <property name="invisible_char">●</property>
-                    <property name="width_chars">12</property>
-                    <property name="text" translatable="yes">Sat 14:22:25</property>
-                    <property name="primary_icon_activatable">False</property>
-                    <property name="secondary_icon_activatable">False</property>
-                    <property name="primary_icon_sensitive">True</property>
-                    <property name="secondary_icon_sensitive">True</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">False</property>
-                    <property name="position">1</property>
-                  </packing>
-                </child>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">0</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkToggleButton" id="togglebutton1">
-                <property name="label" translatable="yes">Enabled</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_action_appearance">False</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">1</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkButton" id="button1">
-                <property name="label" translatable="yes">Delete</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">True</property>
-                <property name="use_action_appearance">False</property>
-                <property name="image">image2</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">2</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkCheckButton" id="checkbutton1">
-                <property name="label" translatable="yes">Pin to top</property>
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="receives_default">False</property>
-                <property name="use_action_appearance">False</property>
-                <property name="draw_indicator">True</property>
-              </object>
-              <packing>
-                <property name="expand">False</property>
-                <property name="fill">True</property>
-                <property name="position">3</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
-</interface>
--- a/light9/vidref/vidref.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,189 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>vidref</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css" type="text/css">
-    <style>
-     body {
-       background: black;
-       color: rgb(170, 170, 170);
-       font-family: sans-serif; 
-     }
-     a {
-       color: rgb(163, 163, 255);
-     }
-     input[type=range] { width: 400px; }
-     .smallUrl { font-size: 60%; }
-
-     .jcrop-holder {
-       position: absolute !important;
-       top: 0 !important;
-       background-color: initial !important;
-     }
-    </style>
-  </head>
-  <body>
-    <h1>video setup</h1>
-    
-    <div>Camera view</div>
-    <div>
-      <div style="position: relative; display: inline-block">
-        <img id="cam" src="/picamserve/pic?resize=500&awb_mode=auto&exposure_mode=auto&shutter=100000">
-        <div id="cover" style="position: absolute; left: 0; top: 0; right: 0; bottom: 0;"></div>
-      </div>
-    </div>
-   
-    <fieldset>
-      <legend>set these</legend>
-      <div><label>shutter <input type="range" min="1" max="100000" data-bind="value: params.shutter, valueUpdate: 'input'"></label></div>
-      <div><label>brightness <span data-bind="text: params.brightness"></span> <input type="range" min="0" max="100" step="1" data-bind="value: params.brightness, valueUpdate: 'input'"></label></div>
-      <div><label>exposure_mode
-        <select data-bind="value: params.exposure_mode">
-          <option>auto</option>
-          <option>fireworks</option>
-          <option>verylong</option>
-          <option>fixedfps</option>
-          <option>backlight</option>
-          <option>antishake</option>
-          <option>snow</option>
-          <option>sports</option>
-          <option>nightpreview</option>
-          <option>night</option>
-          <option>beach</option>
-          <option>spotlight</option>
-        </select>           
-      </label></div>
-      <div><label>exposure_compensation <span data-bind="text: params.exposure_compensation"></span> <input type="range" min="-25" max="25" step="1" data-bind="value: params.exposure_compensation, valueUpdate: 'input'"></label></div>
-      <div><label>awb_mode
-        <select data-bind="value: params.awb_mode">
-          <option>horizon</option>
-          <option>off</option>
-          <option>cloudy</option>
-          <option>shade</option>
-          <option>fluorescent</option>
-          <option>tungsten</option>
-          <option>auto</option>
-          <option>flash</option>
-          <option>sunlight</option>
-          <option>incandescent</option>
-        </select>
-      </label></div>
-      <div><label>redgain <input type="range" min="0" max="8" step=".1" data-bind="value: params.redgain, valueUpdate: 'input'"></label></div>
-      <div><label>bluegain <input type="range" min="0" max="8" step=".1" data-bind="value: params.bluegain, valueUpdate: 'input'"></label></div>
-      <div><label>iso <input type="range" min="100" max="800" step="20" list="isos" data-bind="value: params.iso, valueUpdate: 'input'"></label></div>
-      <datalist id="isos">
-        <option>100</option>
-        <option>200</option>
-        <option>320</option>
-        <option>400</option>
-        <option>500</option>
-        <option>640</option>
-        <option>800</option>
-      </datalist>
-      <div><label>rotation
-        <select data-bind="value: params.rotation">
-          <option>0</option>
-          <option>90</option>
-          <option>180</option>
-          <option>270</option>
-        </select>
-      </label></div>
-      <div>See <a href="http://picamera.readthedocs.org/en/release-1.4/api.html#picamera.PiCamera.ISO">picamera attribute docs</a></div>
-    </fieldset>
-
-    <div>Resulting url: <a class="smallUrl" data-bind="attr: {href: currentUrl}, text: currentUrl"></a></div>
-
-    <div>Resulting crop image:</div>
-    <div><img id="cropped"></div>
-
-    
-    <script src="/lib/knockout/dist/knockout.js"></script>
-    <script src="/lib/jquery/dist/jquery.min.js"></script>
-    <script src="/lib/underscore/underscore-min.js"></script>
-    <script src="/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js"></script>
-    <script>
-     jQuery(function () {
-       var model = {
-         baseUrl: ko.observable(),
-         crop: ko.observable({x: 0, y: 0, w: 1, h: 1}),
-         params: {
-           shutter: ko.observable(50000),
-           exposure_mode: ko.observable('auto'),
-           awb_mode: ko.observable('auto'),
-           brightness: ko.observable(50),
-           redgain: ko.observable(1),
-           bluegain: ko.observable(1),
-           iso: ko.observable(250),
-           exposure_compensation: ko.observable(0),
-           rotation: ko.observable(0),
-         }
-       };
-       model.currentUrl = ko.computed(assembleCamUrlWithCrop);
-
-       function getBaseUrl() {
-         $.ajax({
-           url: 'picUrl',
-           success: model.baseUrl
-         });
-       }
-       
-       function imageUpdatesForever(model, img, onFirstLoad) {
-         var everLoaded = false;
-         function onLoad(ev) {
-           if (ev.type == 'load' && !everLoaded) {
-             everLoaded = true;
-             onFirstLoad();
-           }
-           
-           var src = assembleCamUrl() + '&t=' + (+new Date());
-           img.src = src;
-
-           $("#cropped").attr({src: assembleCamUrlWithCrop()});
-         }
-         img.addEventListener('load', onLoad);
-         img.addEventListener('error', onLoad);
-         
-         onLoad({type: '<startup>'})
-       }
-       
-       function assembleCamUrl() {
-         if (!model.baseUrl()) {
-           return '#';
-         }
-         return model.baseUrl() + '?resize=1080&' + $.param(ko.toJS(model.params));
-       }
-       
-       function assembleCamUrlWithCrop() {
-         return assembleCamUrl() + '&' + $.param(model.crop());
-       }
-       
-       getBaseUrl();
-       
-       imageUpdatesForever(model, document.getElementById('cam'), function onFirstLoad() {
-         var crop = $('#cover').Jcrop({onChange: function (c) {
-           var size = this.getBounds();
-           model.crop({x: c.x / size[0], y: c.y / size[1], w: c.w / size[0], h: c.h / size[1]});
-         }}, function() {
-           this.setSelect([50, 50, 100, 100]);
-         });
-       });
-
-       var putVidrefCamRequest = _.debounce(
-           function(uri) {
-             $.ajax({
-               type: 'PUT',
-               url: 'vidrefCamRequest',
-               data: {uri: uri}
-             });
-           }, 1000);
-       ko.computed(function saver() {
-         var uri = assembleCamUrlWithCrop();
-         putVidrefCamRequest(uri);
-       });
-
-       ko.applyBindings(model);
-     });
-    </script>
-  </body>
-</html>
--- a/light9/vidref/vidref.ui	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,234 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<ui version="4.0">
- <class>MainWindow</class>
- <widget class="QMainWindow" name="MainWindow">
-  <property name="geometry">
-   <rect>
-    <x>0</x>
-    <y>0</y>
-    <width>863</width>
-    <height>728</height>
-   </rect>
-  </property>
-  <property name="windowTitle">
-   <string>MainWindow</string>
-  </property>
-  <widget class="QWidget" name="centralwidget">
-   <widget class="QLabel" name="label">
-    <property name="geometry">
-     <rect>
-      <x>20</x>
-      <y>260</y>
-      <width>251</width>
-      <height>16</height>
-     </rect>
-    </property>
-    <property name="text">
-     <string>Live view</string>
-    </property>
-   </widget>
-   <widget class="QWidget" name="liveView" native="true">
-    <property name="geometry">
-     <rect>
-      <x>20</x>
-      <y>10</y>
-      <width>320</width>
-      <height>240</height>
-     </rect>
-    </property>
-   </widget>
-   <widget class="QCheckBox" name="checkBox">
-    <property name="geometry">
-     <rect>
-      <x>50</x>
-      <y>280</y>
-      <width>121</width>
-      <height>19</height>
-     </rect>
-    </property>
-    <property name="text">
-     <string>enabled</string>
-    </property>
-    <property name="checked">
-     <bool>true</bool>
-    </property>
-   </widget>
-   <widget class="QTableWidget" name="tableWidget">
-    <property name="geometry">
-     <rect>
-      <x>50</x>
-      <y>470</y>
-      <width>171</width>
-      <height>121</height>
-     </rect>
-    </property>
-    <row>
-     <property name="text">
-      <string>song</string>
-     </property>
-    </row>
-    <row>
-     <property name="text">
-      <string>time</string>
-     </property>
-    </row>
-    <column>
-     <property name="text">
-      <string>value</string>
-     </property>
-    </column>
-    <item row="0" column="0">
-     <property name="text">
-      <string>whatever</string>
-     </property>
-    </item>
-   </widget>
-   <widget class="QLabel" name="label_3">
-    <property name="geometry">
-     <rect>
-      <x>40</x>
-      <y>340</y>
-      <width>52</width>
-      <height>13</height>
-     </rect>
-    </property>
-    <property name="text">
-     <string>Song</string>
-    </property>
-   </widget>
-   <widget class="QLabel" name="label_4">
-    <property name="geometry">
-     <rect>
-      <x>40</x>
-      <y>360</y>
-      <width>52</width>
-      <height>13</height>
-     </rect>
-    </property>
-    <property name="text">
-     <string>Time</string>
-    </property>
-   </widget>
-   <widget class="QLineEdit" name="lineEdit">
-    <property name="geometry">
-     <rect>
-      <x>90</x>
-      <y>330</y>
-      <width>113</width>
-      <height>23</height>
-     </rect>
-    </property>
-   </widget>
-   <widget class="QLineEdit" name="lineEdit_2">
-    <property name="geometry">
-     <rect>
-      <x>90</x>
-      <y>360</y>
-      <width>113</width>
-      <height>23</height>
-     </rect>
-    </property>
-   </widget>
-   <widget class="QScrollArea" name="scrollArea">
-    <property name="geometry">
-     <rect>
-      <x>570</x>
-      <y>330</y>
-      <width>191</width>
-      <height>191</height>
-     </rect>
-    </property>
-    <property name="widgetResizable">
-     <bool>true</bool>
-    </property>
-    <widget class="QWidget" name="scrollAreaWidgetContents">
-     <property name="geometry">
-      <rect>
-       <x>0</x>
-       <y>0</y>
-       <width>189</width>
-       <height>189</height>
-      </rect>
-     </property>
-    </widget>
-   </widget>
-   <widget class="QGroupBox" name="groupBox">
-    <property name="geometry">
-     <rect>
-      <x>270</x>
-      <y>310</y>
-      <width>411</width>
-      <height>331</height>
-     </rect>
-    </property>
-    <property name="title">
-     <string>Replay from 16:10</string>
-    </property>
-    <widget class="QGraphicsView" name="graphicsView_2">
-     <property name="geometry">
-      <rect>
-       <x>20</x>
-       <y>30</y>
-       <width>311</width>
-       <height>231</height>
-      </rect>
-     </property>
-    </widget>
-    <widget class="QCheckBox" name="checkBox_2">
-     <property name="geometry">
-      <rect>
-       <x>60</x>
-       <y>270</y>
-       <width>191</width>
-       <height>19</height>
-      </rect>
-     </property>
-     <property name="text">
-      <string>follow current time</string>
-     </property>
-    </widget>
-    <zorder>graphicsView_2</zorder>
-    <zorder>graphicsView_2</zorder>
-    <zorder>checkBox_2</zorder>
-   </widget>
-  </widget>
-  <widget class="QMenuBar" name="menubar">
-   <property name="geometry">
-    <rect>
-     <x>0</x>
-     <y>0</y>
-     <width>863</width>
-     <height>21</height>
-    </rect>
-   </property>
-  </widget>
-  <widget class="QStatusBar" name="statusbar"/>
-  <widget class="QToolBar" name="toolBar">
-   <property name="windowTitle">
-    <string>toolBar</string>
-   </property>
-   <attribute name="toolBarArea">
-    <enum>TopToolBarArea</enum>
-   </attribute>
-   <attribute name="toolBarBreak">
-    <bool>false</bool>
-   </attribute>
-  </widget>
-  <widget class="QToolBar" name="toolBar_2">
-   <property name="windowTitle">
-    <string>toolBar_2</string>
-   </property>
-   <attribute name="toolBarArea">
-    <enum>TopToolBarArea</enum>
-   </attribute>
-   <attribute name="toolBarBreak">
-    <bool>false</bool>
-   </attribute>
-  </widget>
- </widget>
- <resources/>
- <connections/>
- <slots>
-  <slot>startLiveView()</slot>
- </slots>
-</ui>
--- a/light9/wavelength.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-#!/usr/bin/python
-
-import sys, wave
-
-
-def wavelength(filename):
-    filename = filename.replace('.ogg', '.wav')
-    wavefile = wave.open(filename, 'rb')
-
-    framerate = wavefile.getframerate()  # frames / second
-    nframes = wavefile.getnframes()  # number of frames
-    song_length = nframes / framerate
-
-    return song_length
-
-
-if __name__ == "__main__":
-    for songfile in sys.argv[1:]:
-        print(songfile, wavelength(songfile))
--- a/light9/wavepoints.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,43 +0,0 @@
-import wave, audioop
-
-
-def simp(filename, seconds_per_average=0.001):
-    """smaller seconds_per_average means fewer data points"""
-    wavefile = wave.open(filename, 'rb')
-    print("# gnuplot data for %s, seconds_per_average=%s" %
-          (filename, seconds_per_average))
-    print(
-        "# %d channels, samplewidth: %d, framerate: %s, frames: %d\n# Compression type: %s (%s)"
-        % wavefile.getparams())
-
-    framerate = wavefile.getframerate()  # frames / second
-
-    frames_to_read = int(framerate * seconds_per_average)
-    print("# frames_to_read=%s" % frames_to_read)
-
-    time_and_max = []
-    values = []
-    count = 0
-    while True:
-        fragment = wavefile.readframes(frames_to_read)
-        if not fragment:
-            break
-
-        # other possibilities:
-        # m = audioop.avg(fragment, 2)
-        # print count, "%s %s" % audioop.minmax(fragment, 2)
-
-        m = audioop.rms(fragment, wavefile._framesize)
-        time_and_max.append((count, m))
-        values.append(m)
-        count += frames_to_read
-        # if count>1000000:
-        #     break
-
-    # find the min and max
-    min_value, max_value = min(values), max(values)
-    points = []  # (secs,height)
-    for count, value in time_and_max:
-        points.append(
-            (count / framerate, (value - min_value) / (max_value - min_value)))
-    return points
--- a/light9/web/AutoDependencies.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,137 +0,0 @@
-import debug from "debug";
-import { NamedNode, Quad_Graph, Quad_Object, Quad_Predicate, Quad_Subject, Term, Util } from "n3";
-import { filter } from "underscore";
-import { Patch, QuadPattern } from "./patch";
-import { SubEvent } from "sub-events";
-import { SyncedGraph } from "./SyncedGraph";
-
-const log = debug("autodep");
-
-// use patch as an optional optimization, but you can't count on it
-export type HandlerFunc = (p?: Patch) => void;
-
-class Handler {
-  patterns: QuadPattern[];
-  innerHandlers: Handler[];
-  // a function and the quad patterns it cared about
-  constructor(public func: HandlerFunc | null, public label: string) {
-    this.patterns = []; // s,p,o,g quads that should trigger the next run
-    this.innerHandlers = []; // Handlers requested while this one was running
-  }
-}
-
-export class AutoDependencies {
-  handlers: Handler;
-  handlerStack: Handler[];
-  graphError: SubEvent<string> = new SubEvent();
-  constructor(private graph: SyncedGraph) {
-    // tree of all known Handlers (at least those with non-empty
-    // patterns). Top node is not a handler.
-    this.handlers = new Handler(null, "root");
-    this.handlerStack = [this.handlers]; // currently running
-    log("window.ad");
-    (window as any).ad = this;
-  }
-
-  runHandler(func: HandlerFunc, label: string) {
-    // what if we have this func already? duplicate is safe?
-    if (label == null) {
-      throw new Error("missing label");
-    }
-
-    const h = new Handler(func, label);
-    const tailChildren = this.handlerStack[this.handlerStack.length - 1].innerHandlers;
-    const matchingLabel = filter(tailChildren, (c: Handler) => c.label === label).length;
-    // ohno, something depends on some handlers getting run twice :(
-    if (matchingLabel < 2) {
-      tailChildren.push(h);
-    }
-    //console.time("handler #{label}")
-    // todo: this may fire 1-2 times before the
-    // graph is initially loaded, which is a waste. Try deferring it if we
-    // haven't gotten the graph yet.
-    this._rerunHandler(h, /*patch=*/ undefined);
-    log(`new handler ${label} ran first time and requested ${h.patterns.length} pats`);
-  }
-
-  _rerunHandler(handler: Handler, patch?: Patch) {
-    handler.patterns = [];
-    this.handlerStack.push(handler);
-    try {
-      if (handler.func === null) {
-        throw new Error("tried to rerun root");
-      }
-      handler.func(patch);
-    } catch (e) {
-      this.graphError.emit(String(e));
-    } finally {
-      // assuming here it didn't get to do all its queries, we could
-      // add a *,*,*,* handler to call for sure the next time?
-      // log('done. got: ', handler.patterns)
-      this.handlerStack.pop();
-    }
-  }
-
-  // handler might have no watches, in which case we could forget about it
-  logHandlerTree() {
-    log("handler tree:");
-    const shorten = (x: Term | null) => {
-      if (x === null) {
-        return "null";
-      }
-      if (!Util.isNamedNode(x)) {
-        return x.value;
-      }
-      return this.graph.shorten(x as NamedNode);
-    };
-
-    var prn = (h: Handler, indent: string) => {
-      log(`${indent} 🤝 handler "${h.label}" ${h.patterns.length} pats`);
-      for (let pat of h.patterns) {
-        log(`${indent}   ⣝ s=${shorten(pat.subject)} p=${shorten(pat.predicate)} o=${shorten(pat.object)}`);
-      }
-      Array.from(h.innerHandlers).map((c: any) => prn(c, indent + "    "));
-    };
-    prn(this.handlers, "");
-  }
-
-  _handlerIsAffected(child: Handler, patch: Patch): boolean {
-    // it should be correct but slow to always return true here
-    for (let pat of child.patterns) {
-      if (patch.matches(pat)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  graphChanged(patch: Patch) {
-    // SyncedGraph is telling us this patch just got applied to the graph.
-
-    var rerunInners = (cur: Handler) => {
-      const toRun = cur.innerHandlers.slice();
-      for (let child of Array.from(toRun)) {
-        const match = this._handlerIsAffected(child, patch);
-
-        if (match) {
-          log("match", child.label, match);
-          child.innerHandlers = []; // let all children get called again
-          this._rerunHandler(child, patch);
-        } else {
-          rerunInners(child);
-        }
-      }
-    };
-    rerunInners(this.handlers);
-  }
-
-  askedFor(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null, g: Quad_Graph | null) {
-    // SyncedGraph is telling us someone did a query that depended on
-    // quads in the given pattern.
-    // console.log(`  asked for s/${s?.id} p/${p?.id} o/${o?.id}`)
-    const current = this.handlerStack[this.handlerStack.length - 1];
-    if (current != null && current !== this.handlers) {
-      current.patterns.push({ subject: s, predicate: p, object: o, graph: g } as QuadPattern);
-    }
-  }
-}
--- a/light9/web/EditChoice.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,118 +0,0 @@
-// see light9/editchoice.py for gtk version
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { $V, Vector } from "sylvester";
-export { ResourceDisplay } from "../web/ResourceDisplay";
-const log = debug("editchoice");
-const RDFS = "http://www.w3.org/2000/01/rdf-schema#";
-
-function setupDrop(
-  senseElem: HTMLElement,
-  highlightElem: HTMLElement,
-  coordinateOriginElem: HTMLElement | null,
-  onDrop: (uri: NamedNode, pos: Vector | null) => void
-) {
-  const highlight = () => highlightElem.classList.add("dragging");
-  const unhighlight = () => highlightElem.classList.remove("dragging");
-
-  senseElem.addEventListener("drag", (event: DragEvent) => { });
-
-  senseElem.addEventListener("dragstart", (event: DragEvent) => { });
-
-  senseElem.addEventListener("dragend", (event: DragEvent) => { });
-
-  senseElem.addEventListener("dragover", (event: DragEvent) => {
-    event.preventDefault();
-    event.dataTransfer!.dropEffect = "copy";
-    highlight();
-  });
-
-  senseElem.addEventListener("dragenter", (event: DragEvent) => {
-    highlight();
-  });
-
-  senseElem.addEventListener("dragleave", (event: DragEvent) => {
-    unhighlight();
-  });
-
-  senseElem.addEventListener("drop", (event: DragEvent) => {
-    event.preventDefault();
-    const uri = new NamedNode(event.dataTransfer!.getData("text/uri-list"));
-
-    let pos: Vector | null = null;
-    if (coordinateOriginElem != null) {
-      const root = coordinateOriginElem.getBoundingClientRect();
-      pos = $V([event.pageX - root.left, event.pageY - root.top]);
-    }
-
-    try {
-      onDrop(uri, pos);
-    } catch (e) {
-      log(e);
-    }
-    unhighlight();
-  });
-}
-
-// Picks a URI based on the caller setting the property OR
-// the user drag-and-dropping a text/uri-list resource (probably
-// an <resource-display> or <a href> tag)
-@customElement("edit-choice")
-export class EditChoice extends LitElement {
-  @property() uri?: NamedNode
-  @property({ type: Boolean }) nounlink = false;
-  @property({ type: Boolean }) rename = false;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        background: #141448;
-        /* min-width: 10em; */
-        padding: 3px 8px;
-      }
-      .dragging {
-        background: rgba(126, 52, 245, 0.0784313725490196);
-        box-shadow: 0 0 20px #ffff00;
-      }
-      a {
-        color: #8e8eff;
-        padding: 3px;
-        display: inline-block;
-        font-size: 145%;
-      }
-    `,
-  ];
-  render() {
-    const unlink = html`
-    <button @click=${this.unlink}>Unlink</button>
-    `
-    return html`
-      <resource-display .uri=${this.uri} ?rename=${this.rename}></resource-display>
-      ${this.nounlink ? html`` : unlink}
-    `;
-  }
-
-  constructor() {
-    super();
-    setupDrop(this, this, null, this._setUri.bind(this));
-  }
-
-  // updated(changedProperties: PropertyValues) {
-  //   log('cp' ,changedProperties)
-  //   if (changedProperties.has("box")) {
-  //     log('setupdrop', this.box)
-  //     setupDrop(this.box, this.box, null, this._setUri.bind(this));
-  //   }
-  // }
-
-  _setUri(u?: NamedNode) {
-    this.uri = u;
-    this.dispatchEvent(new CustomEvent("edited", { detail: { newValue: u } }));
-  }
-
-  unlink() {
-    return this._setUri(undefined);
-  }
-}
--- a/light9/web/Light9CursorCanvas.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,146 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import Sylvester from "sylvester";
-import { line } from "./drawing";
-
-const $V = Sylvester.Vector.create;
-
-const log = debug("cursor");
-
-export interface PlainViewState {
-  zoomSpec: { t1: () => number; t2: () => number };
-  fullZoomX: (t: number) => number;
-  zoomInX: (t: number) => number;
-  cursor: { t: () => number };
-  audioY: () => number;
-  audioH: () => number;
-  zoomedTimeY: () => number; // not what you think- it's the zone in between
-  zoomedTimeH: () => number;
-  mouse: { pos: () => Vector };
-}
-
-// For cases where you have a zoomed-out view on top of a zoomed-in view,
-// overlay this element and it'll draw a time cursor on both views.
-@customElement("light9-cursor-canvas")
-export class Light9CursorCanvas extends LitElement {
-  cursorPath: null | {
-    top0: Vector;
-    top1: Vector;
-    mid0: Vector;
-    mid1: Vector;
-    mid2: Vector;
-    mid3: Vector;
-    bot0: Vector;
-    bot1: Vector;
-  } = null;
-  canvasEl!: HTMLCanvasElement;
-  ctx!: CanvasRenderingContext2D;
-  offsetWidth: any;
-  offsetHeight: any;
-  @property() viewState: PlainViewState | null = null;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-      }
-    `,
-  ];
-  render() {
-    return html`<canvas></canvas>`;
-  }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("viewState")) {
-      this.redrawCursor();
-    }
-  }
-  connectedCallback() {
-    super.connectedCallback();
-    window.addEventListener("resize", this.onResize);
-    this.onResize();
-  }
-
-  firstUpdated() {
-    this.canvasEl = this.shadowRoot!.firstElementChild as HTMLCanvasElement;
-    this.onResize();
-    this.ctx = this.canvasEl.getContext("2d")!;
-  }
-
-  disconnectedCallback() {
-    window.removeEventListener("resize", this.onResize);
-    super.disconnectedCallback();
-  }
-
-  // onViewState() {
-  //   ko.computed(this.redrawCursor.bind(this));
-  // }
-
-  onResize() {
-    if (!this.canvasEl) {
-      return;
-    }
-    this.canvasEl.width = this.offsetWidth;
-    this.canvasEl.height = this.offsetHeight;
-    this.redrawCursor();
-  }
-
-  redrawCursor() {
-    const vs = this.viewState;
-    if (!vs) {
-      return;
-    }
-    const dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2()];
-    const xZoomedOut = vs.fullZoomX(vs.cursor.t());
-    const xZoomedIn = vs.zoomInX(vs.cursor.t());
-
-    this.cursorPath = {
-      top0: $V([xZoomedOut, vs.audioY()]),
-      top1: $V([xZoomedOut, vs.audioY() + vs.audioH()]),
-      mid0: $V([xZoomedIn + 2, vs.zoomedTimeY() + vs.zoomedTimeH()]),
-      mid1: $V([xZoomedIn - 2, vs.zoomedTimeY() + vs.zoomedTimeH()]),
-      mid2: $V([xZoomedOut - 1, vs.audioY() + vs.audioH()]),
-      mid3: $V([xZoomedOut + 1, vs.audioY() + vs.audioH()]),
-      bot0: $V([xZoomedIn, vs.zoomedTimeY() + vs.zoomedTimeH()]),
-      bot1: $V([xZoomedIn, this.offsetHeight]),
-    };
-    this.redraw();
-  }
-
-  redraw() {
-    if (!this.ctx || !this.viewState) {
-      return;
-    }
-    this.ctx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
-
-    this.ctx.strokeStyle = "#fff";
-    this.ctx.lineWidth = 0.5;
-    this.ctx.beginPath();
-    const mouse = this.viewState.mouse.pos();
-    line(this.ctx, $V([0, mouse.e(2)]), $V([this.canvasEl.width, mouse.e(2)]));
-    line(this.ctx, $V([mouse.e(1), 0]), $V([mouse.e(1), this.canvasEl.height]));
-    this.ctx.stroke();
-
-    if (this.cursorPath) {
-      this.ctx.strokeStyle = "#ff0303";
-      this.ctx.lineWidth = 1.5;
-      this.ctx.beginPath();
-      line(this.ctx, this.cursorPath.top0, this.cursorPath.top1);
-      this.ctx.stroke();
-
-      this.ctx.fillStyle = "#9c0303";
-      this.ctx.beginPath();
-      this.ctx.moveTo(this.cursorPath.mid0.e(1), this.cursorPath.mid0.e(2));
-      for (let p of [this.cursorPath.mid1, this.cursorPath.mid2, this.cursorPath.mid3]) {
-        this.ctx.lineTo(p.e(1), p.e(2));
-      }
-      this.ctx.fill();
-
-      this.ctx.strokeStyle = "#ff0303";
-      this.ctx.lineWidth = 3;
-      this.ctx.beginPath();
-      line(this.ctx, this.cursorPath.bot0, this.cursorPath.bot1, "#ff0303", "3px");
-      this.ctx.stroke();
-    }
-  }
-}
--- a/light9/web/RdfDbChannel.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,160 +0,0 @@
-import debug from "debug";
-import { SubEvent } from "sub-events";
-import { SyncgraphPatchMessage } from "./patch";
-const log = debug("rdfdbclient");
-
-class ChannelPinger {
-  private timeoutId?: number;
-  private lastMs: number = 0;
-  constructor(private ws: WebSocket) {
-    this._pingLoop();
-  }
-  lastPingMs(): number {
-    return this.lastMs;
-  }
-  pong() {
-    this.lastMs = Date.now() + this.lastMs;
-  }
-  _pingLoop() {
-    if (this.ws.readyState !== this.ws.OPEN) {
-      return;
-    }
-    this.ws.send("PING");
-    this.lastMs = -Date.now();
-
-    if (this.timeoutId != null) {
-      clearTimeout(this.timeoutId);
-    }
-    this.timeoutId = (setTimeout(this._pingLoop.bind(this), 10000) as unknown) as number;
-  }
-}
-
-export class RdfDbChannel {
-  // lower level reconnecting websocket -- knows about message types, but not what's inside a patch body
-  private ws?: WebSocket = undefined;
-  private pinger?: ChannelPinger;
-  private connectionId: string = "none"; // server's name for us
-  private reconnectTimer?: NodeJS.Timeout = undefined;
-  private messagesReceived = 0; // (non-ping messages)
-  private messagesSent = 0;
-
-  newConnection: SubEvent<void> = new SubEvent();
-  serverMessage: SubEvent<{ evType: string; body: SyncgraphPatchMessage }> = new SubEvent();
-  statusDisplay: SubEvent<string> = new SubEvent();
-
-  constructor(public patchSenderUrl: string) {
-    this.openConnection();
-  }
-  sendMessage(body: string): boolean {
-    // one try, best effort, true if we think it worked
-    if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
-      return false;
-    }
-    log("send patch to server, " + body.length + " bytes");
-    this.ws.send(body);
-    this.messagesSent++;
-    this.updateStatus();
-    return true;
-  }
-
-  disconnect(why:string) {
-    // will be followed by an autoconnect
-    log("disconnect requested:", why);
-    if (this.ws !== undefined) {
-      const closeHandler = this.ws.onclose?.bind(this.ws);
-      if (!closeHandler) {
-        throw new Error();
-      }
-      closeHandler(new CloseEvent("forced"));
-    }
-  }
-
-  private openConnection() {
-    const wsOrWss = window.location.protocol.replace("http", "ws");
-    const fullUrl = wsOrWss + "//" + window.location.host + this.patchSenderUrl;
-    if (this.ws !== undefined) {
-      this.ws.close();
-    }
-    this.ws = new WebSocket(fullUrl);
-    this.ws.onopen = this.onWsOpen.bind(this, this.ws);
-    this.ws.onerror = this.onWsError.bind(this);
-    this.ws.onclose = this.onWsClose.bind(this);
-    this.ws.onmessage = this.onWsMessage.bind(this);
-  }
-
-  private onWsOpen(ws: WebSocket) {
-    log("new connection to", this.patchSenderUrl);
-    this.updateStatus();
-    this.newConnection.emit();
-    this.pinger = new ChannelPinger(ws);
-  }
-
-  private onWsMessage(evt: { data: string }) {
-    const msg = evt.data;
-    if (msg === "PONG") {
-      this.onPong();
-      return;
-    }
-    this.onJson(msg);
-  }
-
-  private onPong() {
-    if (this.pinger) {
-      this.pinger.pong();
-      this.updateStatus();
-    }
-  }
-
-  private onJson(msg: string) {
-    const input = JSON.parse(msg);
-    if (input.connectedAs) {
-      this.connectionId = input.connectedAs;
-    } else {
-      this.onPatch(input as SyncgraphPatchMessage);
-    }
-  }
-
-  private onPatch(input: SyncgraphPatchMessage) {
-    log(`patch msg from server`);
-    this.serverMessage.emit({ evType: "patch", body: input });
-    this.messagesReceived++;
-    this.updateStatus();
-  }
-
-  private onWsError(e: Event) {
-    log("ws error", e);
-    this.disconnect("ws error");
-    this.updateStatus();
-  }
-
-  private onWsClose(ev: CloseEvent) {
-    log("ws close");
-    this.updateStatus();
-    if (this.reconnectTimer !== undefined) {
-      clearTimeout(this.reconnectTimer);
-    }
-    this.reconnectTimer = setTimeout(this.openConnection.bind(this), 1000);
-  }
-
-  private updateStatus() {
-    const conn = (() => {
-      if (this.ws === undefined) {
-        return "no";
-      } else {
-        switch (this.ws.readyState) {
-          case this.ws.CONNECTING:
-            return "connecting";
-          case this.ws.OPEN:
-            return `open as ${this.connectionId}`;
-          case this.ws.CLOSING:
-            return "closing";
-          case this.ws.CLOSED:
-            return "close";
-        }
-      }
-    })();
-
-    const ping = this.pinger ? this.pinger.lastPingMs() : "...";
-    this.statusDisplay.emit(`${conn}; ${this.messagesReceived} recv; ${this.messagesSent} sent; ping ${ping}ms`);
-  }
-}
--- a/light9/web/RdfdbSyncedGraph.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-import debug from "debug";
-import { LitElement, css, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { SyncedGraph } from "./SyncedGraph";
-
-const log = debug("syncedgraph-el");
-
-// Contains a SyncedGraph. Displays as little status box.
-// Put one element on your page and use getTopGraph everywhere.
-@customElement("rdfdb-synced-graph")
-export class RdfdbSyncedGraph extends LitElement {
-  @property() graph: SyncedGraph;
-  @property() status: string;
-  @property() testGraph = false;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        border: 1px solid gray;
-        min-width: 22em;
-        background: #05335a;
-        color: #4fc1d4;
-      }
-    `,
-  ];
-  render() {
-    return html`graph: ${this.status}`;
-  }
-
-  constructor() {
-    super();
-    this.status = "startup";
-    const prefixes = new Map<string, string>([
-      ["", "http://light9.bigasterisk.com/"],
-      ["dev", "http://light9.bigasterisk.com/device/"],
-      ["effect", "http://light9.bigasterisk.com/effect/"],
-      ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
-      ["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
-      ["xsd", "http://www.w3.org/2001/XMLSchema#"],
-    ]);
-    this.graph = new SyncedGraph(
-      this.testGraph ? "unused" : "/service/rdfdb/syncedGraph",
-      prefixes,
-      (s: string) => {
-        this.status = s;
-      }
-    );
-    setTopGraph(this.graph);
-  }
-}
-
-// todo: consider if this has anything to contribute:
-// https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
-let setTopGraph: (sg: SyncedGraph) => void;
-(window as any).topSyncedGraph = new Promise<SyncedGraph>((res, rej) => {
-  setTopGraph = res;
-});
-
-export async function getTopGraph(): Promise<SyncedGraph> {
-  const s = (window as any).topSyncedGraph;
-  return await s;
-}
--- a/light9/web/ResourceDisplay.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,164 +0,0 @@
-import { TextField } from "@material/mwc-textfield";
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { getTopGraph } from "./RdfdbSyncedGraph";
-import { SyncedGraph } from "./SyncedGraph";
-export { Button } from "@material/mwc-button";
-export { Dialog } from "@material/mwc-dialog";
-export { TextField } from "@material/mwc-textfield";
-
-const log = debug("rdisplay");
-
-@customElement("resource-display")
-export class ResourceDisplay extends LitElement {
-  graph!: SyncedGraph;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-      }
-
-      a.resource {
-        color: inherit;
-        text-decoration: none;
-      }
-
-      .resource {
-        border: 1px solid #545454;
-        border-radius: 5px;
-        padding: 1px;
-        margin: 2px;
-        background: rgb(49, 49, 49);
-        display: inline-block;
-        text-shadow: 1px 1px 2px black;
-      }
-      .resource.minor {
-        background: none;
-        border: none;
-      }
-      .resource a {
-        color: rgb(150, 150, 255);
-        padding: 1px;
-        display: inline-block;
-      }
-      .resource.minor a {
-        text-decoration: none;
-        color: rgb(155, 155, 193);
-        padding: 0;
-      }
-    `,
-  ];
-
-  render() {
-    let renameDialog = html``;
-    if (this.renameDialogOpen) {
-      renameDialog = html` <mwc-dialog id="renameDialog" open @closing=${this.onRenameClosing} @closed=${this.onRenameClosed}>
-        <p>
-          New label:
-          <mwc-textfield id="renameField" dialogInitialFocus .value=${this.renameTo}></mwc-textfield>
-        </p>
-        <mwc-button dialogAction="cancel" slot="secondaryAction">Cancel</mwc-button>
-        <mwc-button dialogAction="ok" slot="primaryAction">OK</mwc-button>
-      </mwc-dialog>`;
-    }
-
-    return html` <span class="${this.resClasses()}">
-        <a href="${this.href()}" id="uri"> <!-- type icon goes here -->${this.label}</a>
-        ${this.rename ? html`<button @click=${this.onRename}>Rename</button>` : ""} </span
-      >${renameDialog}`;
-    //
-  }
-  @property() uri?: NamedNode;
-
-  @state() label: string = "";
-  @state() renameDialogOpen = false;
-  @state() renameTo = "";
-
-  @property({ type: Boolean }) rename: boolean = false;
-  @property({ type: Boolean }) noclick: boolean = false;
-  @property({ type: Boolean }) minor: boolean = false;
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.onUri();
-    });
-  }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("uri")) {
-      this.onUri();
-    }
-  }
-
-  private onUri() {
-    if (!this.graph) {
-      return; /*too soon, but getTopGraph will call us again*/
-    }
-    
-    if (this.uri === undefined) {
-      this.label = "(unset)";
-    } else if  (this.uri === null) {
-      throw 'use undefined please'
-    } else {
-      this.graph.runHandler(this.compile.bind(this, this.graph), `label for ${this.uri.id}`);
-    }
-  }
-  private compile(graph: SyncedGraph) {
-    if (this.uri === undefined) {
-      return;
-    } else {
-      this.label = this.graph.labelOrTail(this.uri);
-    }
-  }
-
-  private href(): string {
-    if (!this.uri || this.noclick) {
-      return "javascript:;";
-    }
-    return this.uri.value;
-  }
-
-  private resClasses() {
-    return this.minor ? "resource minor" : "resource";
-  }
-
-  private onRename() {
-    this.renameTo = this.label;
-    this.renameDialogOpen = true;
-    setTimeout(() => {
-      // I! 👏 know! 👏 the! 👏 element! 👏 I! 👏 want!
-      const inputEl = this.shadowRoot!.querySelector("#renameField")!.shadowRoot!.querySelector("input")! as HTMLInputElement;
-      inputEl.setSelectionRange(0, -1);
-    }, 100);
-  }
-
-  // move to SyncedGraph
-  private whatCtxHeldTheObj(subj: NamedNode, pred: NamedNode): NamedNode {
-    var ctxs = this.graph.contextsWithPattern(subj, pred, null);
-    if (ctxs.length != 1) {
-      throw new Error(`${ctxs.length} ${pred.id} stmts for ${subj.id}`);
-    }
-    return ctxs[0];
-  }
-
-  private onRenameClosing(ev: CustomEvent) {
-    this.renameTo = (this.shadowRoot!.querySelector("#renameField")! as TextField).value;
-  }
-
-  private onRenameClosed(ev: CustomEvent) {
-    this.renameDialogOpen = false;
-    if (ev.detail.action == "ok") {
-      var label = this.graph.Uri("rdfs:label");
-      if (this.uri === undefined) {
-        throw "lost uri";
-      }
-      const ctx = this.whatCtxHeldTheObj(this.uri, label);
-      this.graph.patchObject(this.uri, label, this.graph.Literal(this.renameTo), ctx);
-    }
-    this.renameTo = "";
-  }
-}
--- a/light9/web/SyncedGraph.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,404 +0,0 @@
-import debug from "debug";
-import * as N3 from "n3";
-import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
-import { sortBy, unique } from "underscore";
-import { AutoDependencies, HandlerFunc } from "./AutoDependencies";
-import { Patch, patchToDeleteEntireGraph } from "./patch";
-import { RdfDbClient } from "./rdfdbclient";
-
-const log = debug("graph");
-
-const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
-
-export class SyncedGraph {
-  private autoDeps: AutoDependencies;
-  private client: RdfDbClient;
-  private graph: N3.Store;
-  private cachedFloatValues: Map<string, number> = new Map();
-  private cachedUriValues: Map<string, N3.NamedNode> = new Map();
-  private prefixFuncs: (prefix: string) => N3.PrefixedToIri;
-  private serial: any;
-  private nextNumber: any;
-  // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own
-  // one of these. Syncs both ways with rdfdb. Meant to hide the choice of RDF lib, so we can change it
-  // later.
-  //
-  // Note that _applyPatch is the only method to write to the graph, so
-  // it can fire subscriptions.
-
-  constructor(
-    // The /syncedGraph path of an rdfdb server.
-    patchSenderUrl: string,
-    // prefixes can be used in Uri(curie) calls. This mapping may grow during loadTrig calls.
-    public prefixes: Map<string, string>,
-    private setStatus: (status: string) => void
-  ) {
-    this.prefixFuncs = this.rebuildPrefixFuncs(prefixes);
-    this.graph = new N3.Store();
-    this.autoDeps = new AutoDependencies(this);
-    this.autoDeps.graphError.subscribe((e) => {
-      log("graph learned of error - reconnecting", e);
-      this.client.disconnect("graph error");
-    });
-    this.clearGraph();
-
-    this.client = new RdfDbClient(patchSenderUrl, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
-  }
-
-  clearGraph() {
-    // must not try send a patch to the server!
-    // just deletes the statements; watchers are unaffected.
-    this.cachedFloatValues = new Map(); // s + '|' + p -> number
-    this.cachedUriValues = new Map(); // s + '|' + p -> Uri
-
-    const p = patchToDeleteEntireGraph(this.graph);
-    if (!p.isEmpty()) {
-      this._applyPatch(p);
-    }
-    // if we had a Store already, this lets N3.Store free all its indices/etc
-    this.graph = new N3.Store();
-    this.rebuildPrefixFuncs(this.prefixes);
-  }
-
-  _clearGraphOnNewConnection() {
-    // must not try send a patch to the server
-
-    log("clearGraphOnNewConnection");
-    this.clearGraph();
-    log("clearGraphOnNewConnection done");
-  }
-
-  private rebuildPrefixFuncs(prefixes: Map<string, string>) {
-    const p = Object.create(null);
-    prefixes.forEach((v: string, k: string) => (p[k] = v));
-
-    this.prefixFuncs = N3.Util.prefixes(p);
-    return this.prefixFuncs;
-  }
-
-  U() {
-    // just a shorthand
-    return this.Uri.bind(this);
-  }
-
-  Uri(curie: string) {
-    if (curie == null) {
-      throw new Error("no uri");
-    }
-    if (curie.match(/^http/)) {
-      return N3.DataFactory.namedNode(curie);
-    }
-    const part = curie.split(":");
-    return this.prefixFuncs(part[0])(part[1]);
-  }
-
-  // Uri(shorten(u)).value==u
-  shorten(uri: N3.NamedNode): string {
-    for (let row of [
-      { sh: "dev", lo: "http://light9.bigasterisk.com/theater/vet/device/" },
-      { sh: "effect", lo: "http://light9.bigasterisk.com/effect/" },
-      { sh: "", lo: "http://light9.bigasterisk.com/" },
-      { sh: "rdfs", lo: "http://www.w3.org/2000/01/rdf-schema#" },
-      { sh: "xsd", lo: "http://www.w3.org/2001/XMLSchema#" },
-    ]) {
-      if (uri.value.startsWith(row.lo)) {
-        return row.sh + ":" + uri.value.substring(row.lo.length);
-      }
-    }
-    return uri.value;
-  }
-
-  Literal(jsValue: string | number) {
-    return N3.DataFactory.literal(jsValue);
-  }
-
-  LiteralRoundedFloat(f: number) {
-    return N3.DataFactory.literal(f.toPrecision(3), this.Uri("http://www.w3.org/2001/XMLSchema#decimal"));
-  }
-
-  Quad(s: any, p: any, o: any, g: any) {
-    return N3.DataFactory.quad(s, p, o, g);
-  }
-
-  toJs(literal: { value: any }) {
-    // incomplete
-    return parseFloat(literal.value);
-  }
-
-  loadTrig(trig: any, cb: () => any) {
-    // for debugging
-    const adds: Quad[] = [];
-    const parser = new N3.Parser();
-    parser.parse(trig, (error: any, quad: any, prefixes: any) => {
-      if (error) {
-        throw new Error(error);
-      }
-      if (quad) {
-        adds.push(quad);
-      } else {
-        this._applyPatch(new Patch([], adds));
-        // todo: here, add those prefixes to our known set
-        if (cb) {
-          cb();
-        }
-      }
-    });
-  }
-
-  quads(): any {
-    // for debugging
-    return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]);
-  }
-
-  applyAndSendPatch(patch: Patch) {
-    console.time("applyAndSendPatch");
-    if (!this.client) {
-      log("not connected-- dropping patch");
-      return;
-    }
-    if (!patch.isEmpty()) {
-      this._applyPatch(patch);
-      // // chaos delay
-      //       setTimeout(()=>{
-      if (this.client) {
-        log("sending patch:\n", patch.dump());
-        this.client.sendPatch(patch);
-      }
-      // },300*Math.random())
-    }
-    console.timeEnd("applyAndSendPatch");
-  }
-
-  _applyPatch(patch: Patch) {
-    // In most cases you want applyAndSendPatch.
-    //
-    // This is the only method that writes to this.graph!
-    if (patch.isEmpty()) throw "dont send empty patches here";
-    log("_applyPatch [1] \n", patch.dump());
-    this.cachedFloatValues.clear();
-    this.cachedUriValues.clear();
-    patch.applyToGraph(this.graph);
-    if (false) {
-      log("applied patch locally", patch.summary());
-    } else {
-      log("applied patch locally:\n" + patch.dump());
-    }
-    this.autoDeps.graphChanged(patch);
-  }
-
-  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode): Patch {
-    // make a patch which removes existing values for (s,p,*,c) and
-    // adds (s,p,newObject,c). Values in other graphs are not affected.
-    const existing = this.graph.getQuads(s, p, null, g);
-    return new Patch(existing, newObject !== null ? [this.Quad(s, p, newObject, g)] : []);
-  }
-
-  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode) {
-    this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
-  }
-
-  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
-    this.applyAndSendPatch(new Patch(this.graph.getQuads(s, p, null, g), []));
-  }
-
-  public runHandler(func: HandlerFunc, label: string) {
-    // runs your func once, tracking graph calls. if a future patch
-    // matches what you queried, we runHandler your func again (and
-    // forget your queries from the first time).
-
-    // helps with memleak? not sure yet. The point was if two matching
-    // labels get puushed on, we should run only one. So maybe
-    // appending a serial number is backwards.
-    if (!this.serial) {
-      this.serial = 1;
-    }
-    this.serial += 1;
-    //label = label + @serial
-
-    this.autoDeps.runHandler(func, label);
-  }
-
-  _singleValue(s: Quad_Subject, p: Quad_Predicate) {
-    this.autoDeps.askedFor(s, p, null, null);
-    const quads = this.graph.getQuads(s, p, null, null);
-    const objs = new Set(Array.from(quads).map((q: Quad) => q.object));
-
-    switch (objs.size) {
-      case 0:
-        throw new Error("no value for " + s.value + " " + p.value);
-      case 1:
-        var obj = objs.values().next().value;
-        return obj;
-      default:
-        throw new Error("too many different values: " + JSON.stringify(quads));
-    }
-  }
-
-  floatValue(s: Quad_Subject, p: Quad_Predicate) {
-    const key = s.value + "|" + p.value;
-    const hit = this.cachedFloatValues.get(key);
-    if (hit !== undefined) {
-      return hit;
-    }
-    //log('float miss', s, p)
-
-    const v = this._singleValue(s, p).value;
-    const ret = parseFloat(v);
-    if (isNaN(ret)) {
-      throw new Error(`${s.value} ${p.value} -> ${v} not a float`);
-    }
-    this.cachedFloatValues.set(key, ret);
-    return ret;
-  }
-
-  stringValue(s: any, p: any) {
-    return this._singleValue(s, p).value;
-  }
-
-  uriValue(s: Quad_Subject, p: Quad_Predicate) {
-    const key = s.value + "|" + p.value;
-    const hit = this.cachedUriValues.get(key);
-    if (hit !== undefined) {
-      return hit;
-    }
-
-    const ret = this._singleValue(s, p);
-    this.cachedUriValues.set(key, ret);
-    return ret;
-  }
-
-  labelOrTail(uri: { value: { split: (arg0: string) => any } }) {
-    let ret: any;
-    try {
-      ret = this.stringValue(uri, this.Uri("rdfs:label"));
-    } catch (error) {
-      const words = uri.value.split("/");
-      ret = words[words.length - 1];
-    }
-    if (!ret) {
-      ret = uri.value;
-    }
-    return ret;
-  }
-
-  objects(s: any, p: any): Quad_Object[] {
-    this.autoDeps.askedFor(s, p, null, null);
-    const quads = this.graph.getQuads(s, p, null, null);
-    return Array.from(quads).map((q: { object: any }) => q.object);
-  }
-
-  subjects(p: any, o: any): Quad_Subject[] {
-    this.autoDeps.askedFor(null, p, o, null);
-    const quads = this.graph.getQuads(null, p, o, null);
-    return Array.from(quads).map((q: { subject: any }) => q.subject);
-  }
-
-  subjectStatements(s: Quad_Subject): Quad[] {
-    this.autoDeps.askedFor(s, null, null, null);
-    const quads = this.graph.getQuads(s, null, null, null);
-    return quads;
-  }
-
-  items(list: any) {
-    const out = [];
-    let current = list;
-    while (true) {
-      if (current.value === RDF + "nil") {
-        break;
-      }
-
-      this.autoDeps.askedFor(current, null, null, null); // a little loose
-
-      const firsts = this.graph.getQuads(current, RDF + "first", null, null);
-      const rests = this.graph.getQuads(current, RDF + "rest", null, null);
-      if (firsts.length !== 1) {
-        throw new Error(`list node ${current} has ${firsts.length} rdf:first edges`);
-      }
-      out.push(firsts[0].object);
-
-      if (rests.length !== 1) {
-        throw new Error(`list node ${current} has ${rests.length} rdf:rest edges`);
-      }
-      current = rests[0].object;
-    }
-
-    return out;
-  }
-
-  contains(s: any, p: any, o: any): boolean {
-    this.autoDeps.askedFor(s, p, o, null);
-    // Sure this is a nice warning to remind me to rewrite, but the graph.size call itself was taking 80% of the time in here
-    // log("contains calling getQuads when graph has ", this.graph.size);
-    return this.graph.getQuads(s, p, o, null).length > 0;
-  }
-
-  nextNumberedResources(base: { id: any }, howMany: number) {
-    // base is NamedNode or string
-    // Note this is unsafe before we're synced with the graph. It'll
-    // always return 'name0'.
-    if (base.id) {
-      base = base.id;
-    }
-    const results = [];
-
-    // @contains is really slow.
-    if (this.nextNumber == null) {
-      this.nextNumber = new Map();
-    }
-    let start = this.nextNumber.get(base);
-    if (start === undefined) {
-      start = 0;
-    }
-
-    for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) {
-      const uri = this.Uri(`${base}${serial}`);
-      if (!this.contains(uri, null, null)) {
-        results.push(uri);
-        log("nextNumberedResources", `picked ${uri}`);
-        this.nextNumber.set(base, serial + 1);
-        if (results.length >= howMany) {
-          return results;
-        }
-      }
-    }
-    throw new Error(`can't make sequential uri with base ${base}`);
-  }
-
-  nextNumberedResource(base: any) {
-    return this.nextNumberedResources(base, 1)[0];
-  }
-
-  contextsWithPattern(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null): N3.NamedNode[] {
-    this.autoDeps.askedFor(s, p, o, null);
-    const ctxs: N3.NamedNode[] = [];
-    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
-      if (q.graph.termType != "NamedNode") throw `context was ${q.graph.id}`;
-      ctxs.push(q.graph);
-    }
-    return unique(ctxs);
-  }
-
-  sortKey(uri: N3.NamedNode) {
-    const parts = uri.value.split(/([0-9]+)/);
-    const expanded = parts.map(function (p: string) {
-      const f = parseInt(p);
-      if (isNaN(f)) {
-        return p;
-      }
-      return p.padStart(8, "0");
-    });
-    return expanded.join("");
-  }
-
-  sortedUris(uris: any) {
-    return sortBy(uris, this.sortKey);
-  }
-
-  prettyLiteral(x: any) {
-    if (typeof x === "number") {
-      return this.LiteralRoundedFloat(x);
-    } else {
-      return this.Literal(x);
-    }
-  }
-}
--- a/light9/web/TiledHome.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-import * as React from "react";
-import { createRoot } from "react-dom/client";
-import * as FlexLayout from "flexlayout-react";
-export { Light9DeviceSettings } from "./live/Light9DeviceSettings";
-export { Light9CollectorUi } from "./collector/Light9CollectorUi";
-
-const config:FlexLayout.IJsonModel = {
-  global: {},
-  borders: [],
-  layout: {
-    type: "row",
-    weight: 100,
-    children: [
-      {
-        type: "tabset",
-        weight: 50,
-        children: [{ type: "tab", name: "devsettings", component: "light9-device-settings" }],
-      },
-      {
-        type: "tabset",
-        weight: 50,
-        children: [{ type: "tab", name: "collector", component: "light9-collector-ui" }],
-      },
-    ],
-  },
-};
-
-const e = React.createElement;
-
-// see https://github.com/lit/lit/tree/main/packages/labs/react
-
-class Main extends React.Component {
-  constructor(props) {
-    super(props);
-    this.state = { model: FlexLayout.Model.fromJson(config) };
-  }
-
-  factory = (node) => {
-    var component = node.getComponent();
-    return e(component, null, "");
-  };
-
-  render() {
-    return e(FlexLayout.Layout, { model: this.state.model, factory: this.factory });
-  }
-}
-
-const root = createRoot(document.getElementById("container")!);
-root.render(React.createElement(Main));
--- a/light9/web/ascoltami/Light9AscoltamiUi.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,310 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { classMap } from "lit/directives/class-map.js";
-import { NamedNode } from "n3";
-import Sylvester from "sylvester";
-import { Zoom } from "../light9-timeline-audio";
-import { PlainViewState } from "../Light9CursorCanvas";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { TimingUpdate } from "./main";
-import { showRoot } from "../show_specific";
-export { Light9TimelineAudio } from "../light9-timeline-audio";
-export { Light9CursorCanvas } from "../Light9CursorCanvas";
-export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
-export { ResourceDisplay } from "../ResourceDisplay";
-const $V = Sylvester.Vector.create;
-
-debug.enable("*");
-const log = debug("asco");
-
-function byId(id: string): HTMLElement {
-  return document.getElementById(id)!;
-}
-async function postJson(url: string, jsBody: Object) {
-  return fetch(url, {
-    method: "POST",
-    headers: { "Content-Type": "applcation/json" },
-    body: JSON.stringify(jsBody),
-  });
-}
-@customElement("light9-ascoltami-ui")
-export class Light9AscoltamiUi extends LitElement {
-  graph!: SyncedGraph;
-  times!: { intro: number; post: number };
-  @property() nextText: string = "";
-  @property() isPlaying: boolean = false;
-  @property() show: NamedNode | null = null;
-  @property() song: NamedNode | null = null;
-  @property() selectedSong: NamedNode | null = null;
-  @property() currentDuration: number = 0;
-  @property() zoom: Zoom;
-  @property() overviewZoom: Zoom;
-  @property() viewState: PlainViewState | null = null;
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      .timeRow {
-        margin: 14px;
-        position: relative;
-      }
-      #overview {
-        height: 60px;
-      }
-      #zoomed {
-        margin-top: 40px;
-        height: 80px;
-      }
-      #cursor {
-        position: absolute;
-        left: 0;
-        top: 0;
-        width: 100%;
-        height: 100%;
-      }
-      #grow {
-        flex: 1 1 auto;
-        display: flex;
-      }
-      #grow > span {
-        display: flex;
-        position: relative;
-        width: 50%;
-      }
-      #playSelected {
-        height: 100px;
-      }
-      #songList {
-        overflow-y: scroll;
-        position: absolute;
-        left: 0;
-        top: 0;
-        right: 0;
-        bottom: 0;
-      }
-      #songList .row {
-        width: 60%;
-        min-height: 40px;
-        text-align: left;
-        position: relative;
-      }
-      #songList .row:nth-child(even) {
-        background: #333;
-      }
-      #songList .row:nth-child(odd) {
-        background: #444;
-      }
-      #songList button {
-        min-height: 40px;
-        margin-bottom: 10px;
-      }
-      #songList .row.playing {
-        box-shadow: 0 0 30px red;
-        background-color: #de5050;
-      }
-    `,
-  ];
-  render() {
-    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <link rel="stylesheet" href="../style.css" />
-
-      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
-
-      <div id="grow">
-        <span>
-          <div id="songList">
-            <table>
-              ${this.songList.map(
-                (song) => html`
-                  <tr
-                    class="row ${classMap({
-                      playing: !!(this.song && song.equals(this.song)),
-                    })}"
-                  >
-                    <td><resource-display .uri=${song} noclick></resource-display></td>
-                    <td>
-                      <button @click=${this.onSelectSong.bind(this, song)}>
-                        <span>Select</span>
-                      </button>
-                    </td>
-                  </tr>
-                `
-              )}
-            </table>
-          </div> </span
-        ><span>
-          <div id="right">
-            <div>
-              Selected:
-              <resource-display .uri=${this.selectedSong}></resource-display>
-            </div>
-            <div>
-              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
-            </div>
-          </div>
-        </span>
-      </div>
-
-      <div class="timeRow">
-        <div id="timeSlider"></div>
-        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
-        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
-        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
-      </div>
-
-      <div class="commands">
-        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
-          <strong>Stop</strong>
-          <div class="key">s</div>
-        </button>
-        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
-          <strong>Play</strong>
-          <div class="key">p</div>
-        </button>
-        <button id="cmd-intro" @click=${this.onCmdIntro}>
-          <strong>Skip intro</strong>
-          <div class="key">i</div>
-        </button>
-        <button id="cmd-post" @click=${this.onCmdPost}>
-          <strong>Skip to Post</strong>
-          <div class="key">t</div>
-        </button>
-        <button id="cmd-go" @click=${this.onCmdGo}>
-          <strong>Go</strong>
-          <div class="key">g</div>
-          <div id="next">${this.nextText}</div>
-        </button>
-      </div>`;
-  }
-
-  onSelectSong(song: NamedNode, ev: MouseEvent) {
-    if (this.selectedSong && song.equals(this.selectedSong)) {
-      this.selectedSong = null;
-    } else {
-      this.selectedSong = song;
-    }
-  }
-  async onPlaySelected(ev: Event) {
-    if (!this.selectedSong) {
-      return;
-    }
-    await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
-  }
-
-  onCmdStop(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { pause: true });
-  }
-  onCmdPlay(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { resume: true });
-  }
-  onCmdIntro(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
-  }
-  onCmdPost(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/time", {
-      t: this.currentDuration - this.times.post,
-      resume: true,
-    });
-  }
-  onCmdGo(ev?: MouseEvent): void {
-    postJson("../service/ascoltami/go", {});
-  }
-
-  bindKeys() {
-    document.addEventListener("keypress", (ev) => {
-      if (ev.which == 115) {
-        this.onCmdStop();
-        return false;
-      }
-      if (ev.which == 112) {
-        this.onCmdPlay();
-        return false;
-      }
-      if (ev.which == 105) {
-        this.onCmdIntro();
-        return false;
-      }
-      if (ev.which == 116) {
-        this.onCmdPost();
-        return false;
-      }
-
-      if (ev.key == "g") {
-        this.onCmdGo();
-        return false;
-      }
-      return true;
-    });
-  }
-
-  async musicSetup() {
-    // shoveled over from the vanillajs version
-    const config = await (await fetch("../service/ascoltami/config")).json();
-    this.show = new NamedNode(config.show);
-    this.times = config.times;
-    document.title = document.title.replace("{{host}}", config.host);
-    try {
-      const h1 = document.querySelector("h1")!;
-      h1.innerText = h1.innerText.replace("{{host}}", config.host);
-    } catch (e) {}
-
-    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
-  }
-
-  onOldStyleUpdate(data: TimingUpdate) {
-    this.nextText = data.next;
-    this.isPlaying = data.playing;
-    this.currentDuration = data.duration;
-    this.song = new NamedNode(data.song);
-    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
-    const t1 = data.t - 2,
-      t2 = data.t + 20;
-    this.zoom = { duration: data.duration, t1, t2 };
-    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
-    const w = timeRow.offsetWidth;
-    this.viewState = {
-      zoomSpec: { t1: () => t1, t2: () => t2 },
-      cursor: { t: () => data.t },
-      audioY: () => 0,
-      audioH: () => 60,
-      zoomedTimeY: () => 60,
-      zoomedTimeH: () => 40,
-      fullZoomX: (sec: number) => (sec / data.duration) * w,
-      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
-      mouse: { pos: () => $V([0, 0]) },
-    };
-  }
-
-  @property() songList: NamedNode[] = [];
-  constructor() {
-    super();
-    this.bindKeys();
-    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.musicSetup(); // async
-      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
-    });
-  }
-  graphChanged() {
-    this.songList = [];
-    try {
-      const playList = this.graph.uriValue(
-        //
-        this.graph.Uri(showRoot),
-        this.graph.Uri(":playList")
-      );
-      log(playList);
-      this.songList = this.graph.items(playList) as NamedNode[];
-    } catch (e) {
-      log("no playlist yet");
-    }
-    log(this.songList.length);
-  }
-}
--- a/light9/web/ascoltami/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>ascoltami on {{host}}</title>
-    <link rel="stylesheet" href="../style.css" />
-    <style>
-      #cmd-go {
-        min-width: 5em;
-      }
-      .song-name {
-        padding-left: 0.4em;
-      }
-      .dimStalled #currentTime {
-        font-size: 20px;
-        background: green;
-        color: black;
-        padding: 3px;
-      }
-      .dimStalled {
-        font-size: 90%;
-      }
-      body {
-        margin: 0;
-        padding: 0;
-        overflow: hidden;
-        min-height: 100vh;
-      }
-      #page {
-        width: 100%;
-        height: 100vh; /* my phone was losing the bottom :( */
-        display: flex;
-        flex-direction: column;
-      }
-      #page > div,
-      #page > p {
-        flex: 0 1 auto;
-        margin: 0;
-      }
-      light9-ascoltami-ui {
-        flex: 1 1 auto;
-      }
-    </style>
-    <meta
-      name="viewport"
-      content="user-scalable=no, width=device-width, initial-scale=.7"
-    />
-    <script type="module" src="./Light9AscoltamiUi"></script>
-  </head>
-  <body>
-    <div id="page">
-      <h1>ascoltami on {{host}}</h1>
-      <div class="songs" style="display: none"></div>
-
-      <div class="dimStalled">
-        <table>
-          <tr>
-            <td colspan="3">
-              <strong>Song:</strong> <span id="currentSong"></span>
-            </td>
-          </tr>
-          <tr>
-            <td><strong>Time:</strong> <span id="currentTime"></span></td>
-            <td><strong>Left:</strong> <span id="leftTime"></span></td>
-            <td>
-              <strong>Until autostop:</strong>
-              <span id="leftAutoStopTime"></span>
-            </td>
-          </tr>
-          <tr>
-            <td colspan="3">
-               <span id="states"></span>
-            </td>
-          </tr>
-        </table>
-      </div>
-
-      <hr />
-      <light9-ascoltami-ui></light9-ascoltami-ui>
-      <p><a href="">reload</a></p>
-    </div>
-    <script type="module" src="./main.ts"></script>
-  </body>
-</html>
--- a/light9/web/ascoltami/main.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,100 +0,0 @@
-function byId(id: string): HTMLElement {
-  return document.getElementById(id)!;
-}
-
-export interface TimingUpdate {
-  // GET /ascoltami/time response
-  duration: number;
-  next: string; // e.g. 'play'
-  playing: boolean;
-  song: string;
-  started: number; // unix sec
-  t: number; // seconds into song
-  state: { current: { name: string }; pending: { name: string } };
-}
-
-(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => {
-  let currentHighlightedSong = "";
-  // let lastPlaying = false;
-
-  
-  const events = new EventSource("../service/ascoltami/time/stream");
-  events.addEventListener("message", (m)=>{
-    const update = JSON.parse(m.data) as TimingUpdate
-    updateCurrent(update)
-    markUpdateTiming();
-  })
-
-  async function updateCurrent(data:TimingUpdate) {
-    byId("currentSong").innerText = data.song;
-    if (data.song != currentHighlightedSong) {
-      showCurrentSong(data.song);
-    }
-    byId("currentTime").innerText = data.t.toFixed(1);
-    byId("leftTime").innerText = (data.duration - data.t).toFixed(1);
-    byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1);
-    byId("states").innerText = JSON.stringify(data.state);
-    //   document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration });
-    timingUpdate(data);
-  }
-  let recentUpdates: Array<number> = [];
-  function markUpdateTiming() {
-    recentUpdates.push(+new Date());
-    recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0));
-  }
-
-  function refreshUpdateFreqs() {
-    if (recentUpdates.length > 1) {
-      if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) {
-        byId("updateActual").innerText = "(stalled)";
-        return;
-      }
-
-      var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1);
-      byId("updateActual").innerText = "" + Math.round(1000 / avgMs);
-    }
-  }
-  setInterval(refreshUpdateFreqs, 2000);
-
-  function showCurrentSong(uri: string) {
-    document.querySelectorAll(".songs div").forEach((row: Element, i: number) => {
-      if (row.querySelector("button")!.dataset.uri == uri) {
-        row.classList.add("currentSong");
-      } else {
-        row.classList.remove("currentSong");
-      }
-    });
-    currentHighlightedSong = uri;
-  }
-
-  const data = await (await fetch("api/songs")).json();
-  data.songs.forEach((song: { uri: string; label: string }) => {
-    const button = document.createElement("button");
-    // link is just for dragging, not clicking
-    const link = document.createElement("a");
-    const n = document.createElement("span");
-    n.classList.add("num");
-    n.innerText = song.label.slice(0, 2);
-    link.appendChild(n);
-
-    const sn = document.createElement("span");
-    sn.classList.add("song-name");
-    sn.innerText = song.label.slice(2).trim();
-    link.appendChild(sn);
-    link.setAttribute("href", song.uri);
-    link.addEventListener("click", (ev) => {
-      ev.stopPropagation();
-      button.click();
-    });
-    button.appendChild(link);
-    button.dataset.uri = song.uri;
-    button.addEventListener("click", async (ev) => {
-      await fetch("api/song", { method: "POST", body: song.uri });
-      showCurrentSong(song.uri);
-    });
-    const dv = document.createElement("div");
-    dv.appendChild(button);
-    document.querySelector(".songs")!.appendChild(dv);
-  });
-
-};
--- a/light9/web/collector/Light9CollectorDevice.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-export { ResourceDisplay } from "../../web/ResourceDisplay";
-
-const log = debug("device-el");
-
-@customElement("light9-collector-device")
-export class Light9CollectorDevice extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        break-inside: avoid-column;
-        font-size: 80%;
-      }
-      h3 {
-        margin-top: 12px;
-        margin-bottom: 0;
-      }
-      td {
-        white-space: nowrap;
-      }
-
-      td.nonzero {
-        background: #310202;
-        color: #e25757;
-      }
-      td.full {
-        background: #2b0000;
-        color: red;
-        font-weight: bold;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <h3><resource-display .uri=${this.uri}></resource-display></h3>
-      <table class="borders">
-        <tr>
-          <th>out attr</th>
-          <th>value</th>
-          <th>chan</th>
-        </tr>
-        ${this.attrs.map(
-          (item) => html`
-            <tr>
-              <td>${item.attr}</td>
-              <td class=${item.valClass}>${item.val} →</td>
-              <td>${item.chan}</td>
-            </tr>
-          `
-        )}
-      </table>
-    `;
-  }
-  @property({
-    converter: acceptStringOrUri(),
-  })
-  uri: NamedNode = new NamedNode("");
-  @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = [];
-
-  setAttrs(attrs: any) {
-    this.attrs = attrs;
-    this.attrs.forEach(function (row: any) {
-      row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
-    });
-  }
-}
-
-function acceptStringOrUri() {
-  return (s: string | null) => new NamedNode(s || "");
-}
--- a/light9/web/collector/Light9CollectorUi.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,91 +0,0 @@
-import debug from "debug";
-import { html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import ReconnectingWebSocket from "reconnectingwebsocket";
-import { sortBy, uniq } from "underscore";
-import { Patch } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { Light9CollectorDevice } from "./Light9CollectorDevice";
-export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
-export { Light9CollectorDevice };
-
-debug.enable("*");
-const log = debug("collector");
-
-@customElement("light9-collector-ui")
-export class Light9CollectorUi extends LitElement {
-  graph!: SyncedGraph;
-  render() {
-    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
-      <h1>Collector <a href="metrics">[metrics]</a></h1>
-
-      <h2>Devices</h2>
-      <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `;
-  }
-
-  @property() devices: NamedNode[] = [];
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
-    });
-
-    const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "../service/collector/updates");
-    ws.addEventListener("message", (ev: any) => {
-      const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet;
-      if (outputAttrsSet) {
-        this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs);
-      }
-    });
-  }
-
-  findDevices(patch?: Patch) {
-    const U = this.graph.U();
-
-    this.devices = [];
-    this.clearDeviceChildElementCache();
-    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
-    uniq(sortBy(classes, "value"), true).forEach((dc) => {
-      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        this.devices.push(dev as NamedNode);
-      });
-    });
-  }
-
-  deviceElements: Map<string, Light9CollectorDevice> = new Map();
-
-  clearDeviceChildElementCache() {
-    this.deviceElements = new Map();
-  }
-
-  findDeviceChildElement(uri: string): Light9CollectorDevice | undefined {
-    const known = this.deviceElements.get(uri);
-    if (known) {
-      return known;
-    }
-
-    for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) {
-      const eld = el as Light9CollectorDevice;
-      if (eld.uri.value == uri) {
-        this.deviceElements.set(uri, eld);
-        return eld;
-      }
-    }
-
-    return undefined;
-  }
-
-  updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) {
-    const el = this.findDeviceChildElement(uri);
-    if (!el) {
-      // unresolved race: updates come in before we have device elements to display them
-      setTimeout(() => this.updateDev(uri, attrs), 300);
-      return;
-    }
-    el.setAttrs(attrs);
-  }
-}
--- a/light9/web/collector/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>collector</title>
-    <meta charset="utf-8" />
-
-    <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="Light9CollectorUi"></script>
-
-    <style>
-      td {
-        white-space: nowrap;
-      }
-    </style>
-  </head>
-  <body>
-    <light9-collector-ui></light9-collector-ui>
-  </body>
-</html>
--- a/light9/web/colorpick_crosshair_large.svg	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,127 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="1000"
-   height="1000"
-   viewBox="0 0 1000 1000"
-   version="1.1"
-   id="svg8"
-   inkscape:export-filename="/home/drewp/projects-local/light9/light9/web/colorpick_crosshair_large.png"
-   inkscape:export-xdpi="60.720001"
-   inkscape:export-ydpi="60.720001"
-   inkscape:version="0.92.1 unknown"
-   sodipodi:docname="colorpick_crosshair_large.svg">
-  <defs
-     id="defs2" />
-  <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="0.7"
-     inkscape:cx="628.31802"
-     inkscape:cy="596.88994"
-     inkscape:document-units="px"
-     inkscape:current-layer="layer1"
-     showgrid="true"
-     showguides="true"
-     inkscape:snap-bbox="true"
-     inkscape:snap-global="true"
-     inkscape:bbox-paths="false"
-     inkscape:bbox-nodes="true"
-     inkscape:snap-bbox-edge-midpoints="true"
-     fit-margin-top="0"
-     fit-margin-left="0"
-     fit-margin-right="0"
-     fit-margin-bottom="0"
-     inkscape:window-width="1785"
-     inkscape:window-height="1286"
-     inkscape:window-x="855"
-     inkscape:window-y="211"
-     inkscape:window-maximized="0"
-     units="px"
-     viewbox-width="100"
-     viewbox-height="100"
-     inkscape:snap-page="true"
-     scale-x="1"
-     inkscape:showpageshadow="true"
-     inkscape:pagecheckerboard="false">
-    <inkscape:grid
-       type="xygrid"
-       id="grid4487"
-       originx="84.666667"
-       originy="42.333326" />
-  </sodipodi:namedview>
-  <metadata
-     id="metadata5">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title></dc:title>
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <g
-     inkscape:label="Layer 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(84.666667,-398.3332)">
-    <ellipse
-       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:0.69199997;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
-       id="path4485"
-       cx="415.33334"
-       cy="898.33374"
-       rx="23.649397"
-       ry="23.649393" />
-    <path
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 391.02774,898.33321 H -84.666663"
-       id="path4489"
-       inkscape:connector-curvature="0" />
-    <ellipse
-       ry="13.000324"
-       rx="13.000322"
-       cy="898.33374"
-       cx="414.77835"
-       id="ellipse4497"
-       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4509"
-       d="m 415.33329,874.02821 v 52.083"
-       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.64999998;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-    <path
-       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.64999998;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 439.63886,898.33321 H 387.55552"
-       id="path4511"
-       inkscape:connector-curvature="0" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4515"
-       d="M 915.33327,898.33321 H 439.63886"
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-    <path
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 415.33329,1398.3332 V 922.63921"
-       id="path4517"
-       inkscape:connector-curvature="0" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4519"
-       d="M 415.33329,874.02821 V 398.33323"
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-  </g>
-</svg>
--- a/light9/web/colorpick_crosshair_small.svg	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,116 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   xmlns:dc="http://purl.org/dc/elements/1.1/"
-   xmlns:cc="http://creativecommons.org/ns#"
-   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
-   xmlns:svg="http://www.w3.org/2000/svg"
-   xmlns="http://www.w3.org/2000/svg"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="400"
-   height="60"
-   viewBox="0 0 400 60"
-   version="1.1"
-   id="svg8"
-   inkscape:export-filename="/home/drewp/projects-local/light9/light9/web/colorpick_crosshair_small.png"
-   inkscape:export-xdpi="60.720001"
-   inkscape:export-ydpi="60.720001"
-   inkscape:version="0.92.1 unknown"
-   sodipodi:docname="colorpick_crosshair_small.svg">
-  <defs
-     id="defs2" />
-  <sodipodi:namedview
-     id="base"
-     pagecolor="#ffffff"
-     bordercolor="#666666"
-     borderopacity="1.0"
-     inkscape:pageopacity="0.0"
-     inkscape:pageshadow="2"
-     inkscape:zoom="44.8"
-     inkscape:cx="197.93986"
-     inkscape:cy="27.463198"
-     inkscape:document-units="px"
-     inkscape:current-layer="layer1"
-     showgrid="true"
-     showguides="true"
-     inkscape:snap-bbox="true"
-     inkscape:snap-global="true"
-     inkscape:bbox-paths="false"
-     inkscape:bbox-nodes="true"
-     inkscape:snap-bbox-edge-midpoints="true"
-     fit-margin-top="0"
-     fit-margin-left="0"
-     fit-margin-right="0"
-     fit-margin-bottom="0"
-     inkscape:window-width="1785"
-     inkscape:window-height="1286"
-     inkscape:window-x="945"
-     inkscape:window-y="735"
-     inkscape:window-maximized="0"
-     units="px"
-     inkscape:snap-page="true"
-     inkscape:snap-object-midpoints="true">
-    <inkscape:grid
-       type="xygrid"
-       id="grid4487"
-       originx="84.666667"
-       originy="42.333326" />
-  </sodipodi:namedview>
-  <metadata
-     id="metadata5">
-    <rdf:RDF>
-      <cc:Work
-         rdf:about="">
-        <dc:format>image/svg+xml</dc:format>
-        <dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
-        <dc:title></dc:title>
-      </cc:Work>
-    </rdf:RDF>
-  </metadata>
-  <g
-     inkscape:label="Layer 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(84.666667,-1014.75)">
-    <path
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 106.75804,1044.7945 H -187.55665"
-       id="path4489"
-       inkscape:connector-curvature="0" />
-    <ellipse
-       ry="7.6999803"
-       rx="7.6999774"
-       cy="1044.7944"
-       cx="115.35117"
-       id="ellipse4497"
-       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:0.531;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.78628826;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4509"
-       d="m 115.35115,1036.2014 v 17.1862"
-       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.26794326;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-    <path
-       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.26794326;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 123.94429,1044.7946 H 106.75804"
-       id="path4511"
-       inkscape:connector-curvature="0" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4515"
-       d="M 418.25895,1044.7945 H 123.94429"
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-    <path
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
-       d="M 115.35115,1347.7023 V 1053.3876"
-       id="path4517"
-       inkscape:connector-curvature="0" />
-    <path
-       inkscape:connector-curvature="0"
-       id="path4519"
-       d="M 115.35115,1036.2014 V 741.88681"
-       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
-  </g>
-</svg>
Binary file light9/web/colorpick_rainbow_large.png has changed
Binary file light9/web/colorpick_rainbow_small.png has changed
--- a/light9/web/drawing.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-
-export function svgPathFromPoints(pts: { forEach: (arg0: (p: any) => void) => void }) {
-  let out = "";
-  pts.forEach(function (p: Number[] | { elements: Number[] }) {
-    let x, y;
-    if ((p as any).elements) {
-      // for vec2
-      [x, y] = (p as any).elements;
-    } else {
-      [x, y] = p as Number[];
-    }
-    if (out.length === 0) {
-      out = "M ";
-    } else {
-      out += "L ";
-    }
-    out += "" + x + "," + y + " ";
-  });
-  return out;
-};
-
-export function line(
-  ctx: { moveTo: (arg0: any, arg1: any) => void; lineTo: (arg0: any, arg1: any) => any },
-  p1: { e: (arg0: number) => any },
-  p2: { e: (arg0: number) => any }
-) {
-  ctx.moveTo(p1.e(1), p1.e(2));
-  return ctx.lineTo(p2.e(1), p2.e(2));
-};
-
-// http://stackoverflow.com/a/4959890
-export function roundRect(
-  ctx: {
-    beginPath: () => void;
-    moveTo: (arg0: any, arg1: any) => void;
-    lineTo: (arg0: number, arg1: number) => void;
-    arc: (arg0: number, arg1: number, arg2: any, arg3: number, arg4: number, arg5: boolean) => void;
-    closePath: () => any;
-  },
-  sx: number,
-  sy: number,
-  ex: number,
-  ey: number,
-  r: number
-) {
-  const d2r = Math.PI / 180;
-  if (ex - sx - 2 * r < 0) {
-    r = (ex - sx) / 2;
-  } // ensure that the radius isn't too large for x
-  if (ey - sy - 2 * r < 0) {
-    r = (ey - sy) / 2;
-  } // ensure that the radius isn't too large for y
-  ctx.beginPath();
-  ctx.moveTo(sx + r, sy);
-  ctx.lineTo(ex - r, sy);
-  ctx.arc(ex - r, sy + r, r, d2r * 270, d2r * 360, false);
-  ctx.lineTo(ex, ey - r);
-  ctx.arc(ex - r, ey - r, r, d2r * 0, d2r * 90, false);
-  ctx.lineTo(sx + r, ey);
-  ctx.arc(sx + r, ey - r, r, d2r * 90, d2r * 180, false);
-  ctx.lineTo(sx, sy + r);
-  ctx.arc(sx + r, sy + r, r, d2r * 180, d2r * 270, false);
-  return ctx.closePath();
-};
--- a/light9/web/edit-choice-demo.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,30 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title></title>
-    <meta charset="utf-8" />
-       <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-
-    <link rel="import" href="rdfdb-synced-graph.html">
-    <link rel="import" href="edit-choice.html">
-    <script src="/node_modules/n3/n3-browser.js"></script>
-      <script src="/lib/knockout/dist/knockout.js"></script>
-      <script src="/lib/shortcut/index.js"></script>
-      <script src="/lib/async/dist/async.js"></script>
-      <script src="/lib/underscore/underscore-min.js"></script>
-  </head>
-  <body>
-    <dom-bind>
-      <template>
-        <p>
-          <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-        </p>
-        <p>
-          edit-choice: <edit-choice graph="{{graph}}" uri="http://example.com/hello"></edit-choice>
-        </p>
-        <p>
-          <a href="http://light9.bigasterisk.com/effect/spideredge" >this has a label</a>
-      </template>
-    </dom-bind>
-  </body>
-</html>
--- a/light9/web/edit-choice.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-log = debug('editchoice')
-RDFS = 'http://www.w3.org/2000/01/rdf-schema#'
-
-
-
-window.setupDrop = (senseElem, highlightElem, coordinateOriginElem, onDrop) ->
-
-  highlight = -> highlightElem.classList.add('dragging')
-  unhighlight = -> highlightElem.classList.remove('dragging')
-  
-  senseElem.addEventListener 'drag', (event) =>
-    
-  senseElem.addEventListener 'dragstart', (event) =>
-    
-  senseElem.addEventListener 'dragend', (event) =>
-    
-  senseElem.addEventListener 'dragover', (event) =>
-    event.preventDefault()
-    event.dataTransfer.dropEffect = 'copy'
-    highlight()
-
-  senseElem.addEventListener 'dragenter', (event) =>
-    highlight()
-    
-  senseElem.addEventListener 'dragleave', (event) =>
-    unhighlight()
-   
-  senseElem.addEventListener 'drop', (event) ->
-    event.preventDefault()
-    uri = event.dataTransfer.getData('text/uri-list')
-
-    pos = if coordinateOriginElem?
-        root = coordinateOriginElem.getBoundingClientRect()
-        $V([event.pageX - root.left, event.pageY - root.top])
-      else
-        null
-
-    try
-      onDrop(uri, pos)
-    catch e
-      log(e)
-    unhighlight()
-
-
-
-coffeeElementSetup(class EditChoice extends Polymer.Element
-  @is: "edit-choice",
-  @getter_properties:
-    graph: {type: Object, notify: true},
-    uri: {type: String, notify: true},
-
-  _setUri: (u) ->
-    @uri = u
-    @dispatchEvent(new CustomEvent('edited'))
-
-  connectedCallback: ->
-    super.connectedCallback()
-    setupDrop(@$.box, @$.box, null, @_setUri.bind(@))
-
-  unlink: ->
-    @_setUri(null)
-)
--- a/light9/web/edit-choice_test.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>edit-choice test</title>
-    <meta charset="utf-8">
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <script src="/node_modules/mocha/mocha.js"></script>
-    <script src="/node_modules/chai/chai.js"></script>
-
-    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
-    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
-
-    <link rel="import" href="rdfdb-synced-graph.html">
-    <link rel="import" href="edit-choice.html">
-  </head>
-  <body>
-    <div id="mocha"><p><a href=".">Index</a></p></div>
-    <div id="messages"></div>
-    <div id="fixtures">
-      <dom-bind>
-        <template>
-          <p>
-            <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
-          </p>
-          <p>
-            edit-choice: <edit-choice id="ec" graph="{{graph}}" uri="http://example.com/a"></edit-choice>
-          </p>
-        </template>
-      </dom-bind>
-    </div>
-    
-    <script>
-     mocha.setup('bdd')
-     const assert = chai.assert;
-     
-     describe("resource-display", () => {
-         let ec;
-         let graph;
-         beforeEach((done) => {
-             ec = document.querySelector("#ec");
-             window.ec=ec;
-             graph = document.querySelector("#graph");
-             graph.graph.clearGraph();
-             graph.graph.loadTrig(`
-                       @prefix : <http://example.com/> .
-                       @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-                       :a rdfs:label "label a" :ctx .
-                     `, done);
-         });
-         it("shows the uri as a resource-display");
-         it("accepts a drop event and changes the uri");
-         it("clears uri when you click unlink");
-         
-     }); 
-     mocha.run();
-    </script>
-  </body>
-</html>
--- a/light9/web/effects/Light9EffectListing.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { sortBy } from "underscore";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-export { ResourceDisplay } from "../ResourceDisplay";
-
-debug.enable("*");
-const log = debug("listing");
-
-@customElement("light9-effect-listing")
-export class Light9EffectListing extends LitElement {
-  render() {
-    return html`
-      <h1>Effects</h1>
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)}
-    `;
-  }
-  graph!: SyncedGraph;
-  effects: NamedNode[] = [];
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.getClasses.bind(this), "getClasses");
-    });
-  }
-
-  getClasses() {
-    const U = this.graph.U();
-    this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[];
-    this.effects = sortBy(this.effects, (ec: NamedNode) => {
-      try {
-        return this.graph.stringValue(ec, U("rdfs:label"));
-      } catch (e) {
-        return ec.value;
-      }
-    });
-    this.requestUpdate();
-  }
-}
-
-@customElement("light9-effect-class")
-export class Light9EffectClass extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        padding: 5px;
-        border: 1px solid green;
-        background: #1e271e;
-        margin-bottom: 3px;
-      }
-      a {
-        color: #7992d0;
-        background: #00000859;
-        min-width: 4em;
-        min-height: 2em;
-        display: inline-block;
-        text-align: center;
-        vertical-align: middle;
-      }
-      resource-display {
-        min-width: 12em;
-        font-size: 180%;
-      }
-    `,
-  ];
-  render() {
-    if (!this.uri) {
-      return html`loading...`;
-    }
-    return html`
-      Effect
-      <resource-display .uri=${this.uri} rename></resource-display>
-      <a href="../live?effect=${this.uri.value}">Edit</a>
-      <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax>
-      <span style="float:right">
-        <button disabled @click=${this.onAdd}>Add to current song</button>
-        <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button>
-      </span>
-    `;
-  }
-  graph!: SyncedGraph;
-  uri?: NamedNode;
-
-  onAdd() {
-    // this.$.songEffects.body = { drop: this.uri.value };
-    // this.$.songEffects.generateRequest();
-  }
-
-  onMomentaryPress() {
-    // this.$.songEffects.body = { drop: this.uri.value, event: "start" };
-    // this.lastPress = this.$.songEffects.generateRequest();
-    // return this.lastPress.completes.then((request: { response: { note: any } }) => {
-    //   return (this.lastMomentaryNote = request.response.note);
-    // });
-  }
-
-  onMomentaryRelease() {
-    // if (!this.lastMomentaryNote) {
-    //   return;
-    // }
-    // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote };
-    // this.lastMomentaryNote = null;
-    // return this.$.songEffects.generateRequest();
-  }
-}
--- a/light9/web/effects/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>effect listing</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css">    
-    <script type="module" src="./Light9EffectListing"></script>
-  </head>
-  <body>
-    <light9-effect-listing></light9-effect-listing>
-  </body>
-</html>
--- a/light9/web/fade/Light9EffectFader.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,190 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { NamedNode, Quad } from "n3";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { showRoot } from "../show_specific";
-import { SyncedGraph } from "../SyncedGraph";
-import { Patch } from "../patch";
-import { Literal } from "n3";
-export { Light9Fader } from "./Light9Fader";
-
-const log = debug("efffader")
-
-//////////////////////////////////////
-const RETURN_URI = new NamedNode("");
-const RETURN_FLOAT = 1;
-function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined {
-  // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned .
-  let x: NamedNode;
-  try {
-    x = graph.uriValue(subj1, pred1);
-  } catch (e) {
-    return undefined;
-  }
-  try {
-    if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") {
-      return graph.uriValue(x, pred2) as T;
-    } else if (typeof returnWhat === "number") {
-      return graph.floatValue(x, pred2) as T;
-    }
-  } catch (e) {
-    return undefined;
-  }
-}
-function set2Step(
-  graph: SyncedGraph, //
-  subj1: NamedNode,
-  pred1: NamedNode,
-  baseName: string,
-  pred2: NamedNode,
-  newObjLiteral: Literal
-) { }
-
-function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined {
-  try {
-    return graph.uriValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined {
-  try {
-    return graph.stringValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined {
-  try {
-    return graph.floatValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-
-//////////////////////////////////////
-class EffectFader {
-  constructor(public uri: NamedNode) { }
-  column: string = "unset";
-  effect?: NamedNode;
-  effectAttr?: NamedNode; // :strength
-  setting?: NamedNode; // we assume fader always has exactly one setting
-  value?: number;
-}
-
-@customElement("light9-effect-fader")
-export class Light9EffectFader extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        border: 2px gray outset;
-        background: #272727;
-      }
-      light9-fader {
-        margin: 0px;
-        width: 100%;
-      }
-    `,
-  ];
-  render() {
-    if (this.conf === undefined || this.conf.value === undefined) {
-      return html`...`;
-    }
-    return html`
-      <div><resource-display .uri=${this.uri}></resource-display>
-      <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader>
-      <div>${this.conf.value.toPrecision(3)}</div>
-      <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div>
-      <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div>
-    `;
-  }
-
-  graph?: SyncedGraph;
-  ctx: NamedNode = new NamedNode(showRoot + "/fade");
-  @property() uri!: NamedNode;
-  @state() conf?: EffectFader; // compiled from graph
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this, this.graph), `fader config ${this.uri.value}`);
-    });
-  }
-
-  private compile(graph: SyncedGraph) {
-    const U = graph.U();
-    this.conf = undefined;
-
-    const conf = new EffectFader(this.uri);
-
-    if (!graph.contains(this.uri, U("rdf:type"), U(":Fader"))) {
-      // not loaded yet, perhaps
-      return;
-    }
-
-    conf.column = maybeStringValue(graph, this.uri, U(":column")) || "unset";
-    conf.effect = maybeUriValue(graph, this.uri, U(":effect"));
-    conf.effectAttr = get2Step(RETURN_URI, graph, this.uri, U(":setting"), U(":effectAttr"));
-
-    this.conf = conf;
-    graph.runHandler(this.compileValue.bind(this, graph, this.conf), `fader config.value ${this.uri.value}`);
-  }
-
-  private compileValue(graph: SyncedGraph, conf: EffectFader) {
-    // external graph change -> conf.value
-    const U = graph.U();
-    conf.value = get2Step(RETURN_FLOAT, graph, this.uri, U(":setting"), U(":value"));
-    // since conf attrs aren't watched as property:
-    this.requestUpdate()
-  }
-
-  onSliderInput(ev: CustomEvent) {
-    // slider user input -> graph
-    if (this.conf === undefined) return;
-    this.conf.value = ev.detail.value
-    this.writeValueToGraph()
-  }
-
-  writeValueToGraph() {
-    // this.value -> graph
-    if (this.graph === undefined) {
-      return;
-    }
-    const U = this.graph.U();
-    if (this.conf === undefined) {
-      return;
-    }
-    if (this.conf.value === undefined) {
-      log(`value of ${this.uri} is undefined`)
-      return;
-    }
-    log('writeValueToGraph', this.conf.value)
-    const valueTerm = this.graph.LiteralRoundedFloat(this.conf.value);
-    const settingNode = this.graph.uriValue(this.uri, U(":setting"));
-    this.graph.patchObject(settingNode, this.graph.Uri(":value"), valueTerm, this.ctx);
-
-  }
-
-  onEffectChange(ev: CustomEvent) {
-    if (this.graph === undefined) {
-      return;
-    }
-    const { newValue } = ev.detail;
-    this.graph.patchObject(this.uri, this.graph.Uri(":effect"), newValue, this.ctx);
-  }
-
-  onEffectAttrChange(ev: CustomEvent) {
-    if (this.graph === undefined) {
-      return;
-    }
-    // const { newValue } = ev.detail;
-    // if (this.setting === undefined) {
-    //   this.setting = this.graph.nextNumberedResource(this.graph.Uri(":fade_set"));
-    //   this.graph.patchObject(this.uri, this.graph.Uri(":setting"), this.setting, this.ctx);
-    // }
-    // this.graph.patchObject(this.setting, this.graph.Uri(":effectAttr"), newValue, this.ctx);
-  }
-}
--- a/light9/web/fade/Light9FadeUi.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,169 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import * as N3 from "n3";
-import { NamedNode, Quad } from "n3";
-import { Patch } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { showRoot } from "../show_specific";
-import { SyncedGraph } from "../SyncedGraph";
-export { EditChoice } from "../EditChoice";
-export { Light9EffectFader } from "./Light9EffectFader";
-export { Light9Fader } from "./Light9Fader";
-
-debug.enable("*,autodep");
-const log = debug("fade");
-
-class FaderConfig {
-  constructor(public uri: NamedNode, public column: number) { }
-}
-
-class FadePage {
-  constructor(public uri: NamedNode) { }
-  faderConfigs: FaderConfig[] = [];
-}
-class FadePages {
-  pages: FadePage[] = [];
-}
-
-@customElement("light9-fade-ui")
-export class Light9FadeUi extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        user-select: none; /* really this is only desirable during slider drag events */
-      }
-      .mappedToHw {
-        background: #393945;
-      }
-      #gm light9-fader {
-        width: 300px;
-      }
-    `,
-  ];
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>Fade</h1>
-<div id="gm">
-  <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master
-</div>
-      ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))}
-
-      <div><button @click=${this.addPage}>Add new page</button></div>
-    `;
-  }
-  private renderPage(page: FadePage): TemplateResult {
-    const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage);
-    return html`<div class="${mappedToHw ? "mappedToHw" : ""}">
-      <fieldset>
-        <legend>
-          Page
-          <resource-display rename .uri=${page.uri}></resource-display>
-          ${mappedToHw ? html`mapped to hardware sliders` : html`
-          <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button>
-          `}
-        </legend>
-        ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)}
-      </fieldset>
-    </div>`;
-  }
-
-  graph!: SyncedGraph;
-  ctx: NamedNode = new NamedNode(showRoot + "/fade");
-
-  @property() fadePages?: FadePages;
-  @property() currentHwPage?: NamedNode;
-  @property() grandMaster?: number;
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this), `faders layout`);
-      this.graph.runHandler(this.compileGm.bind(this), `faders gm`);
-    });
-  }
-  connectedCallback(): void {
-    super.connectedCallback();
-  }
-
-  compile() {
-    const U = this.graph.U();
-    this.fadePages = undefined;
-    const fadePages = new FadePages();
-    for (let page of this.graph.subjects(U("rdf:type"), U(":FadePage"))) {
-      const fp = new FadePage(page as NamedNode);
-      try {
-        for (let fader of this.graph.objects(page, U(":fader"))) {
-          const colLit = this.graph.stringValue(fader, U(':column'))
-          fp.faderConfigs.push(new FaderConfig(fader as NamedNode, parseFloat(colLit)));
-        }
-        fp.faderConfigs.sort((a, b) => {
-          return a.column - (b.column);
-        });
-        fadePages.pages.push(fp);
-      } catch (e) { }
-    }
-    fadePages.pages.sort((a, b) => {
-      return a.uri.value.localeCompare(b.uri.value);
-    });
-    this.fadePages = fadePages;
-    this.currentHwPage = undefined;
-    try {
-      const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
-      this.currentHwPage = this.graph.uriValue(mc, U(":outputs"));
-    } catch (e) { }
-  }
-  compileGm() {
-    const U = this.graph.U();
-    this.grandMaster = undefined
-    let newVal
-    try {
-
-      newVal = this.graph.floatValue(U(':grandMaster'), U(':value'))
-    } catch (e) {
-      return
-    }
-    this.grandMaster = newVal;
-
-  }
-  gmChanged(ev: CustomEvent) {
-    const U = this.graph.U();
-    const newVal = ev.detail.value
-    // this.grandMaster = newVal;
-    this.graph.patchObject(U(':grandMaster'), U(':value'), this.graph.LiteralRoundedFloat(newVal), this.ctx)
-
-  }
-
-
-  mapThisToHw(page: NamedNode) {
-    const U = this.graph.U();
-    log("map to hw", page);
-    const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
-    this.graph.patchObject(mc, U(":outputs"), page, this.ctx);
-  }
-
-  addPage() {
-    const U = this.graph.U();
-    const uri = this.graph.nextNumberedResource(showRoot + "/fadePage");
-    const adds = [
-      //
-      new Quad(uri, U("rdf:type"), U(":FadePage"), this.ctx),
-      new Quad(uri, U("rdfs:label"), N3.DataFactory.literal("unnamed"), this.ctx),
-    ];
-    for (let n = 1; n <= 8; n++) {
-      const f = this.graph.nextNumberedResource(showRoot + "/fader");
-      const s = this.graph.nextNumberedResource(showRoot + "/faderset");
-      adds.push(new Quad(uri, U(":fader"), f, this.ctx));
-      adds.push(new Quad(f, U("rdf:type"), U(":Fader"), this.ctx));
-      adds.push(new Quad(f, U(":column"), N3.DataFactory.literal("" + n), this.ctx));
-      adds.push(new Quad(f, U(":setting"), s, this.ctx));
-      adds.push(new Quad(s, U(":effectAttr"), U(":strength"), this.ctx));
-      adds.push(new Quad(s, U(":value"), this.graph.LiteralRoundedFloat(0), this.ctx));
-    }
-    this.graph.applyAndSendPatch(new Patch([], adds));
-  }
-}
--- a/light9/web/fade/Light9Fader.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,146 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValueMap } from "lit";
-import { customElement, property, query } from "lit/decorators.js";
-
-import { clamp } from "../floating_color_picker";
-const log = debug("fade");
-
-class Drag {
-  constructor(public startDragPxY: number, public startDragValue: number) {}
-}
-
-@customElement("light9-fader")
-export class Light9Fader extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        border: 2px gray inset;
-        background: #000;
-        height: 80px;
-      }
-      #handle {
-        background: gray;
-        border: 5px gray outset;
-        position: relative;
-        left: 0;
-        right: -25px;
-      }
-    `,
-  ];
-
-  @property() value: number = 0;
-
-  @query("#handle") handleEl!: HTMLElement;
-
-  troughHeight = 80 - 2 - 2 - 5 - 5;
-  handleHeight = 10;
-
-  drag?: Drag;
-  unmutedValue: number = 1;
-
-  render() {
-    return html` <div id="handle"><hr /></div> `;
-  }
-
-  protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.update(changedProperties);
-    if (changedProperties.has("value")) {
-      
-    }
-  }
-  valueChangedFromUi() {
-    this.value= clamp(this.value, 0, 1)
-    this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value } }));
-  }
-
-  protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.updated(_changedProperties);
-    const y = this.sliderTopY(this.value);
-    this.handleEl.style.top = y + "px";
-  }
-
-  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.firstUpdated(_changedProperties);
-    this.handleEl.style.height = this.handleHeight + "px";
-    this.events();
-  }
-
-  events() {
-    const hand = this.handleEl;
-    hand.addEventListener("mousedown", (ev: MouseEvent) => {
-      ev.stopPropagation();
-      if (ev.buttons == 1) {
-        this.drag = new Drag(ev.clientY, this.value);
-      } else if (ev.buttons == 2) {
-        this.onRmb();
-      }
-    });
-    this.addEventListener("mousedown", (ev: MouseEvent) => {
-      ev.stopPropagation();
-      if (ev.buttons == 1) {
-        this.value = this.sliderValue(ev.offsetY);
-        this.valueChangedFromUi()
-        this.drag = new Drag(ev.clientY, this.value);
-      } else if (ev.buttons == 2) {
-        // RMB in trough
-        this.onRmb();
-      }
-    });
-
-    this.addEventListener("contextmenu", (event) => {
-      event.preventDefault();
-    });
-
-    this.addEventListener("wheel", (ev: WheelEvent) => {
-      ev.preventDefault();
-      this.value += ev.deltaY / this.troughHeight * -.05;
-      this.valueChangedFromUi()
-    });
-
-    const maybeDrag = (ev: MouseEvent) => {
-      if (ev.buttons != 1) return;
-      if (this.drag === undefined) return;
-      ev.stopPropagation();
-      this.onMouseDrag(ev.clientY - this.drag.startDragPxY!);
-    };
-    hand.addEventListener("mousemove", maybeDrag);
-    this.addEventListener("mousemove", maybeDrag);
-    window.addEventListener("mousemove", maybeDrag);
-
-    hand.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-    this.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-    window.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-  }
-  onRmb() {
-    if (this.value > 0.1) {
-      // mute
-      this.unmutedValue = this.value;
-      this.value = 0;
-    } else {
-      // unmute
-      this.value = this.unmutedValue;
-    }
-    this.valueChangedFromUi()
-  }
-  onMouseDrag(dy: number) {
-    if (this.drag === undefined) throw "unexpected";
-    this.value = this.drag.startDragValue - dy / this.troughHeight;
-    this.valueChangedFromUi()
-  }
-
-  onMouseUpAnywhere() {
-    this.drag = undefined;
-  }
-
-  sliderTopY(value: number): number {
-    const usableY = this.troughHeight - this.handleHeight;
-    const yAdj = this.handleHeight / 2 - 5 - 2;
-    return (1 - value) * usableY + yAdj;
-  }
-  sliderValue(offsetY: number): number {
-    const usableY = this.troughHeight - this.handleHeight;
-    const yAdj = this.handleHeight / 2 - 5 - 2;
-    return clamp(1 - (offsetY - yAdj) / usableY, 0, 1);
-  }
-}
--- a/light9/web/fade/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>fade</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css">    
-    <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script>
-    <script type="module" src="./Light9FadeUi"></script>
-  </head>
-  <body>
-    <light9-fade-ui></light9-fade-ui>
-  </body>
-</html>
--- a/light9/web/floating_color_picker.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,321 +0,0 @@
-// Note that this file deals only with hue+sat. See Light9ColorPicker for the value component.
-
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, query } from "lit/decorators.js";
-import color from "onecolor";
-import { SubEvent } from "sub-events";
-
-const log = debug("control.color.pick");
-
-export function clamp(x: number, lo: number, hi: number) {
-  return Math.max(lo, Math.min(hi, x));
-}
-
-class RainbowCoord {
-  // origin is rainbow top-lefft
-  constructor(public x: number, public y: number) {}
-}
-
-export class ClientCoord {
-  //  origin is top-left of client viewport (regardless of page scroll)
-  constructor(public x: number, public y: number) {}
-}
-
-// Load the rainbow, and map between colors and pixels.
-class RainbowCanvas {
-  ctx: CanvasRenderingContext2D;
-  colorPos: { [color: string]: RainbowCoord } = {};
-  _loaded = false;
-  _loadWatchers: (() => void)[] = [];
-  constructor(url: string, public size: RainbowCoord) {
-    var elem = document.createElement("canvas");
-    elem.width = size.x;
-    elem.height = size.y;
-    this.ctx = elem.getContext("2d")!;
-
-    var img = new Image();
-    img.onload = () => {
-      this.ctx.drawImage(img, 0, 0);
-      this._readImage();
-      this._loaded = true;
-      this._loadWatchers.forEach(function (cb) {
-        cb();
-      });
-      this._loadWatchers = [];
-    };
-    img.src = url;
-  }
-
-  onLoad(cb: () => void) {
-    // we'll call this when posFor is available
-    if (this._loaded) {
-      cb();
-      return;
-    }
-    this._loadWatchers.push(cb);
-  }
-
-  _readImage() {
-    var data = this.ctx.getImageData(0, 0, this.size.x, this.size.y).data;
-    for (var y = 0; y < this.size.y; y += 1) {
-      for (var x = 0; x < this.size.x; x += 1) {
-        var base = (y * this.size.x + x) * 4;
-        let px = [data[base + 0], data[base + 1], data[base + 2], 255];
-        if (px[0] == 0 && px[1] == 0 && px[2] == 0) {
-          // (there's no black on the rainbow images)
-          throw new Error(`color picker canvas (${this.size.x}) returns 0,0,0`);
-        }
-        var c = color(px).hex();
-        this.colorPos[c] = new RainbowCoord(x, y);
-      }
-    }
-  }
-
-  colorAt(pos: RainbowCoord) {
-    var data = this.ctx.getImageData(pos.x, pos.y, 1, 1).data;
-    return color([data[0], data[1], data[2], 255]).hex();
-  }
-
-  posFor(col: string): RainbowCoord {
-    if (col == "#000000") {
-      throw new Error("no match");
-    }
-
-    log("col", col);
-    if (col == "#ffffff") {
-      return new RainbowCoord(400 / 2, 0);
-    }
-
-    let bright = color(col).value(1).hex();
-    let r = parseInt(bright.slice(1, 3), 16),
-      g = parseInt(bright.slice(3, 5), 16),
-      b = parseInt(bright.slice(5, 7), 16);
-
-    // We may not have a match for this color exactly (e.g. on
-    // the small image), so we have to search for a near one.
-
-    // 0, 1, -1, 2, -2, ...
-    let walk = function (x: number): number {
-      return -x + (x > 0 ? 0 : 1);
-    };
-
-    var radius = 8;
-    for (var dr = 0; dr < radius; dr = walk(dr)) {
-      for (var dg = 0; dg < radius; dg = walk(dg)) {
-        for (var db = 0; db < radius; db = walk(db)) {
-          // Don't need bounds check- out of range
-          // corrupt colors just won't match.
-          const color2 = color([r + dr, g + dg, b + db, 255]);
-          const pos = this.colorPos[color2.hex()];
-          if (pos !== undefined) {
-            return pos;
-          }
-        }
-      }
-    }
-    throw new Error("no match");
-  }
-}
-
-// One-per-page element that floats above everything. Plus the scrim element, which is also per-page.
-@customElement("light9-color-picker-float")
-class Light9ColorPickerFloat extends LitElement {
-  static styles = [
-    css`
-      :host {
-        z-index: 10;
-        position: fixed; /* host coords are the same as client coords /*
-        left: 0;
-        top: 0;
-        width: 100%;
-        height: 100%;
-
-        /* Updated later. */
-        display: none;
-      }
-      #largeCrosshair {
-        position: absolute;
-        left: -60px;
-        top: -62px;
-        pointer-events: none;
-      }
-      #largeCrosshair {
-        background: url(/colorpick_crosshair_large.svg);
-        width: 1000px;
-        height: 1000px;
-      }
-      #largeRainbowComp {
-        z-index: 2;
-        position: relative;
-        width: 400px;
-        height: 200px;
-        border: 10px solid #000;
-        box-shadow: 8px 11px 40px 0px rgba(0, 0, 0, 0.74);
-        overflow: hidden;
-      }
-      #largeRainbow {
-        background: url(/colorpick_rainbow_large.png);
-        width: 400px;
-        height: 200px;
-        user-select: none;
-      }
-      #outOfBounds {
-        user-select: none;
-        z-index: 1;
-        background: #00000060;
-        position: absolute;
-        left: 0;
-        top: 0;
-        width: 100%;
-        height: 100%;
-      }
-    `,
-  ];
-
-  @query("#largeCrosshair") largeCrosshairEl!: HTMLElement;
-  @query("#largeRainbow") largeRainbowEl!: HTMLElement;
-
-  canvasMove: SubEvent<RainbowCoord> = new SubEvent();
-  outsideMove: SubEvent<ClientCoord> = new SubEvent();
-  mouseUp: SubEvent<void> = new SubEvent();
-
-  render() {
-    return html`
-      <!-- Temporary scrim on the rest of the page. It looks like we're dimming
-            the page to look pretty, but really this is so we can track the mouse
-            when it's outside the large canvas. -->
-      <div id="outOfBounds" @mousemove=${this.onOutOfBoundsMove} @mouseup=${this.onMouseUp}></div>
-      <div id="largeRainbowComp">
-        <div id="largeRainbow" @mousemove=${this.onCanvasMove} @mouseup=${this.onMouseUp}></div>
-        <div id="largeCrosshair"></div>
-      </div>
-    `;
-  }
-
-  // make top-left of rainbow image be at this pos
-  placeRainbow(pos: ClientCoord) {
-    const el = this.shadowRoot?.querySelector("#largeRainbowComp")! as HTMLElement;
-    const cssBorder = 10;
-    el.style.left = pos.x - cssBorder + "px";
-    el.style.top = pos.y - cssBorder + "px";
-  }
-
-  moveLargeCrosshair(pos: RainbowCoord) {
-    const ch = this.largeCrosshairEl;
-    ch.style.left = pos.x - ch.offsetWidth / 2 + "px";
-    ch.style.top = pos.y - ch.offsetHeight / 2 + "px";
-  }
-
-  private onCanvasMove(ev: MouseEvent) {
-    this.canvasMove.emit(new RainbowCoord(ev.offsetX, ev.offsetY));
-  }
-
-  private onMouseUp(ev: MouseEvent) {
-    this.mouseUp.emit();
-  }
-
-  private onOutOfBoundsMove(ev: MouseEvent) {
-    this.outsideMove.emit(new ClientCoord(ev.clientX, ev.clientY));
-  }
-}
-
-class PickerFloat {
-  private rainbow?: RainbowCanvas;
-  private currentListener?: (hsc: string) => void;
-  private rainbowOrigin: ClientCoord = new ClientCoord(0, 0);
-  private floatEl?: Light9ColorPickerFloat;
-
-  pageInit() {
-    this.getFloatEl();
-    this.getRainbow();
-  }
-  private forceHostStyle(el: HTMLElement) {
-    el.style.zIndex = "10";
-    el.style.position = "fixed";
-    el.style.left = "0";
-    el.style.top = "0";
-    el.style.width = "100%";
-    el.style.height = "100%";
-    el.style.display = "none";
-  }
-  private getFloatEl(): Light9ColorPickerFloat {
-    if (!this.floatEl) {
-      this.floatEl = document.createElement("light9-color-picker-float") as Light9ColorPickerFloat;
-      this.forceHostStyle(this.floatEl);
-      this.subscribeToFloatElement(this.floatEl);
-      document.body.appendChild(this.floatEl);
-    }
-    return this.floatEl;
-  }
-
-  private subscribeToFloatElement(el: Light9ColorPickerFloat) {
-    el.canvasMove.subscribe(this.onCanvasMove.bind(this));
-    el.outsideMove.subscribe(this.onOutsideMove.bind(this));
-    el.mouseUp.subscribe(() => {
-      this.hide();
-    });
-  }
-
-  private onCanvasMove(pos: RainbowCoord) {
-    pos = new RainbowCoord( //
-      clamp(pos.x, 0, 400 - 1), //
-      clamp(pos.y, 0, 200 - 1)
-    );
-    this.getFloatEl().moveLargeCrosshair(pos);
-    if (this.currentListener) {
-      this.currentListener(this.getRainbow().colorAt(pos));
-    }
-  }
-
-  private onOutsideMove(pos: ClientCoord) {
-    const rp = this.toRainbow(pos);
-    this.onCanvasMove(rp);
-  }
-
-  private getRainbow(): RainbowCanvas {
-    if (!this.rainbow) {
-      this.rainbow = new RainbowCanvas("/colorpick_rainbow_large.png", new RainbowCoord(400, 200));
-    }
-    return this.rainbow;
-  }
-
-  startPick(clickPoint: ClientCoord, startColor: string, onNewHueSatColor: (hsc: string) => void) {
-    const el = this.getFloatEl();
-
-    let pos: RainbowCoord;
-    try {
-      pos = this.getRainbow().posFor(startColor);
-    } catch (e) {
-      pos = new RainbowCoord(-999, -999);
-    }
-
-    this.rainbowOrigin = new ClientCoord( //
-      clickPoint.x - clamp(pos.x, 0, 400), //
-      clickPoint.y - clamp(pos.y, 0, 200)
-    );
-
-    el.placeRainbow(this.rainbowOrigin);
-    setTimeout(() => {
-      this.getFloatEl().moveLargeCrosshair(pos);
-    }, 1);
-
-    el.style.display = "block";
-    this.currentListener = onNewHueSatColor;
-  }
-
-  private hide() {
-    const el = this.getFloatEl();
-    el.style.display = "none";
-    this.currentListener = undefined;
-  }
-
-  private toRainbow(pos: ClientCoord): RainbowCoord {
-    return new RainbowCoord( //
-      pos.x - this.rainbowOrigin.x, //
-      pos.y - this.rainbowOrigin.y
-    );
-  }
-}
-
-export const pickerFloat = new PickerFloat();
--- a/light9/web/graph_test.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,257 +0,0 @@
-log = console.log
-assert = require('chai').assert
-expect = require('chai').expect
-SyncedGraph = require('./graph.js').SyncedGraph
-
-describe 'SyncedGraph', ->
-  describe 'constructor', ->
-    it 'should successfully make an empty graph without connecting to rdfdb', ->
-      g = new SyncedGraph()
-      g.quads()
-      assert.equal(g.quads().length, 0)
-
-  describe 'auto dependencies', ->
-    graph = new SyncedGraph()
-    RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
-    U = (tail) -> graph.Uri('http://example.com/' + tail)
-    A1 = U('a1')
-    A2 = U('a2')
-    A3 = U('a3')
-    A4 = U('a4')
-    ctx = U('ctx')
-    quad = (s, p, o) -> graph.Quad(s, p, o, ctx)
-
-    beforeEach (done) ->
-      graph = new SyncedGraph()
-      graph.loadTrig("
-        @prefix : <http://example.com/> .
-        :ctx {
-          :a1 :a2 :a3 .
-          :a1 :someFloat 1.5 .
-          :a1 :someString \"hello\" .
-          :a1 :multipleObjects :a4, :a5 .
-          :a2 a :Type1 .
-          :a3 a :Type1 .
-        }
-      ", done)
-    
-    it 'calls a handler right away', ->
-      called = 0
-      hand = ->
-        called++
-      graph.runHandler(hand, 'run')
-      assert.equal(1, called)
-      
-    it 'calls a handler a 2nd time if the graph is patched with relevant data', ->
-      called = 0
-      hand = ->
-        called++
-        graph.uriValue(A1, A2)
-      graph.runHandler(hand, 'run')
-      graph.applyAndSendPatch({
-        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
-      assert.equal(2, called)
-
-    it 'notices new queries a handler makes upon rerun', ->
-      called = 0
-      objsFound = []
-      hand = ->
-        called++
-        graph.uriValue(A1, A2)
-        if called > 1
-          objsFound.push(graph.objects(A1, A3))
-      graph.runHandler(hand, 'run')
-      # first run looked up A1,A2,*
-      graph.applyAndSendPatch({
-        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
-      # second run also looked up A1,A3,* (which matched none)
-      graph.applyAndSendPatch({
-        delQuads: [], addQuads: [quad(A1, A3, A4)]})
-      # third run should happen here, noticing the new A1,A3,* match
-      assert.equal(3, called)
-      assert.deepEqual([[], [A4]], objsFound)
-
-    it 'calls a handler again even if the handler throws an error', ->
-      called = 0
-      hand = ->
-        called++
-        graph.uriValue(A1, A2)
-        throw new Error('this test handler throws an error')
-      graph.runHandler(hand, 'run')
-      graph.applyAndSendPatch({
-        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
-      assert.equal(2, called)
-
-    describe 'works with nested handlers', ->
-
-      innerResults = []
-      inner = ->
-        console.log('\nninnerfetch')
-        innerResults.push(graph.uriValue(A1, A2))
-        console.log("innerResults #{JSON.stringify(innerResults)}\n")
-
-      outerResults = []
-      doRunInner = true
-      outer = ->
-        if doRunInner
-          graph.runHandler(inner, 'runinner')
-        console.log('push outer')
-        outerResults.push(graph.floatValue(A1, U('someFloat')))
-
-      beforeEach ->
-        innerResults = []
-        outerResults = []
-        doRunInner = true
-
-      affectInner = {
-        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]
-      }
-      affectOuter = {
-        delQuads: [
-          quad(A1, U('someFloat'), graph.Literal(1.5))
-        ], addQuads: [
-          quad(A1, U('someFloat'), graph.LiteralRoundedFloat(2))
-        ]}
-      affectBoth = {
-        delQuads: affectInner.delQuads.concat(affectOuter.delQuads),
-        addQuads: affectInner.addQuads.concat(affectOuter.addQuads)
-        }
-                  
-      it 'calls everything normally once', ->
-        graph.runHandler(outer, 'run')
-        assert.deepEqual([A3], innerResults)
-        assert.deepEqual([1.5], outerResults)
-
-      it.skip '[performance] reruns just the inner if its dependencies change', ->
-        console.log(graph.quads())
-        graph.runHandler(outer, 'run')
-        graph.applyAndSendPatch(affectInner)
-        assert.deepEqual([A3, A4], innerResults)
-        assert.deepEqual([1.5], outerResults)
-        
-      it.skip '[performance] reruns the outer (and therefore inner) if its dependencies change', ->
-        graph.runHandler(outer, 'run')
-        graph.applyAndSendPatch(affectOuter)
-        assert.deepEqual([A3, A3], innerResults)
-        assert.deepEqual([1.5, 2], outerResults)
-        
-        
-      it.skip '[performance] does not send a redundant inner run if it is already rerunning outer', ->
-        # Note that outer may or may not call inner each time, and we
-        # don't want to redundantly call inner. We need to:
-        #  1. build the set of handlers to rerun,
-        #  2. call them from outside-in, and
-        #  3. any runHandler calls that happen, they need to count as reruns.
-        graph.runHandler(outer, 'run')
-        graph.applyAndSendPatch(affectBoth)
-        assert.deepEqual([A3, A4], innerResults)
-        assert.deepEqual([1.5, 2], outerResults)
-
-      it 'reruns the outer and the inner if all dependencies change, but outer omits calling inner this time', ->
-        graph.runHandler(outer, 'run')
-        doRunInner = false
-        graph.applyAndSendPatch(affectBoth)
-        assert.deepEqual([A3, A4], innerResults)
-        assert.deepEqual([1.5, 2], outerResults)
-        
-    describe 'watches calls to:', ->
-      it 'floatValue', ->
-        values = []
-        hand = -> values.push(graph.floatValue(A1, U('someFloat')))
-        graph.runHandler(hand, 'run')
-        graph.patchObject(A1, U('someFloat'), graph.LiteralRoundedFloat(2), ctx)
-        assert.deepEqual([1.5, 2.0], values)
-        
-      it 'stringValue', ->
-        values = []
-        hand = -> values.push(graph.stringValue(A1, U('someString')))
-        graph.runHandler(hand, 'run')
-        graph.patchObject(A1, U('someString'), graph.Literal('world'), ctx)
-        assert.deepEqual(['hello', 'world'], values)
-
-      it 'uriValue', ->
-        # covered above, but this one tests patchObject on a uri, too
-        values = []
-        hand = -> values.push(graph.uriValue(A1, A2))
-        graph.runHandler(hand, 'run')
-        graph.patchObject(A1, A2, A4, ctx)
-        assert.deepEqual([A3, A4], values)
-
-      it 'objects', ->
-        values = []
-        hand = -> values.push(graph.objects(A1, U('multipleObjects')))
-        graph.runHandler(hand, 'run')
-        graph.patchObject(A1, U('multipleObjects'), U('newOne'), ctx)
-        expect(values[0]).to.deep.have.members([U('a4'), U('a5')])
-        expect(values[1]).to.deep.have.members([U('newOne')])
-
-      it 'subjects', ->
-        values = []
-        rdfType = graph.Uri(RDF + 'type')
-        hand = -> values.push(graph.subjects(rdfType, U('Type1')))
-        graph.runHandler(hand, 'run')
-        graph.applyAndSendPatch(
-          {delQuads: [], addQuads: [quad(A4, rdfType, U('Type1'))]})
-        expect(values[0]).to.deep.have.members([A2, A3])
-        expect(values[1]).to.deep.have.members([A2, A3, A4])
-
-      describe 'items', ->
-        it 'when the list order changes', (done) ->
-          values = []
-          successes = 0
-          hand = ->
-            try
-              head = graph.uriValue(U('x'), U('y'))
-            catch
-              # graph goes empty between clearGraph and loadTrig
-              return
-            values.push(graph.items(head))
-            successes++
-          graph.clearGraph()
-          graph.loadTrig "
-            @prefix : <http://example.com/> .
-            :ctx { :x :y (:a1 :a2 :a3) } .
-          ", () ->
-             graph.runHandler(hand, 'run')
-             graph.clearGraph()
-             graph.loadTrig "
-              @prefix : <http://example.com/> .
-              :ctx { :x :y (:a1 :a3 :a2) } .
-            ", () ->
-               assert.deepEqual([[A1, A2, A3], [A1, A3, A2]], values)
-               assert.equal(2, successes)
-               done()
-  
-      describe 'contains', ->
-        it 'when a new triple is added', ->
-          values = []
-          hand = -> values.push(graph.contains(A1, A1, A1))
-          graph.runHandler(hand, 'run')
-          graph.applyAndSendPatch(
-            {delQuads: [], addQuads: [quad(A1, A1, A1)]})
-          assert.deepEqual([false, true], values)
-
-        it 'when a relevant triple is removed', ->
-          values = []
-          hand = -> values.push(graph.contains(A1, A2, A3))
-          graph.runHandler(hand, 'run')
-          graph.applyAndSendPatch(
-            {delQuads: [quad(A1, A2, A3)], addQuads: []})
-          assert.deepEqual([true, false], values)
-
-    describe 'performs well', ->
-      it "[performance] doesn't call handler a 2nd time if the graph gets an unrelated patch", ->
-        called = 0
-        hand = ->
-          called++
-          graph.uriValue(A1, A2)
-        graph.runHandler(hand, 'run')
-        graph.applyAndSendPatch({
-          delQuads: [], addQuads: [quad(A2, A3, A4)]})
-        assert.equal(1, called)
-
-      it.skip '[performance] calls a handler 2x but then not again if the handler stopped caring about the data', ->
-        assert.fail()
-
-      it.skip "[performance] doesn't get slow if the handler makes tons of repetitive lookups", ->
-        assert.fail()
--- a/light9/web/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>light9 home</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="style.css" />
-    <link rel="stylesheet" href="flexlayout-react/style/dark.css" />
-    <script type="module" src="TiledHome.ts"></script>
-  </head>
-  <body>
-    <div id="container"></div>
-  </body>
-</html>
--- a/light9/web/lib/.bowerrc	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-{
-  "directory": "."
-}
-
--- a/light9/web/lib/bower.json	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-{
-  "name": "3rd-party-libs",
-  "dependencies": {
-    "N3.js": "https://github.com/RubenVerborgh/N3.js.git#04f4e21f4ccb351587dc00a3f26340b28d4bb10f",
-    "QueryString": "http://unixpapa.com/js/QueryString.js",
-    "async": "https://github.com/caolan/async.git#^1.5.2",
-    "color": "https://github.com/One-com/one-color.git#^3.0.4",
-    "iron-ajax": "PolymerElements/iron-ajax#~2.1.3",
-    "iron-resizable-behavior": "PolymerElements/iron-resizable-behavior#^2.1.0",
-    "isotope": "^3.0.4",
-    "isotope-fit-columns": "^1.1.3",
-    "jquery": "^3.3.1",
-    "jquery-ui": "~1.11.4",
-    "jquery.columnizer": "https://github.com/adamwulf/Columnizer-jQuery-Plugin.git#^1.6.2",
-    "knockout": "knockoutjs#^3.4.2",
-    "paper-button": "PolymerElements/paper-button#^2.1.1",
-    "paper-dialog": "PolymerElements/paper-dialog#^2.1.0",
-    "paper-item": "PolymerElements/paper-item#2.1.0",
-    "paper-listbox": "PolymerElements/paper-listbox#2.1.0",
-    "paper-radio-button": "PolymerElements/paper-radio-button#^2.1.0",
-    "paper-radio-group": "PolymerElements/paper-radio-group#^2.1.0",
-    "paper-slider": "PolymerElements/paper-slider#~2.0.6",
-    "paper-styles": "PolymerElements/paper-styles#^2.1.0",
-    "rdflib.js": "https://github.com/linkeddata/rdflib.js.git#920e59fe37",
-    "rdfstore": "https://github.com/antoniogarrote/rdfstore-js.git#b3f7c0c9c1da9b26261af0d4858722fa982411bb",
-    "shortcut": "http://www.openjs.com/scripts/events/keyboard_shortcuts/shortcut.js",
-    "sylvester": "~0.1.3",
-    "underscore": "~1.8.3",
-    "polymer": "Polymer/polymer#^2.0.0",
-    "iron-flex-layout": "PolymerElements/iron-flex-layout#^2.0.3",
-    "iron-component-page": "PolymerElements/iron-component-page#^3.0.1",
-    "paper-header-panel": "PolymerElements/paper-header-panel#^2.1.0",
-    "iron-overlay-behavior": "PolymerElements/iron-overlay-behavior#^2.3.4",
-    "debug": "https://github.com/visionmedia/debug/archive/master.zip"
-  },
-  "resolutions": {
-    "webcomponentsjs": "^v1.1.0",
-    "polymer": "^2.0.0",
-    "iron-flex-layout": "^2.0.3",
-    "paper-button": "^2.1.1",
-    "iron-component-page": "^3.0.1",
-    "iron-doc-viewer": "^3.0.0",
-    "paper-header-panel": "^2.1.0",
-    "iron-overlay-behavior": "^2.3.4"
-  }
-}
--- a/light9/web/lib/onecolor.d.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-type ColorFormat = "hex" | "rgb" | "hsl" | "hsv";
-interface Color {
-  clone(): this;
-  toString(format?: ColorFormat): string;
-  toJSON(): string;
-  value(): number;
-  value(v: number): this;
-  hex(): string;
-}
-
-declare function color(value: any): Color;
-
-export = color;
--- a/light9/web/lib/parse-prometheus-text-format.d.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-declare module "parse-prometheus-text-format" {
-  function parsePrometheusTextFormat(s: string): any;
-  export default parsePrometheusTextFormat;
-}
--- a/light9/web/lib/sylvester.d.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,828 +0,0 @@
-// local fixes; the DefinitelyTyped one had "is not a module" errors
-
-
-// Type definitions for sylvester 0.1.3
-// Project: https://github.com/jcoglan/sylvester
-// Definitions by: Stephane Alie <https://github.com/StephaneAlie>
-// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
-
-// === Sylvester ===
-// Vector and Matrix mathematics modules for JavaScript
-// Copyright (c) 2007 James Coglan
-
-export declare module Sylvester {
-    interface VectorStatic {
-        /**
-         * Constructor function.
-         */
-        create(elements: Vector|Array<number>): Vector;
-
-        i: Vector;
-        j: Vector;
-        k: Vector;
-
-        /**
-         * Random vector of size n.
-         *
-         * @param {number} n The vector size.
-         */
-        Random(n: number): Vector;
-
-        /**
-         * Vector filled with zeros.
-         *
-         * @param {number} n The vector size.
-         */
-        Zero(n: number): Vector;
-    }
-    interface MatrixStatic {
-        /**
-         * Constructor function.
-         *
-         * @param {Array<number>|Array<Array<number>>|Vector|Matrix} elements The elements.
-         */
-        create(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
-
-        /**
-         * Identity matrix of size n.
-         *
-         * @param {number} n The size.
-         */
-        I(n: number): Matrix;
-
-        /**
-         * Diagonal matrix - all off-diagonal elements are zero
-         *
-         * @param {any} elements The elements.
-         */
-        Diagonal(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
-
-        /**
-         * Rotation matrix about some axis. If no axis is supplied, assume we're after a 2D transform.
-         *
-         * @param {number} theta The angle in radians.
-         * @param {Vector} a [Optional] The axis.
-         */
-        Rotation(theta: number, a?: Vector): Matrix;
-
-        RotationX(t: number): Matrix;
-        RotationY(t: number): Matrix;
-        RotationZ(t: number): Matrix;
-
-        /**
-         * Random matrix of n rows, m columns.
-         *
-         * @param {number} n The number of rows.
-         * @param {number} m The number of columns.
-         */
-        Random(n: number, m: number): Matrix;
-
-        /**
-         * Matrix filled with zeros.
-         *
-         * @param {number} n The number of rows.
-         * @param {number} m The number of columns.
-         */
-        Zero(n: number, m: number): Matrix;
-    }
-
-    interface LineStatic {
-        /**
-         * Constructor function.
-         *
-         * @param Array<number>|Vector anchor The anchor vector.
-         * @param Array<number>|Vector direction The direction vector.
-         */
-        create(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
-
-        X: Line;
-        Y: Line;
-        Z: Line;
-    }
-    interface PlaneStatic {
-        /**
-         * Constructor function.
-         */
-        create(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
-
-        /**
-         * Constructor function.
-         */
-        create(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
-
-        XY: Plane;
-        YZ: Plane;
-        ZX: Plane;
-        YX: Plane;
-    }
-}
-
-interface Vector {
-    /**
-     * Gets an array containing the vector's elements.
-     */
-    elements: Array<number>;
-
-    /**
-     * Returns element i of the vector.
-     */
-    e(i: number): number;
-
-    /**
-     * Returns the number of elements the vector has.
-     */
-    dimensions(): number;
-
-    /**
-     * Returns the modulus ('length') of the vector.
-     */
-    modulus(): number;
-
-    /**
-     * Returns true if the vector is equal to the argument.
-     *
-     * @param {Vector|Array<number>} vector The vector to compare equality.
-     */
-    eql(vector: Vector|Array<number>): boolean;
-
-    /**
-     * Returns a copy of the vector.
-     */
-    dup(): Vector;
-
-    /**
-     * Maps the vector to another vector according to the given function.
-     *
-     * @param {Function} fn The function to apply to each element (x, i) => {}.
-     */
-    map(fn: (x: number, i: number) => any): Vector;
-
-    /**
-     * Calls the iterator for each element of the vector in turn.
-     *
-     * @param {Function} fn The function to apply to each element (x, i) => {}.
-     */
-    each(fn: (x: number, i: number) => any): void;
-
-    /**
-     * Returns a new vector created by normalizing the receiver.
-     */
-    toUnitVector(): Vector;
-
-    /**
-     * Returns the angle between the vector and the argument (also a vector).
-     *
-     * @param {Vector} vector The other vector to calculate the angle.
-     */
-    angleFrom(vector: Vector): number;
-
-    /**
-     * Returns true if the vector is parallel to the argument.
-     *
-     * @param {Vector} vector The other vector.
-     */
-    isParallelTo(vector: Vector): boolean;
-
-    /**
-     * Returns true if the vector is antiparallel to the argument.
-     *
-     * @param {Vector} vector The other vector.
-     */
-    isAntiparallelTo(vector: Vector): boolean;
-
-    /**
-     * Returns true iff the vector is perpendicular to the argument.
-     *
-     * @param {Vector} vector The other vector.
-     */
-    isPerpendicularTo(vector: Vector): boolean;
-
-    /**
-     * Returns the result of adding the argument to the vector.
-     *
-     * @param {Vector|Array<number>} vector The vector.
-     */
-    add(vector: Vector|Array<number>): Vector;
-
-    /**
-     * Returns the result of subtracting the argument from the vector.
-     *
-     * @param {Vector|Array<number>} vector The vector.
-     */
-    subtract(vector: Vector|Array<number>): Vector;
-
-    /**
-     * Returns the result of multiplying the elements of the vector by the argument.
-     *
-     * @param {number} k The value by which to multiply the vector.
-     */
-    multiply(k: number): Vector;
-
-    /**
-     * Returns the result of multiplying the elements of the vector by the argument (Alias for multiply(k)).
-     *
-     * @param {number} k The value by which to multiply the vector.
-     */
-    x(k: number): Vector;
-
-    /**
-     * Returns the scalar product of the vector with the argument. Both vectors must have equal dimensionality.
-     *
-     * @param: {Vector|Array<number>} vector The other vector.
-     */
-    dot(vector: Vector|Array<number>): number;
-
-    /**
-     * Returns the vector product of the vector with the argument. Both vectors must have dimensionality 3.
-     *
-     * @param {Vector|Array<number>} vector The other vector.
-     */
-    cross(vector: Vector|Array<number>): Vector;
-
-    /**
-     * Returns the (absolute) largest element of the vector.
-     */
-    max(): number;
-
-    /**
-     * Returns the index of the first match found.
-     *
-     * @param {number} x The value.
-     */
-    indexOf(x: number): number;
-
-    /**
-     * Returns a diagonal matrix with the vector's elements as its diagonal elements.
-     */
-    toDiagonalMatrix(): Matrix;
-
-    /**
-     * Returns the result of rounding the elements of the vector.
-     */
-    round(): Vector;
-
-    /**
-     * Returns a copy of the vector with elements set to the given value if they differ from
-     * it by less than Sylvester.precision.
-     *
-     * @param {number} x The value to snap to.
-     */
-    snapTo(x: number): Vector;
-
-    /**
-     * Returns the vector's distance from the argument, when considered as a point in space.
-     *
-     * @param {Vector|Line|Plane} obj The object to calculate the distance.
-     */
-    distanceFrom(obj: Vector|Line|Plane): number;
-
-    /**
-     * Returns true if the vector is point on the given line.
-     *
-     * @param {Line} line The line.
-     */
-    liesOn(line: Line): boolean;
-
-    /**
-     * Return true if the vector is a point in the given plane.
-     *
-     * @param {Plane} plane The plane.
-     */
-    liesIn(plane: Plane): boolean;
-
-    /**
-     * Rotates the vector about the given object. The object should be a point if the vector is 2D,
-     * and a line if it is 3D. Be careful with line directions!
-     *
-     * @param {number|Matrix} t The angle in radians or in rotation matrix.
-     * @param {Vector|Line} obj The rotation axis.
-     */
-    rotate(t: number|Matrix, obj: Vector|Line): Vector;
-
-    /**
-     * Returns the result of reflecting the point in the given point, line or plane.
-     *
-     * @param {Vector|Line|Plane} obj The object.
-     */
-    reflectionIn(obj: Vector|Line|Plane): Vector;
-
-    /**
-     * Utility to make sure vectors are 3D. If they are 2D, a zero z-component is added.
-     */
-    to3D(): Vector;
-
-    /**
-     * Returns a string representation of the vector.
-     */
-    inspect(): string;
-
-    /**
-     * Set vector's elements from an array.
-     *
-     * @param {Vector|Array<number>} els The elements.
-     */
-    setElements(els: Vector|Array<number>): Vector;
-}
-
-interface Matrix {
-    /**
-     * Gets a nested array containing the matrix's elements.
-     */
-    elements: Array<Array<number>>;
-    /**
-     * Returns element (i,j) of the matrix.
-     *
-     * @param {number} i The row index.
-     * @param {number} j The column index.
-     */
-    e(i: number, j: number): any;
-
-    /**
-     * Returns row k of the matrix as a vector.
-     *
-     * @param {number} i The row index.
-     */
-    row(i: number): Vector;
-
-    /**
-     * Returns column k of the matrix as a vector.
-     *
-     * @param {number} j The column index.
-     */
-    col(j: number): Vector;
-
-    /**
-     * Returns the number of rows/columns the matrix has.
-     *
-     * @return {any} An object { rows: , cols: }.
-     */
-    dimensions(): any;
-
-    /**
-     * Returns the number of rows in the matrix.
-     */
-    rows(): number;
-
-    /**
-     * Returns the number of columns in the matrix.
-     */
-    cols(): number;
-
-    /**
-     * Returns true if the matrix is equal to the argument. You can supply a vector as the argument,
-     * in which case the receiver must be a one-column matrix equal to the vector.
-     *
-     * @param {Vector|Matrix} matrix The argument to compare.
-     */
-    eql(matrix: Vector|Matrix): boolean;
-
-    /**
-     * Returns a copy of the matrix.
-     */
-    dup(): Matrix;
-
-    /**
-     * Maps the matrix to another matrix (of the same dimensions) according to the given function.
-     *
-     * @param {Function} fn The function.
-     */
-    map(fn: (x: number, i: number, j: number) => any): Matrix;
-
-    /**
-     * Returns true iff the argument has the same dimensions as the matrix.
-     *
-     * @param {Matrix} matrix The other matrix.
-     */
-    isSameSizeAs(matrix: Matrix): boolean;
-
-    /**
-     * Returns the result of adding the argument to the matrix.
-     *
-     * @param {Matrix} matrix The matrix to add.
-     */
-    add(matrix: Matrix): Matrix;
-
-    /**
-     * Returns the result of subtracting the argument from the matrix.
-     *
-     * @param {Matrix} matrix The matrix to substract.
-     */
-    subtract(matrix: Matrix): Matrix;
-
-    /**
-     * Returns true iff the matrix can multiply the argument from the left.
-     *
-     * @param {Matrix} matrix The matrix.
-     */
-    canMultiplyFromLeft(matrix: Matrix): boolean;
-
-    /**
-     * Returns the result of multiplying the matrix from the right by the argument. If the argument is a scalar
-     * then just multiply all the elements. If the argument is a vector, a vector is returned, which saves you
-     * having to remember calling col(1) on the result.
-     *
-     * @param {number|Matrix} matrix The multiplier.
-     */
-    multiply(matrix: number|Matrix): Matrix;
-
-    /**
-     * Returns the result of multiplying the matrix from the right by the argument. If the argument is a scalar
-     * then just multiply all the elements. If the argument is a vector, a vector is returned, which saves you
-     * having to remember calling col(1) on the result.
-     *
-     * @param {Vector} vector The multiplier.
-     */
-    multiply(vector: Vector): Vector;
-
-    x(matrix: number|Matrix): Matrix;
-
-    x(vector: Vector): Vector;
-
-    /**
-     * Returns a submatrix taken from the matrix. Argument order is: start row, start col, nrows, ncols.
-     * Element selection wraps if the required index is outside the matrix's bounds, so you could use
-     * this to perform row/column cycling or copy-augmenting.
-     *
-     * @param {number} a Starting row index.
-     * @param {number} b Starting column index.
-     * @param {number} c Number of rows.
-     * @param {number} d Number of columns.
-     */
-    minor(a: number, b: number, c: number, d: number): Matrix;
-
-    /**
-     * Returns the transpose of the matrix.
-     */
-    transpose(): Matrix;
-
-    /**
-     * Returns true if the matrix is square.
-     */
-    isSquare(): boolean;
-
-    /**
-     * Returns the (absolute) largest element of the matrix.
-     */
-    max(): number;
-
-    /**
-     * Returns the indeces of the first match found by reading row-by-row from left to right.
-     *
-     * @param {number} x The value.
-     *
-     * @return {any} The element indeces i.e: { row:1, col:1 }
-     */
-    indexOf(x: number): any;
-
-    /**
-     * If the matrix is square, returns the diagonal elements as a vector; otherwise, returns null.
-     */
-    diagonal(): Vector;
-
-    /**
-     * Make the matrix upper (right) triangular by Gaussian elimination. This method only adds multiples
-     * of rows to other rows. No rows are scaled up or switched, and the determinant is preserved.
-     */
-    toRightTriangular(): Matrix;
-    toUpperTriangular(): Matrix;
-
-    /**
-     * Returns the determinant for square matrices.
-     */
-    determinant(): number;
-    det(): number;
-
-    /**
-     * Returns true if the matrix is singular.
-     */
-    isSingular(): boolean;
-
-    /**
-     * Returns the trace for square matrices.
-     */
-    trace(): number;
-    tr(): number;
-
-    /**
-     * Returns the rank of the matrix.
-     */
-    rank(): number;
-    rk(): number;
-
-    /**
-     * Returns the result of attaching the given argument to the right-hand side of the matrix.
-     *
-     * @param {Matrix|Vector} matrix The matrix or vector.
-     */
-    augment(matrix: Matrix|Vector): Matrix;
-
-    /**
-     * Returns the inverse (if one exists) using Gauss-Jordan.
-     */
-    inverse(): Matrix;
-    inv(): Matrix;
-
-    /**
-     * Returns the result of rounding all the elements.
-     */
-    round(): Matrix;
-
-    /**
-     * Returns a copy of the matrix with elements set to the given value if they differ from it
-     * by less than Sylvester.precision.
-     *
-     * @param {number} x The value.
-     */
-    snapTo(x: number): Matrix;
-
-    /**
-     * Returns a string representation of the matrix.
-     */
-    inspect(): string;
-
-    /**
-     * Set the matrix's elements from an array. If the argument passed is a vector, the resulting matrix
-     * will be a single column.
-     *
-     * @param {Array<number>|Array<Array<number>>|Vector|Matrix} matrix The elements.
-     */
-    setElements(matrix: Array<number>|Array<Array<number>>|Vector|Matrix): Matrix;
-}
-
-interface Line {
-    /**
-     * Gets the 3D vector corresponding to a point on the line.
-     */
-    anchor: Vector;
-
-    /**
-     * Gets a normalized 3D vector representing the line's direction.
-     */
-    direction: Vector;
-
-    /**
-     * Returns true if the argument occupies the same space as the line.
-     *
-     * @param {Line} line The other line.
-     */
-    eql(line: Line): boolean;
-
-    /**
-     * Returns a copy of the line.
-     */
-    dup(): Line;
-
-    /**
-     * Returns the result of translating the line by the given vector/array.
-     *
-     * @param {Vector|Array<number>} vector The translation vector.
-     */
-    translate(vector: Vector|Array<number>): Line;
-
-    /**
-     * Returns true if the line is parallel to the argument. Here, 'parallel to' means that the argument's
-     * direction is either parallel or antiparallel to the line's own direction. A line is parallel to a
-     * plane if the two do not have a unique intersection.
-     *
-     * @param {Line|Plane} obj The object.
-     */
-    isParallelTo(obj: Line|Plane): boolean;
-
-    /**
-     * Returns the line's perpendicular distance from the argument, which can be a point, a line or a plane.
-     *
-     * @param {Vector|Line|Plane} obj The object.
-     */
-    distanceFrom(obj: Vector|Line|Plane): number;
-
-    /**
-     * Returns true if the argument is a point on the line.
-     *
-     * @param {Vector} point The point.
-     */
-    contains(point: Vector): boolean;
-
-    /**
-     * Returns true if the line lies in the given plane.
-     *
-     * @param {Plane} plane The plane.
-     */
-    liesIn(plane: Plane): boolean;
-
-    /**
-     * Returns true if the line has a unique point of intersection with the argument.
-     *
-     * @param {Line|Plane} obj The object.
-     */
-    intersects(obj: Line|Plane): boolean;
-
-    /**
-     * Returns the unique intersection point with the argument, if one exists.
-     *
-     * @param {Line|Plane} obj The object.
-     */
-    intersectionWith(obj: Line|Plane): Vector;
-
-    /**
-     * Returns the point on the line that is closest to the given point or line.
-     *
-     * @param {Vector|Line} obj The object.
-     */
-    pointClosestTo(obj: Vector|Line): Vector;
-
-    /**
-     * Returns a copy of the line rotated by t radians about the given line. Works by finding the argument's
-     * closest point to this line's anchor point (call this C) and rotating the anchor about C. Also rotates
-     * the line's direction about the argument's. Be careful with this - the rotation axis' direction
-     * affects the outcome!
-     *
-     * @param {number} t The angle in radians.
-     * @param {Vector|Line} axis The axis.
-     */
-    rotate(t: number, axis: Vector|Line): Line;
-
-    /**
-     * Returns the line's reflection in the given point or line.
-     *
-     * @param {Vector|Line|Plane} obj The object.
-     */
-    reflectionIn(obj: Vector|Line|Plane): Line;
-
-    /**
-     * Set the line's anchor point and direction.
-     *
-     * @param {Array<number>|Vector} anchor The anchor vector.
-     * @param {Array<number>|Vector} direction The direction vector.
-     */
-    setVectors(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
-}
-
-interface Plane {
-    /**
-     * Gets the 3D vector corresponding to a point in the plane.
-     */
-    anchor: Vector;
-
-    /**
-     * Gets a normalized 3D vector perpendicular to the plane.
-     */
-    normal: Vector;
-
-    /**
-     * Returns true if the plane occupies the same space as the argument.
-     *
-     * @param {Plane} plane The other plane.
-     */
-    eql(plane: Plane): boolean;
-
-    /**
-     * Returns a copy of the plane.
-     */
-    dup(): Plane;
-
-    /**
-     * Returns the result of translating the plane by the given vector.
-     *
-     * @param {Array<number>|Vector} vector The translation vector.
-     */
-    translate(vector: Array<number>|Vector): Plane;
-
-    /**
-     * Returns true if the plane is parallel to the argument. Will return true if the planes are equal,
-     * or if you give a line and it lies in the plane.
-     *
-     * @param {Line|Plane} obj The object.
-     */
-    isParallelTo(obj: Line|Plane): boolean;
-
-    /**
-     * Returns true if the receiver is perpendicular to the argument.
-     *
-     * @param {Plane} plane The other plane.
-     */
-    isPerpendicularTo(plane: Plane): boolean;
-
-    /**
-     * Returns the plane's distance from the given object (point, line or plane).
-     *
-     * @parm {Vector|Line|Plane} obj The object.
-     */
-    distanceFrom(obj: Vector|Line|Plane): number;
-
-    /**
-     * Returns true if the plane contains the given point or line.
-     *
-     * @param {Vector|Line} obj The object.
-     */
-    contains(obj: Vector|Line): boolean;
-
-    /**
-     * Returns true if the plane has a unique point/line of intersection with the argument.
-     *
-     * @param {Line|Plane} obj The object.
-     */
-    intersects(obj: Line|Plane): boolean;
-
-    /**
-     * Returns the unique intersection with the argument, if one exists.
-     *
-     * @param {Line} line The line.
-     */
-    intersectionWith(line: Line): Vector;
-
-    /**
-     * Returns the unique intersection with the argument, if one exists.
-     *
-     * @param {Plane} plane The plane.
-     */
-    intersectionWith(plane: Plane): Line;
-
-    /**
-     * Returns the point in the plane closest to the given point.
-     *
-     * @param {Vector} point The point.
-     */
-    pointClosestTo(point: Vector): Vector;
-
-    /**
-     * Returns a copy of the plane, rotated by t radians about the given line. See notes on Line#rotate.
-     *
-     * @param {number} t The angle in radians.
-     * @param {Line} axis The line axis.
-     */
-    rotate(t: number, axis: Line): Plane;
-
-    /**
-     * Returns the reflection of the plane in the given point, line or plane.
-     *
-     * @param {Vector|Line|Plane} obj The object.
-     */
-    reflectionIn(obj: Vector|Line|Plane): Plane;
-
-    /**
-     * Sets the anchor point and normal to the plane. Normal vector is normalised before storage.
-     *
-     * @param {Array<number>|Vector} anchor The anchor vector.
-     * @param {Array<number>|Vector} normal The normal vector.
-     */
-    setVectors(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
-
-    /**
-     * Sets the anchor point and normal to the plane. The normal is calculated by assuming the three points
-     * should lie in the same plane. Normal vector is normalised before storage.
-     *
-     * @param {Array<number>|Vector} anchor The anchor vector.
-     * @param {Array<number>|Vector} v1 The first direction vector.
-     * @param {Array<number>|Vector} v2 The second direction vector.
-     */
-    setVectors(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
-}
-
-declare module Sylvester {
-    export var version: string;
-    export var precision: number;
-}
-
-declare var Vector: Sylvester.VectorStatic;
-declare var Matrix: Sylvester.MatrixStatic;
-declare var Line: Sylvester.LineStatic;
-declare var Plane: Sylvester.PlaneStatic;
-
-/**
-* Constructor function.
-*
-* @param {Vector|Array<number} elements The elements.
-*/
-declare function $V(elements: Vector|Array<number>): Vector;
-
-/**
-* Constructor function.
-*
-* @param {Array<number>|Array<Array<number>>|Vector|Matrix} elements The elements.
-*/
-declare function $M(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
-
-/**
-* Constructor function.
-*
-* @param Array<number>|Vector anchor The anchor vector.
-* @param Array<number>|Vector direction The direction vector.
-*/
-declare function $L(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
-
-/**
-* Constructor function.
-*
-* @param {Array<number>|Vector} anchor The anchor vector.
-* @param {Array<number>|Vector} normal The normal vector.
-*/
-declare function $P(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
-
-/**
- * Constructor function.
- *
- * @param {Array<number>|Vector} anchor The anchor vector.
- * @param {Array<number>|Vector} v1 The first direction vector.
- * @param {Array<number>|Vecotr} v2 The second direction vector.
- */
-declare function $P(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/MIT-LICENSE.txt	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-Copyright (c) 2011 Tapmodo Interactive LLC,
-  http://github.com/tapmodo/Jcrop
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/README.md	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-Jcrop Image Cropping Plugin
-===========================
-
-Jcrop is the quick and easy way to add image cropping functionality to
-your web application. It combines the ease-of-use of a typical jQuery
-plugin with a powerful cross-platform DHTML cropping engine that is
-faithful to familiar desktop graphics applications.
-
-Cross-platform Compatibility
-----------------------------
-
-* Firefox 2+
-* Safari 3+
-* Opera 9.5+
-* Google Chrome 0.2+
-* Internet Explorer 6+
-
-Feature Overview
-----------------
-
-* Attaches unobtrusively to any image
-* Supports aspect ratio locking
-* Supports minSize/maxSize setting
-* Callbacks for selection done, or while moving
-* Keyboard support for nudging selection
-* API features to create interactivity, including animation
-* Support for CSS styling
-* Experimental touch-screen support (iOS, Android, etc)
-
-Contributors
-============
-
-**Special thanks to the following contributors:**
-
-* [Bruno Agutoli](mailto:brunotla1@gmail.com)
-* dhorrigan
-* Phil-B
-* jaymecd
-* all others who have committed their time and effort to help improve Jcrop
-
-MIT License
-===========
-
-**Jcrop is free software under MIT License.**
-
-#### Copyright (c) 2008-2012 Tapmodo Interactive LLC,<br />http://github.com/tapmodo/Jcrop
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
Binary file light9/web/lib/tapmodo-Jcrop-1902fbc/css/Jcrop.gif has changed
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.css	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,165 +0,0 @@
-/* jquery.Jcrop.css v0.9.12 - MIT License */
-/*
-  The outer-most container in a typical Jcrop instance
-  If you are having difficulty with formatting related to styles
-  on a parent element, place any fixes here or in a like selector
-
-  You can also style this element if you want to add a border, etc
-  A better method for styling can be seen below with .jcrop-light
-  (Add a class to the holder and style elements for that extended class)
-*/
-.jcrop-holder {
-  direction: ltr;
-  text-align: left;
-}
-/* Selection Border */
-.jcrop-vline,
-.jcrop-hline {
-  background: #ffffff url("Jcrop.gif");
-  font-size: 0;
-  position: absolute;
-}
-.jcrop-vline {
-  height: 100%;
-  width: 1px !important;
-}
-.jcrop-vline.right {
-  right: 0;
-}
-.jcrop-hline {
-  height: 1px !important;
-  width: 100%;
-}
-.jcrop-hline.bottom {
-  bottom: 0;
-}
-/* Invisible click targets */
-.jcrop-tracker {
-  height: 100%;
-  width: 100%;
-  /* "turn off" link highlight */
-  -webkit-tap-highlight-color: transparent;
-  /* disable callout, image save panel */
-  -webkit-touch-callout: none;
-  /* disable cut copy paste */
-  -webkit-user-select: none;
-}
-/* Selection Handles */
-.jcrop-handle {
-  background-color: #333333;
-  border: 1px #eeeeee solid;
-  width: 7px;
-  height: 7px;
-  font-size: 1px;
-}
-.jcrop-handle.ord-n {
-  left: 50%;
-  margin-left: -4px;
-  margin-top: -4px;
-  top: 0;
-}
-.jcrop-handle.ord-s {
-  bottom: 0;
-  left: 50%;
-  margin-bottom: -4px;
-  margin-left: -4px;
-}
-.jcrop-handle.ord-e {
-  margin-right: -4px;
-  margin-top: -4px;
-  right: 0;
-  top: 50%;
-}
-.jcrop-handle.ord-w {
-  left: 0;
-  margin-left: -4px;
-  margin-top: -4px;
-  top: 50%;
-}
-.jcrop-handle.ord-nw {
-  left: 0;
-  margin-left: -4px;
-  margin-top: -4px;
-  top: 0;
-}
-.jcrop-handle.ord-ne {
-  margin-right: -4px;
-  margin-top: -4px;
-  right: 0;
-  top: 0;
-}
-.jcrop-handle.ord-se {
-  bottom: 0;
-  margin-bottom: -4px;
-  margin-right: -4px;
-  right: 0;
-}
-.jcrop-handle.ord-sw {
-  bottom: 0;
-  left: 0;
-  margin-bottom: -4px;
-  margin-left: -4px;
-}
-/* Dragbars */
-.jcrop-dragbar.ord-n,
-.jcrop-dragbar.ord-s {
-  height: 7px;
-  width: 100%;
-}
-.jcrop-dragbar.ord-e,
-.jcrop-dragbar.ord-w {
-  height: 100%;
-  width: 7px;
-}
-.jcrop-dragbar.ord-n {
-  margin-top: -4px;
-}
-.jcrop-dragbar.ord-s {
-  bottom: 0;
-  margin-bottom: -4px;
-}
-.jcrop-dragbar.ord-e {
-  margin-right: -4px;
-  right: 0;
-}
-.jcrop-dragbar.ord-w {
-  margin-left: -4px;
-}
-/* The "jcrop-light" class/extension */
-.jcrop-light .jcrop-vline,
-.jcrop-light .jcrop-hline {
-  background: #ffffff;
-  filter: alpha(opacity=70) !important;
-  opacity: .70!important;
-}
-.jcrop-light .jcrop-handle {
-  -moz-border-radius: 3px;
-  -webkit-border-radius: 3px;
-  background-color: #000000;
-  border-color: #ffffff;
-  border-radius: 3px;
-}
-/* The "jcrop-dark" class/extension */
-.jcrop-dark .jcrop-vline,
-.jcrop-dark .jcrop-hline {
-  background: #000000;
-  filter: alpha(opacity=70) !important;
-  opacity: 0.7 !important;
-}
-.jcrop-dark .jcrop-handle {
-  -moz-border-radius: 3px;
-  -webkit-border-radius: 3px;
-  background-color: #ffffff;
-  border-color: #000000;
-  border-radius: 3px;
-}
-/* Simple macro to turn off the antlines */
-.solid-line .jcrop-vline,
-.solid-line .jcrop-hline {
-  background: #ffffff;
-}
-/* Fix for twitter bootstrap et al. */
-.jcrop-holder img,
-img.jcrop-preview {
-  max-width: none;
-}
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,29 +0,0 @@
-/* jquery.Jcrop.min.css v0.9.12 (build:20130126) */
-.jcrop-holder{direction:ltr;text-align:left;}
-.jcrop-vline,.jcrop-hline{background:#FFF url(Jcrop.gif);font-size:0;position:absolute;}
-.jcrop-vline{height:100%;width:1px!important;}
-.jcrop-vline.right{right:0;}
-.jcrop-hline{height:1px!important;width:100%;}
-.jcrop-hline.bottom{bottom:0;}
-.jcrop-tracker{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;height:100%;width:100%;}
-.jcrop-handle{background-color:#333;border:1px #EEE solid;font-size:1px;height:7px;width:7px;}
-.jcrop-handle.ord-n{left:50%;margin-left:-4px;margin-top:-4px;top:0;}
-.jcrop-handle.ord-s{bottom:0;left:50%;margin-bottom:-4px;margin-left:-4px;}
-.jcrop-handle.ord-e{margin-right:-4px;margin-top:-4px;right:0;top:50%;}
-.jcrop-handle.ord-w{left:0;margin-left:-4px;margin-top:-4px;top:50%;}
-.jcrop-handle.ord-nw{left:0;margin-left:-4px;margin-top:-4px;top:0;}
-.jcrop-handle.ord-ne{margin-right:-4px;margin-top:-4px;right:0;top:0;}
-.jcrop-handle.ord-se{bottom:0;margin-bottom:-4px;margin-right:-4px;right:0;}
-.jcrop-handle.ord-sw{bottom:0;left:0;margin-bottom:-4px;margin-left:-4px;}
-.jcrop-dragbar.ord-n,.jcrop-dragbar.ord-s{height:7px;width:100%;}
-.jcrop-dragbar.ord-e,.jcrop-dragbar.ord-w{height:100%;width:7px;}
-.jcrop-dragbar.ord-n{margin-top:-4px;}
-.jcrop-dragbar.ord-s{bottom:0;margin-bottom:-4px;}
-.jcrop-dragbar.ord-e{margin-right:-4px;right:0;}
-.jcrop-dragbar.ord-w{margin-left:-4px;}
-.jcrop-light .jcrop-vline,.jcrop-light .jcrop-hline{background:#FFF;filter:alpha(opacity=70)!important;opacity:.70!important;}
-.jcrop-light .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#000;border-color:#FFF;border-radius:3px;}
-.jcrop-dark .jcrop-vline,.jcrop-dark .jcrop-hline{background:#000;filter:alpha(opacity=70)!important;opacity:.7!important;}
-.jcrop-dark .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#FFF;border-color:#000;border-radius:3px;}
-.solid-line .jcrop-vline,.solid-line .jcrop-hline{background:#FFF;}
-.jcrop-holder img,img.jcrop-preview{max-width:none;}
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,84 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-  <title>Jcrop: the jQuery Image Cropping Plugin</title>
-  <link rel="stylesheet" href="demos/demo_files/main.css" type="text/css" />
-  <link rel="stylesheet" href="demos/demo_files/demos.css" type="text/css" />
-  <script src="js/jquery.min.js"></script>
-  <meta http-equiv="Content-type" content="text/html;charset=UTF-8" />
-</head>
-<body>
-
-<div class="container">
-<div class="row">
-<div class="span12">
-<div class="jc-demo-box">
-
-<div class="page-header">
-<ul class="breadcrumb first">
-  <li><a href="http://tapmodo.com/">Tapmodo</a> <span class="divider">/</span></li>
-  <li class="active">Jcrop Plugin</li>
-</ul>
-<h1>Jcrop Image Cropping Plugin</h1>
-</div>
-
-
-<big>
-	<a href="http://deepliquid.com/content/Jcrop.html"><b>Jcrop</b></a>
-	is the image cropping plugin for
-	<a href="http://jquery.com/">jQuery</a>.<br />
-	You've successfully unpacked Jcrop.
-</big>
-
-<h3>Static Demos</h3>
-
-<ul>
-	<li><a href="demos/tutorial1.html">Hello World</a>
-		&mdash; default behavior</li>
-	<li><a href="demos/tutorial2.html">Basic Handler</a>
-		&mdash; basic form integration</li>
-	<li><a href="demos/tutorial3.html">Aspect Ratio w/ Preview Pane</a>
-		&mdash; nice visual example</li>
-	<li><a href="demos/tutorial4.html">Animation/Transitions</a>
-		&mdash; animation/fading demo</li>
-	<li><a href="demos/tutorial5.html">API Interface</a>
-		&mdash; real-time API example</li>
-	<li><a href="demos/styling.html">Styling Example</a>
-		&mdash; style Jcrop dynamically with CSS
-    <small>New in 0.9.10</small>
-    </li>
-  <li><a href="demos/non-image.html">Non-Image Elements</a>
-    &mdash; attach to other DOM block elements
-    <small>New in 0.9.10</small>
-    </li>
-</ul>
-
-<h3>Live Demo</h3>
-
-<ul>
-	<li><a href="demos/crop.php">PHP Cropping Demo</a>
-		&mdash; requires PHP/gd support</li>
-</ul>
-
-<h3>Jcrop Links</h3>
-
-<ul>
-	<li><a href="http://deepliquid.com/content/Jcrop.html">Jcrop Home</a></li>
-	<li><a href="http://deepliquid.com/content/Jcrop_Manual.html">Jcrop Manual</a></li>
-</ul>
-
-<div class="tapmodo-footer">
-  <a href="http://tapmodo.com" class="tapmodo-logo segment">tapmodo.com</a>
-  <div class="segment"><b>&copy; 2008-2013 Tapmodo Interactive LLC</b><br />
-    Jcrop is free software released under <a href="MIT-LICENSE.txt">MIT License</a>
-  </div>
-</div>
-<div class="clearfix"></div>
-
-</div>
-</div>
-</div>
-</div>
-
-</body>
-</html>
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1694 +0,0 @@
-/**
- * jquery.Jcrop.js v0.9.12
- * jQuery Image Cropping Plugin - released under MIT License 
- * Author: Kelly Hallman <khallman@gmail.com>
- * http://github.com/tapmodo/Jcrop
- * Copyright (c) 2008-2013 Tapmodo Interactive LLC {{{
- *
- * Permission is hereby granted, free of charge, to any person
- * obtaining a copy of this software and associated documentation
- * files (the "Software"), to deal in the Software without
- * restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following
- * conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
- * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
- * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
- * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- *
- * }}}
- */
-
-(function ($) {
-
-  $.Jcrop = function (obj, opt) {
-    var options = $.extend({}, $.Jcrop.defaults),
-        docOffset,
-        _ua = navigator.userAgent.toLowerCase(),
-        is_msie = /msie/.test(_ua),
-        ie6mode = /msie [1-6]\./.test(_ua);
-
-    // Internal Methods {{{
-    function px(n) {
-      return Math.round(n) + 'px';
-    }
-    function cssClass(cl) {
-      return options.baseClass + '-' + cl;
-    }
-    function supportsColorFade() {
-      return $.fx.step.hasOwnProperty('backgroundColor');
-    }
-    function getPos(obj) //{{{
-    {
-      var pos = $(obj).offset();
-      return [pos.left, pos.top];
-    }
-    //}}}
-    function mouseAbs(e) //{{{
-    {
-      return [(e.pageX - docOffset[0]), (e.pageY - docOffset[1])];
-    }
-    //}}}
-    function setOptions(opt) //{{{
-    {
-      if (typeof(opt) !== 'object') opt = {};
-      options = $.extend(options, opt);
-
-      $.each(['onChange','onSelect','onRelease','onDblClick'],function(i,e) {
-        if (typeof(options[e]) !== 'function') options[e] = function () {};
-      });
-    }
-    //}}}
-    function startDragMode(mode, pos, touch) //{{{
-    {
-      docOffset = getPos($img);
-      Tracker.setCursor(mode === 'move' ? mode : mode + '-resize');
-
-      if (mode === 'move') {
-        return Tracker.activateHandlers(createMover(pos), doneSelect, touch);
-      }
-
-      var fc = Coords.getFixed();
-      var opp = oppLockCorner(mode);
-      var opc = Coords.getCorner(oppLockCorner(opp));
-
-      Coords.setPressed(Coords.getCorner(opp));
-      Coords.setCurrent(opc);
-
-      Tracker.activateHandlers(dragmodeHandler(mode, fc), doneSelect, touch);
-    }
-    //}}}
-    function dragmodeHandler(mode, f) //{{{
-    {
-      return function (pos) {
-        if (!options.aspectRatio) {
-          switch (mode) {
-          case 'e':
-            pos[1] = f.y2;
-            break;
-          case 'w':
-            pos[1] = f.y2;
-            break;
-          case 'n':
-            pos[0] = f.x2;
-            break;
-          case 's':
-            pos[0] = f.x2;
-            break;
-          }
-        } else {
-          switch (mode) {
-          case 'e':
-            pos[1] = f.y + 1;
-            break;
-          case 'w':
-            pos[1] = f.y + 1;
-            break;
-          case 'n':
-            pos[0] = f.x + 1;
-            break;
-          case 's':
-            pos[0] = f.x + 1;
-            break;
-          }
-        }
-        Coords.setCurrent(pos);
-        Selection.update();
-      };
-    }
-    //}}}
-    function createMover(pos) //{{{
-    {
-      var lloc = pos;
-      KeyManager.watchKeys();
-
-      return function (pos) {
-        Coords.moveOffset([pos[0] - lloc[0], pos[1] - lloc[1]]);
-        lloc = pos;
-
-        Selection.update();
-      };
-    }
-    //}}}
-    function oppLockCorner(ord) //{{{
-    {
-      switch (ord) {
-      case 'n':
-        return 'sw';
-      case 's':
-        return 'nw';
-      case 'e':
-        return 'nw';
-      case 'w':
-        return 'ne';
-      case 'ne':
-        return 'sw';
-      case 'nw':
-        return 'se';
-      case 'se':
-        return 'nw';
-      case 'sw':
-        return 'ne';
-      }
-    }
-    //}}}
-    function createDragger(ord) //{{{
-    {
-      return function (e) {
-        if (options.disabled) {
-          return false;
-        }
-        if ((ord === 'move') && !options.allowMove) {
-          return false;
-        }
-        
-        // Fix position of crop area when dragged the very first time.
-        // Necessary when crop image is in a hidden element when page is loaded.
-        docOffset = getPos($img);
-
-        btndown = true;
-        startDragMode(ord, mouseAbs(e));
-        e.stopPropagation();
-        e.preventDefault();
-        return false;
-      };
-    }
-    //}}}
-    function presize($obj, w, h) //{{{
-    {
-      var nw = $obj.width(),
-          nh = $obj.height();
-      if ((nw > w) && w > 0) {
-        nw = w;
-        nh = (w / $obj.width()) * $obj.height();
-      }
-      if ((nh > h) && h > 0) {
-        nh = h;
-        nw = (h / $obj.height()) * $obj.width();
-      }
-      xscale = $obj.width() / nw;
-      yscale = $obj.height() / nh;
-      $obj.width(nw).height(nh);
-    }
-    //}}}
-    function unscale(c) //{{{
-    {
-      return {
-        x: c.x * xscale,
-        y: c.y * yscale,
-        x2: c.x2 * xscale,
-        y2: c.y2 * yscale,
-        w: c.w * xscale,
-        h: c.h * yscale
-      };
-    }
-    //}}}
-    function doneSelect(pos) //{{{
-    {
-      var c = Coords.getFixed();
-      if ((c.w > options.minSelect[0]) && (c.h > options.minSelect[1])) {
-        Selection.enableHandles();
-        Selection.done();
-      } else {
-        Selection.release();
-      }
-      Tracker.setCursor(options.allowSelect ? 'crosshair' : 'default');
-    }
-    //}}}
-    function newSelection(e) //{{{
-    {
-      if (options.disabled) {
-        return false;
-      }
-      if (!options.allowSelect) {
-        return false;
-      }
-      btndown = true;
-      docOffset = getPos($img);
-      Selection.disableHandles();
-      Tracker.setCursor('crosshair');
-      var pos = mouseAbs(e);
-      Coords.setPressed(pos);
-      Selection.update();
-      Tracker.activateHandlers(selectDrag, doneSelect, e.type.substring(0,5)==='touch');
-      KeyManager.watchKeys();
-
-      e.stopPropagation();
-      e.preventDefault();
-      return false;
-    }
-    //}}}
-    function selectDrag(pos) //{{{
-    {
-      Coords.setCurrent(pos);
-      Selection.update();
-    }
-    //}}}
-    function newTracker() //{{{
-    {
-      var trk = $('<div></div>').addClass(cssClass('tracker'));
-      if (is_msie) {
-        trk.css({
-          opacity: 0,
-          backgroundColor: 'white'
-        });
-      }
-      return trk;
-    }
-    //}}}
-
-    // }}}
-    // Initialization {{{
-    // Sanitize some options {{{
-    if (typeof(obj) !== 'object') {
-      obj = $(obj)[0];
-    }
-    if (typeof(opt) !== 'object') {
-      opt = {};
-    }
-    // }}}
-    setOptions(opt);
-    // Initialize some jQuery objects {{{
-    // The values are SET on the image(s) for the interface
-    // If the original image has any of these set, they will be reset
-    // However, if you destroy() the Jcrop instance the original image's
-    // character in the DOM will be as you left it.
-    var img_css = {
-      border: 'none',
-      visibility: 'visible',
-      margin: 0,
-      padding: 0,
-      position: 'absolute',
-      top: 0,
-      left: 0
-    };
-
-    var $origimg = $(obj),
-      img_mode = true;
-
-    if (obj.tagName == 'IMG') {
-      // Fix size of crop image.
-      // Necessary when crop image is within a hidden element when page is loaded.
-      if ($origimg[0].width != 0 && $origimg[0].height != 0) {
-        // Obtain dimensions from contained img element.
-        $origimg.width($origimg[0].width);
-        $origimg.height($origimg[0].height);
-      } else {
-        // Obtain dimensions from temporary image in case the original is not loaded yet (e.g. IE 7.0). 
-        var tempImage = new Image();
-        tempImage.src = $origimg[0].src;
-        $origimg.width(tempImage.width);
-        $origimg.height(tempImage.height);
-      } 
-
-      var $img = $origimg.clone().removeAttr('id').css(img_css).show();
-
-      $img.width($origimg.width());
-      $img.height($origimg.height());
-      $origimg.after($img).hide();
-
-    } else {
-      $img = $origimg.css(img_css).show();
-      img_mode = false;
-      if (options.shade === null) { options.shade = true; }
-    }
-
-    presize($img, options.boxWidth, options.boxHeight);
-
-    var boundx = $img.width(),
-        boundy = $img.height(),
-        
-        
-        $div = $('<div />').width(boundx).height(boundy).addClass(cssClass('holder')).css({
-        position: 'relative',
-        backgroundColor: options.bgColor
-      }).insertAfter($origimg).append($img);
-
-    if (options.addClass) {
-      $div.addClass(options.addClass);
-    }
-
-    var $img2 = $('<div />'),
-
-        $img_holder = $('<div />') 
-        .width('100%').height('100%').css({
-          zIndex: 310,
-          position: 'absolute',
-          overflow: 'hidden'
-        }),
-
-        $hdl_holder = $('<div />') 
-        .width('100%').height('100%').css('zIndex', 320), 
-
-        $sel = $('<div />') 
-        .css({
-          position: 'absolute',
-          zIndex: 600
-        }).dblclick(function(){
-          var c = Coords.getFixed();
-          options.onDblClick.call(api,c);
-        }).insertBefore($img).append($img_holder, $hdl_holder); 
-
-    if (img_mode) {
-
-      $img2 = $('<img />')
-          .attr('src', $img.attr('src')).css(img_css).width(boundx).height(boundy),
-
-      $img_holder.append($img2);
-
-    }
-
-    if (ie6mode) {
-      $sel.css({
-        overflowY: 'hidden'
-      });
-    }
-
-    var bound = options.boundary;
-    var $trk = newTracker().width(boundx + (bound * 2)).height(boundy + (bound * 2)).css({
-      position: 'absolute',
-      top: px(-bound),
-      left: px(-bound),
-      zIndex: 290
-    }).mousedown(newSelection);
-
-    /* }}} */
-    // Set more variables {{{
-    var bgcolor = options.bgColor,
-        bgopacity = options.bgOpacity,
-        xlimit, ylimit, xmin, ymin, xscale, yscale, enabled = true,
-        btndown, animating, shift_down;
-
-    docOffset = getPos($img);
-    // }}}
-    // }}}
-    // Internal Modules {{{
-    // Touch Module {{{ 
-    var Touch = (function () {
-      // Touch support detection function adapted (under MIT License)
-      // from code by Jeffrey Sambells - http://github.com/iamamused/
-      function hasTouchSupport() {
-        var support = {}, events = ['touchstart', 'touchmove', 'touchend'],
-            el = document.createElement('div'), i;
-
-        try {
-          for(i=0; i<events.length; i++) {
-            var eventName = events[i];
-            eventName = 'on' + eventName;
-            var isSupported = (eventName in el);
-            if (!isSupported) {
-              el.setAttribute(eventName, 'return;');
-              isSupported = typeof el[eventName] == 'function';
-            }
-            support[events[i]] = isSupported;
-          }
-          return support.touchstart && support.touchend && support.touchmove;
-        }
-        catch(err) {
-          return false;
-        }
-      }
-
-      function detectSupport() {
-        if ((options.touchSupport === true) || (options.touchSupport === false)) return options.touchSupport;
-          else return hasTouchSupport();
-      }
-      return {
-        createDragger: function (ord) {
-          return function (e) {
-            if (options.disabled) {
-              return false;
-            }
-            if ((ord === 'move') && !options.allowMove) {
-              return false;
-            }
-            docOffset = getPos($img);
-            btndown = true;
-            startDragMode(ord, mouseAbs(Touch.cfilter(e)), true);
-            e.stopPropagation();
-            e.preventDefault();
-            return false;
-          };
-        },
-        newSelection: function (e) {
-          return newSelection(Touch.cfilter(e));
-        },
-        cfilter: function (e){
-          e.pageX = e.originalEvent.changedTouches[0].pageX;
-          e.pageY = e.originalEvent.changedTouches[0].pageY;
-          return e;
-        },
-        isSupported: hasTouchSupport,
-        support: detectSupport()
-      };
-    }());
-    // }}}
-    // Coords Module {{{
-    var Coords = (function () {
-      var x1 = 0,
-          y1 = 0,
-          x2 = 0,
-          y2 = 0,
-          ox, oy;
-
-      function setPressed(pos) //{{{
-      {
-        pos = rebound(pos);
-        x2 = x1 = pos[0];
-        y2 = y1 = pos[1];
-      }
-      //}}}
-      function setCurrent(pos) //{{{
-      {
-        pos = rebound(pos);
-        ox = pos[0] - x2;
-        oy = pos[1] - y2;
-        x2 = pos[0];
-        y2 = pos[1];
-      }
-      //}}}
-      function getOffset() //{{{
-      {
-        return [ox, oy];
-      }
-      //}}}
-      function moveOffset(offset) //{{{
-      {
-        var ox = offset[0],
-            oy = offset[1];
-
-        if (0 > x1 + ox) {
-          ox -= ox + x1;
-        }
-        if (0 > y1 + oy) {
-          oy -= oy + y1;
-        }
-
-        if (boundy < y2 + oy) {
-          oy += boundy - (y2 + oy);
-        }
-        if (boundx < x2 + ox) {
-          ox += boundx - (x2 + ox);
-        }
-
-        x1 += ox;
-        x2 += ox;
-        y1 += oy;
-        y2 += oy;
-      }
-      //}}}
-      function getCorner(ord) //{{{
-      {
-        var c = getFixed();
-        switch (ord) {
-        case 'ne':
-          return [c.x2, c.y];
-        case 'nw':
-          return [c.x, c.y];
-        case 'se':
-          return [c.x2, c.y2];
-        case 'sw':
-          return [c.x, c.y2];
-        }
-      }
-      //}}}
-      function getFixed() //{{{
-      {
-        if (!options.aspectRatio) {
-          return getRect();
-        }
-        // This function could use some optimization I think...
-        var aspect = options.aspectRatio,
-            min_x = options.minSize[0] / xscale,
-            
-            
-            //min_y = options.minSize[1]/yscale,
-            max_x = options.maxSize[0] / xscale,
-            max_y = options.maxSize[1] / yscale,
-            rw = x2 - x1,
-            rh = y2 - y1,
-            rwa = Math.abs(rw),
-            rha = Math.abs(rh),
-            real_ratio = rwa / rha,
-            xx, yy, w, h;
-
-        if (max_x === 0) {
-          max_x = boundx * 10;
-        }
-        if (max_y === 0) {
-          max_y = boundy * 10;
-        }
-        if (real_ratio < aspect) {
-          yy = y2;
-          w = rha * aspect;
-          xx = rw < 0 ? x1 - w : w + x1;
-
-          if (xx < 0) {
-            xx = 0;
-            h = Math.abs((xx - x1) / aspect);
-            yy = rh < 0 ? y1 - h : h + y1;
-          } else if (xx > boundx) {
-            xx = boundx;
-            h = Math.abs((xx - x1) / aspect);
-            yy = rh < 0 ? y1 - h : h + y1;
-          }
-        } else {
-          xx = x2;
-          h = rwa / aspect;
-          yy = rh < 0 ? y1 - h : y1 + h;
-          if (yy < 0) {
-            yy = 0;
-            w = Math.abs((yy - y1) * aspect);
-            xx = rw < 0 ? x1 - w : w + x1;
-          } else if (yy > boundy) {
-            yy = boundy;
-            w = Math.abs(yy - y1) * aspect;
-            xx = rw < 0 ? x1 - w : w + x1;
-          }
-        }
-
-        // Magic %-)
-        if (xx > x1) { // right side
-          if (xx - x1 < min_x) {
-            xx = x1 + min_x;
-          } else if (xx - x1 > max_x) {
-            xx = x1 + max_x;
-          }
-          if (yy > y1) {
-            yy = y1 + (xx - x1) / aspect;
-          } else {
-            yy = y1 - (xx - x1) / aspect;
-          }
-        } else if (xx < x1) { // left side
-          if (x1 - xx < min_x) {
-            xx = x1 - min_x;
-          } else if (x1 - xx > max_x) {
-            xx = x1 - max_x;
-          }
-          if (yy > y1) {
-            yy = y1 + (x1 - xx) / aspect;
-          } else {
-            yy = y1 - (x1 - xx) / aspect;
-          }
-        }
-
-        if (xx < 0) {
-          x1 -= xx;
-          xx = 0;
-        } else if (xx > boundx) {
-          x1 -= xx - boundx;
-          xx = boundx;
-        }
-
-        if (yy < 0) {
-          y1 -= yy;
-          yy = 0;
-        } else if (yy > boundy) {
-          y1 -= yy - boundy;
-          yy = boundy;
-        }
-
-        return makeObj(flipCoords(x1, y1, xx, yy));
-      }
-      //}}}
-      function rebound(p) //{{{
-      {
-        if (p[0] < 0) p[0] = 0;
-        if (p[1] < 0) p[1] = 0;
-
-        if (p[0] > boundx) p[0] = boundx;
-        if (p[1] > boundy) p[1] = boundy;
-
-        return [Math.round(p[0]), Math.round(p[1])];
-      }
-      //}}}
-      function flipCoords(x1, y1, x2, y2) //{{{
-      {
-        var xa = x1,
-            xb = x2,
-            ya = y1,
-            yb = y2;
-        if (x2 < x1) {
-          xa = x2;
-          xb = x1;
-        }
-        if (y2 < y1) {
-          ya = y2;
-          yb = y1;
-        }
-        return [xa, ya, xb, yb];
-      }
-      //}}}
-      function getRect() //{{{
-      {
-        var xsize = x2 - x1,
-            ysize = y2 - y1,
-            delta;
-
-        if (xlimit && (Math.abs(xsize) > xlimit)) {
-          x2 = (xsize > 0) ? (x1 + xlimit) : (x1 - xlimit);
-        }
-        if (ylimit && (Math.abs(ysize) > ylimit)) {
-          y2 = (ysize > 0) ? (y1 + ylimit) : (y1 - ylimit);
-        }
-
-        if (ymin / yscale && (Math.abs(ysize) < ymin / yscale)) {
-          y2 = (ysize > 0) ? (y1 + ymin / yscale) : (y1 - ymin / yscale);
-        }
-        if (xmin / xscale && (Math.abs(xsize) < xmin / xscale)) {
-          x2 = (xsize > 0) ? (x1 + xmin / xscale) : (x1 - xmin / xscale);
-        }
-
-        if (x1 < 0) {
-          x2 -= x1;
-          x1 -= x1;
-        }
-        if (y1 < 0) {
-          y2 -= y1;
-          y1 -= y1;
-        }
-        if (x2 < 0) {
-          x1 -= x2;
-          x2 -= x2;
-        }
-        if (y2 < 0) {
-          y1 -= y2;
-          y2 -= y2;
-        }
-        if (x2 > boundx) {
-          delta = x2 - boundx;
-          x1 -= delta;
-          x2 -= delta;
-        }
-        if (y2 > boundy) {
-          delta = y2 - boundy;
-          y1 -= delta;
-          y2 -= delta;
-        }
-        if (x1 > boundx) {
-          delta = x1 - boundy;
-          y2 -= delta;
-          y1 -= delta;
-        }
-        if (y1 > boundy) {
-          delta = y1 - boundy;
-          y2 -= delta;
-          y1 -= delta;
-        }
-
-        return makeObj(flipCoords(x1, y1, x2, y2));
-      }
-      //}}}
-      function makeObj(a) //{{{
-      {
-        return {
-          x: a[0],
-          y: a[1],
-          x2: a[2],
-          y2: a[3],
-          w: a[2] - a[0],
-          h: a[3] - a[1]
-        };
-      }
-      //}}}
-
-      return {
-        flipCoords: flipCoords,
-        setPressed: setPressed,
-        setCurrent: setCurrent,
-        getOffset: getOffset,
-        moveOffset: moveOffset,
-        getCorner: getCorner,
-        getFixed: getFixed
-      };
-    }());
-
-    //}}}
-    // Shade Module {{{
-    var Shade = (function() {
-      var enabled = false,
-          holder = $('<div />').css({
-            position: 'absolute',
-            zIndex: 240,
-            opacity: 0
-          }),
-          shades = {
-            top: createShade(),
-            left: createShade().height(boundy),
-            right: createShade().height(boundy),
-            bottom: createShade()
-          };
-
-      function resizeShades(w,h) {
-        shades.left.css({ height: px(h) });
-        shades.right.css({ height: px(h) });
-      }
-      function updateAuto()
-      {
-        return updateShade(Coords.getFixed());
-      }
-      function updateShade(c)
-      {
-        shades.top.css({
-          left: px(c.x),
-          width: px(c.w),
-          height: px(c.y)
-        });
-        shades.bottom.css({
-          top: px(c.y2),
-          left: px(c.x),
-          width: px(c.w),
-          height: px(boundy-c.y2)
-        });
-        shades.right.css({
-          left: px(c.x2),
-          width: px(boundx-c.x2)
-        });
-        shades.left.css({
-          width: px(c.x)
-        });
-      }
-      function createShade() {
-        return $('<div />').css({
-          position: 'absolute',
-          backgroundColor: options.shadeColor||options.bgColor
-        }).appendTo(holder);
-      }
-      function enableShade() {
-        if (!enabled) {
-          enabled = true;
-          holder.insertBefore($img);
-          updateAuto();
-          Selection.setBgOpacity(1,0,1);
-          $img2.hide();
-
-          setBgColor(options.shadeColor||options.bgColor,1);
-          if (Selection.isAwake())
-          {
-            setOpacity(options.bgOpacity,1);
-          }
-            else setOpacity(1,1);
-        }
-      }
-      function setBgColor(color,now) {
-        colorChangeMacro(getShades(),color,now);
-      }
-      function disableShade() {
-        if (enabled) {
-          holder.remove();
-          $img2.show();
-          enabled = false;
-          if (Selection.isAwake()) {
-            Selection.setBgOpacity(options.bgOpacity,1,1);
-          } else {
-            Selection.setBgOpacity(1,1,1);
-            Selection.disableHandles();
-          }
-          colorChangeMacro($div,0,1);
-        }
-      }
-      function setOpacity(opacity,now) {
-        if (enabled) {
-          if (options.bgFade && !now) {
-            holder.animate({
-              opacity: 1-opacity
-            },{
-              queue: false,
-              duration: options.fadeTime
-            });
-          }
-          else holder.css({opacity:1-opacity});
-        }
-      }
-      function refreshAll() {
-        options.shade ? enableShade() : disableShade();
-        if (Selection.isAwake()) setOpacity(options.bgOpacity);
-      }
-      function getShades() {
-        return holder.children();
-      }
-
-      return {
-        update: updateAuto,
-        updateRaw: updateShade,
-        getShades: getShades,
-        setBgColor: setBgColor,
-        enable: enableShade,
-        disable: disableShade,
-        resize: resizeShades,
-        refresh: refreshAll,
-        opacity: setOpacity
-      };
-    }());
-    // }}}
-    // Selection Module {{{
-    var Selection = (function () {
-      var awake,
-          hdep = 370,
-          borders = {},
-          handle = {},
-          dragbar = {},
-          seehandles = false;
-
-      // Private Methods
-      function insertBorder(type) //{{{
-      {
-        var jq = $('<div />').css({
-          position: 'absolute',
-          opacity: options.borderOpacity
-        }).addClass(cssClass(type));
-        $img_holder.append(jq);
-        return jq;
-      }
-      //}}}
-      function dragDiv(ord, zi) //{{{
-      {
-        var jq = $('<div />').mousedown(createDragger(ord)).css({
-          cursor: ord + '-resize',
-          position: 'absolute',
-          zIndex: zi
-        }).addClass('ord-'+ord);
-
-        if (Touch.support) {
-          jq.bind('touchstart.jcrop', Touch.createDragger(ord));
-        }
-
-        $hdl_holder.append(jq);
-        return jq;
-      }
-      //}}}
-      function insertHandle(ord) //{{{
-      {
-        var hs = options.handleSize,
-
-          div = dragDiv(ord, hdep++).css({
-            opacity: options.handleOpacity
-          }).addClass(cssClass('handle'));
-
-        if (hs) { div.width(hs).height(hs); }
-
-        return div;
-      }
-      //}}}
-      function insertDragbar(ord) //{{{
-      {
-        return dragDiv(ord, hdep++).addClass('jcrop-dragbar');
-      }
-      //}}}
-      function createDragbars(li) //{{{
-      {
-        var i;
-        for (i = 0; i < li.length; i++) {
-          dragbar[li[i]] = insertDragbar(li[i]);
-        }
-      }
-      //}}}
-      function createBorders(li) //{{{
-      {
-        var cl,i;
-        for (i = 0; i < li.length; i++) {
-          switch(li[i]){
-            case'n': cl='hline'; break;
-            case's': cl='hline bottom'; break;
-            case'e': cl='vline right'; break;
-            case'w': cl='vline'; break;
-          }
-          borders[li[i]] = insertBorder(cl);
-        }
-      }
-      //}}}
-      function createHandles(li) //{{{
-      {
-        var i;
-        for (i = 0; i < li.length; i++) {
-          handle[li[i]] = insertHandle(li[i]);
-        }
-      }
-      //}}}
-      function moveto(x, y) //{{{
-      {
-        if (!options.shade) {
-          $img2.css({
-            top: px(-y),
-            left: px(-x)
-          });
-        }
-        $sel.css({
-          top: px(y),
-          left: px(x)
-        });
-      }
-      //}}}
-      function resize(w, h) //{{{
-      {
-        $sel.width(Math.round(w)).height(Math.round(h));
-      }
-      //}}}
-      function refresh() //{{{
-      {
-        var c = Coords.getFixed();
-
-        Coords.setPressed([c.x, c.y]);
-        Coords.setCurrent([c.x2, c.y2]);
-
-        updateVisible();
-      }
-      //}}}
-
-      // Internal Methods
-      function updateVisible(select) //{{{
-      {
-        if (awake) {
-          return update(select);
-        }
-      }
-      //}}}
-      function update(select) //{{{
-      {
-        var c = Coords.getFixed();
-
-        resize(c.w, c.h);
-        moveto(c.x, c.y);
-        if (options.shade) Shade.updateRaw(c);
-
-        awake || show();
-
-        if (select) {
-          options.onSelect.call(api, unscale(c));
-        } else {
-          options.onChange.call(api, unscale(c));
-        }
-      }
-      //}}}
-      function setBgOpacity(opacity,force,now) //{{{
-      {
-        if (!awake && !force) return;
-        if (options.bgFade && !now) {
-          $img.animate({
-            opacity: opacity
-          },{
-            queue: false,
-            duration: options.fadeTime
-          });
-        } else {
-          $img.css('opacity', opacity);
-        }
-      }
-      //}}}
-      function show() //{{{
-      {
-        $sel.show();
-
-        if (options.shade) Shade.opacity(bgopacity);
-          else setBgOpacity(bgopacity,true);
-
-        awake = true;
-      }
-      //}}}
-      function release() //{{{
-      {
-        disableHandles();
-        $sel.hide();
-
-        if (options.shade) Shade.opacity(1);
-          else setBgOpacity(1);
-
-        awake = false;
-        options.onRelease.call(api);
-      }
-      //}}}
-      function showHandles() //{{{
-      {
-        if (seehandles) {
-          $hdl_holder.show();
-        }
-      }
-      //}}}
-      function enableHandles() //{{{
-      {
-        seehandles = true;
-        if (options.allowResize) {
-          $hdl_holder.show();
-          return true;
-        }
-      }
-      //}}}
-      function disableHandles() //{{{
-      {
-        seehandles = false;
-        $hdl_holder.hide();
-      } 
-      //}}}
-      function animMode(v) //{{{
-      {
-        if (v) {
-          animating = true;
-          disableHandles();
-        } else {
-          animating = false;
-          enableHandles();
-        }
-      } 
-      //}}}
-      function done() //{{{
-      {
-        animMode(false);
-        refresh();
-      } 
-      //}}}
-      // Insert draggable elements {{{
-      // Insert border divs for outline
-
-      if (options.dragEdges && $.isArray(options.createDragbars))
-        createDragbars(options.createDragbars);
-
-      if ($.isArray(options.createHandles))
-        createHandles(options.createHandles);
-
-      if (options.drawBorders && $.isArray(options.createBorders))
-        createBorders(options.createBorders);
-
-      //}}}
-
-      // This is a hack for iOS5 to support drag/move touch functionality
-      $(document).bind('touchstart.jcrop-ios',function(e) {
-        if ($(e.currentTarget).hasClass('jcrop-tracker')) e.stopPropagation();
-      });
-
-      var $track = newTracker().mousedown(createDragger('move')).css({
-        cursor: 'move',
-        position: 'absolute',
-        zIndex: 360
-      });
-
-      if (Touch.support) {
-        $track.bind('touchstart.jcrop', Touch.createDragger('move'));
-      }
-
-      $img_holder.append($track);
-      disableHandles();
-
-      return {
-        updateVisible: updateVisible,
-        update: update,
-        release: release,
-        refresh: refresh,
-        isAwake: function () {
-          return awake;
-        },
-        setCursor: function (cursor) {
-          $track.css('cursor', cursor);
-        },
-        enableHandles: enableHandles,
-        enableOnly: function () {
-          seehandles = true;
-        },
-        showHandles: showHandles,
-        disableHandles: disableHandles,
-        animMode: animMode,
-        setBgOpacity: setBgOpacity,
-        done: done
-      };
-    }());
-    
-    //}}}
-    // Tracker Module {{{
-    var Tracker = (function () {
-      var onMove = function () {},
-          onDone = function () {},
-          trackDoc = options.trackDocument;
-
-      function toFront(touch) //{{{
-      {
-        $trk.css({
-          zIndex: 450
-        });
-
-        if (touch)
-          $(document)
-            .bind('touchmove.jcrop', trackTouchMove)
-            .bind('touchend.jcrop', trackTouchEnd);
-
-        else if (trackDoc)
-          $(document)
-            .bind('mousemove.jcrop',trackMove)
-            .bind('mouseup.jcrop',trackUp);
-      } 
-      //}}}
-      function toBack() //{{{
-      {
-        $trk.css({
-          zIndex: 290
-        });
-        $(document).unbind('.jcrop');
-      } 
-      //}}}
-      function trackMove(e) //{{{
-      {
-        onMove(mouseAbs(e));
-        return false;
-      } 
-      //}}}
-      function trackUp(e) //{{{
-      {
-        e.preventDefault();
-        e.stopPropagation();
-
-        if (btndown) {
-          btndown = false;
-
-          onDone(mouseAbs(e));
-
-          if (Selection.isAwake()) {
-            options.onSelect.call(api, unscale(Coords.getFixed()));
-          }
-
-          toBack();
-          onMove = function () {};
-          onDone = function () {};
-        }
-
-        return false;
-      }
-      //}}}
-      function activateHandlers(move, done, touch) //{{{
-      {
-        btndown = true;
-        onMove = move;
-        onDone = done;
-        toFront(touch);
-        return false;
-      }
-      //}}}
-      function trackTouchMove(e) //{{{
-      {
-        onMove(mouseAbs(Touch.cfilter(e)));
-        return false;
-      }
-      //}}}
-      function trackTouchEnd(e) //{{{
-      {
-        return trackUp(Touch.cfilter(e));
-      }
-      //}}}
-      function setCursor(t) //{{{
-      {
-        $trk.css('cursor', t);
-      }
-      //}}}
-
-      if (!trackDoc) {
-        $trk.mousemove(trackMove).mouseup(trackUp).mouseout(trackUp);
-      }
-
-      $img.before($trk);
-      return {
-        activateHandlers: activateHandlers,
-        setCursor: setCursor
-      };
-    }());
-    //}}}
-    // KeyManager Module {{{
-    var KeyManager = (function () {
-      var $keymgr = $('<input type="radio" />').css({
-        position: 'fixed',
-        left: '-120px',
-        width: '12px'
-      }).addClass('jcrop-keymgr'),
-
-        $keywrap = $('<div />').css({
-          position: 'absolute',
-          overflow: 'hidden'
-        }).append($keymgr);
-
-      function watchKeys() //{{{
-      {
-        if (options.keySupport) {
-          $keymgr.show();
-          $keymgr.focus();
-        }
-      }
-      //}}}
-      function onBlur(e) //{{{
-      {
-        $keymgr.hide();
-      }
-      //}}}
-      function doNudge(e, x, y) //{{{
-      {
-        if (options.allowMove) {
-          Coords.moveOffset([x, y]);
-          Selection.updateVisible(true);
-        }
-        e.preventDefault();
-        e.stopPropagation();
-      }
-      //}}}
-      function parseKey(e) //{{{
-      {
-        if (e.ctrlKey || e.metaKey) {
-          return true;
-        }
-        shift_down = e.shiftKey ? true : false;
-        var nudge = shift_down ? 10 : 1;
-
-        switch (e.keyCode) {
-        case 37:
-          doNudge(e, -nudge, 0);
-          break;
-        case 39:
-          doNudge(e, nudge, 0);
-          break;
-        case 38:
-          doNudge(e, 0, -nudge);
-          break;
-        case 40:
-          doNudge(e, 0, nudge);
-          break;
-        case 27:
-          if (options.allowSelect) Selection.release();
-          break;
-        case 9:
-          return true;
-        }
-
-        return false;
-      }
-      //}}}
-
-      if (options.keySupport) {
-        $keymgr.keydown(parseKey).blur(onBlur);
-        if (ie6mode || !options.fixedSupport) {
-          $keymgr.css({
-            position: 'absolute',
-            left: '-20px'
-          });
-          $keywrap.append($keymgr).insertBefore($img);
-        } else {
-          $keymgr.insertBefore($img);
-        }
-      }
-
-
-      return {
-        watchKeys: watchKeys
-      };
-    }());
-    //}}}
-    // }}}
-    // API methods {{{
-    function setClass(cname) //{{{
-    {
-      $div.removeClass().addClass(cssClass('holder')).addClass(cname);
-    }
-    //}}}
-    function animateTo(a, callback) //{{{
-    {
-      var x1 = a[0] / xscale,
-          y1 = a[1] / yscale,
-          x2 = a[2] / xscale,
-          y2 = a[3] / yscale;
-
-      if (animating) {
-        return;
-      }
-
-      var animto = Coords.flipCoords(x1, y1, x2, y2),
-          c = Coords.getFixed(),
-          initcr = [c.x, c.y, c.x2, c.y2],
-          animat = initcr,
-          interv = options.animationDelay,
-          ix1 = animto[0] - initcr[0],
-          iy1 = animto[1] - initcr[1],
-          ix2 = animto[2] - initcr[2],
-          iy2 = animto[3] - initcr[3],
-          pcent = 0,
-          velocity = options.swingSpeed;
-
-      x1 = animat[0];
-      y1 = animat[1];
-      x2 = animat[2];
-      y2 = animat[3];
-
-      Selection.animMode(true);
-      var anim_timer;
-
-      function queueAnimator() {
-        window.setTimeout(animator, interv);
-      }
-      var animator = (function () {
-        return function () {
-          pcent += (100 - pcent) / velocity;
-
-          animat[0] = Math.round(x1 + ((pcent / 100) * ix1));
-          animat[1] = Math.round(y1 + ((pcent / 100) * iy1));
-          animat[2] = Math.round(x2 + ((pcent / 100) * ix2));
-          animat[3] = Math.round(y2 + ((pcent / 100) * iy2));
-
-          if (pcent >= 99.8) {
-            pcent = 100;
-          }
-          if (pcent < 100) {
-            setSelectRaw(animat);
-            queueAnimator();
-          } else {
-            Selection.done();
-            Selection.animMode(false);
-            if (typeof(callback) === 'function') {
-              callback.call(api);
-            }
-          }
-        };
-      }());
-      queueAnimator();
-    }
-    //}}}
-    function setSelect(rect) //{{{
-    {
-      setSelectRaw([rect[0] / xscale, rect[1] / yscale, rect[2] / xscale, rect[3] / yscale]);
-      options.onSelect.call(api, unscale(Coords.getFixed()));
-      Selection.enableHandles();
-    }
-    //}}}
-    function setSelectRaw(l) //{{{
-    {
-      Coords.setPressed([l[0], l[1]]);
-      Coords.setCurrent([l[2], l[3]]);
-      Selection.update();
-    }
-    //}}}
-    function tellSelect() //{{{
-    {
-      return unscale(Coords.getFixed());
-    }
-    //}}}
-    function tellScaled() //{{{
-    {
-      return Coords.getFixed();
-    }
-    //}}}
-    function setOptionsNew(opt) //{{{
-    {
-      setOptions(opt);
-      interfaceUpdate();
-    }
-    //}}}
-    function disableCrop() //{{{
-    {
-      options.disabled = true;
-      Selection.disableHandles();
-      Selection.setCursor('default');
-      Tracker.setCursor('default');
-    }
-    //}}}
-    function enableCrop() //{{{
-    {
-      options.disabled = false;
-      interfaceUpdate();
-    }
-    //}}}
-    function cancelCrop() //{{{
-    {
-      Selection.done();
-      Tracker.activateHandlers(null, null);
-    }
-    //}}}
-    function destroy() //{{{
-    {
-      $div.remove();
-      $origimg.show();
-      $origimg.css('visibility','visible');
-      $(obj).removeData('Jcrop');
-    }
-    //}}}
-    function setImage(src, callback) //{{{
-    {
-      Selection.release();
-      disableCrop();
-      var img = new Image();
-      img.onload = function () {
-        var iw = img.width;
-        var ih = img.height;
-        var bw = options.boxWidth;
-        var bh = options.boxHeight;
-        $img.width(iw).height(ih);
-        $img.attr('src', src);
-        $img2.attr('src', src);
-        presize($img, bw, bh);
-        boundx = $img.width();
-        boundy = $img.height();
-        $img2.width(boundx).height(boundy);
-        $trk.width(boundx + (bound * 2)).height(boundy + (bound * 2));
-        $div.width(boundx).height(boundy);
-        Shade.resize(boundx,boundy);
-        enableCrop();
-
-        if (typeof(callback) === 'function') {
-          callback.call(api);
-        }
-      };
-      img.src = src;
-    }
-    //}}}
-    function colorChangeMacro($obj,color,now) {
-      var mycolor = color || options.bgColor;
-      if (options.bgFade && supportsColorFade() && options.fadeTime && !now) {
-        $obj.animate({
-          backgroundColor: mycolor
-        }, {
-          queue: false,
-          duration: options.fadeTime
-        });
-      } else {
-        $obj.css('backgroundColor', mycolor);
-      }
-    }
-    function interfaceUpdate(alt) //{{{
-    // This method tweaks the interface based on options object.
-    // Called when options are changed and at end of initialization.
-    {
-      if (options.allowResize) {
-        if (alt) {
-          Selection.enableOnly();
-        } else {
-          Selection.enableHandles();
-        }
-      } else {
-        Selection.disableHandles();
-      }
-
-      Tracker.setCursor(options.allowSelect ? 'crosshair' : 'default');
-      Selection.setCursor(options.allowMove ? 'move' : 'default');
-
-      if (options.hasOwnProperty('trueSize')) {
-        xscale = options.trueSize[0] / boundx;
-        yscale = options.trueSize[1] / boundy;
-      }
-
-      if (options.hasOwnProperty('setSelect')) {
-        setSelect(options.setSelect);
-        Selection.done();
-        delete(options.setSelect);
-      }
-
-      Shade.refresh();
-
-      if (options.bgColor != bgcolor) {
-        colorChangeMacro(
-          options.shade? Shade.getShades(): $div,
-          options.shade?
-            (options.shadeColor || options.bgColor):
-            options.bgColor
-        );
-        bgcolor = options.bgColor;
-      }
-
-      if (bgopacity != options.bgOpacity) {
-        bgopacity = options.bgOpacity;
-        if (options.shade) Shade.refresh();
-          else Selection.setBgOpacity(bgopacity);
-      }
-
-      xlimit = options.maxSize[0] || 0;
-      ylimit = options.maxSize[1] || 0;
-      xmin = options.minSize[0] || 0;
-      ymin = options.minSize[1] || 0;
-
-      if (options.hasOwnProperty('outerImage')) {
-        $img.attr('src', options.outerImage);
-        delete(options.outerImage);
-      }
-
-      Selection.refresh();
-    }
-    //}}}
-    //}}}
-
-    if (Touch.support) $trk.bind('touchstart.jcrop', Touch.newSelection);
-
-    $hdl_holder.hide();
-    interfaceUpdate(true);
-
-    var api = {
-      setImage: setImage,
-      animateTo: animateTo,
-      setSelect: setSelect,
-      setOptions: setOptionsNew,
-      tellSelect: tellSelect,
-      tellScaled: tellScaled,
-      setClass: setClass,
-
-      disable: disableCrop,
-      enable: enableCrop,
-      cancel: cancelCrop,
-      release: Selection.release,
-      destroy: destroy,
-
-      focus: KeyManager.watchKeys,
-
-      getBounds: function () {
-        return [boundx * xscale, boundy * yscale];
-      },
-      getWidgetSize: function () {
-        return [boundx, boundy];
-      },
-      getScaleFactor: function () {
-        return [xscale, yscale];
-      },
-      getOptions: function() {
-        // careful: internal values are returned
-        return options;
-      },
-
-      ui: {
-        holder: $div,
-        selection: $sel
-      }
-    };
-
-    if (is_msie) $div.bind('selectstart', function () { return false; });
-
-    $origimg.data('Jcrop', api);
-    return api;
-  };
-  $.fn.Jcrop = function (options, callback) //{{{
-  {
-    var api;
-    // Iterate over each object, attach Jcrop
-    this.each(function () {
-      // If we've already attached to this object
-      if ($(this).data('Jcrop')) {
-        // The API can be requested this way (undocumented)
-        if (options === 'api') return $(this).data('Jcrop');
-        // Otherwise, we just reset the options...
-        else $(this).data('Jcrop').setOptions(options);
-      }
-      // If we haven't been attached, preload and attach
-      else {
-        if (this.tagName == 'IMG')
-          $.Jcrop.Loader(this,function(){
-            $(this).css({display:'block',visibility:'hidden'});
-            api = $.Jcrop(this, options);
-            if ($.isFunction(callback)) callback.call(api);
-          });
-        else {
-          $(this).css({display:'block',visibility:'hidden'});
-          api = $.Jcrop(this, options);
-          if ($.isFunction(callback)) callback.call(api);
-        }
-      }
-    });
-
-    // Return "this" so the object is chainable (jQuery-style)
-    return this;
-  };
-  //}}}
-  // $.Jcrop.Loader - basic image loader {{{
-
-  $.Jcrop.Loader = function(imgobj,success,error){
-    var $img = $(imgobj), img = $img[0];
-
-    function completeCheck(){
-      if (img.complete) {
-        $img.unbind('.jcloader');
-        if ($.isFunction(success)) success.call(img);
-      }
-      else window.setTimeout(completeCheck,50);
-    }
-
-    $img
-      .bind('load.jcloader',completeCheck)
-      .bind('error.jcloader',function(e){
-        $img.unbind('.jcloader');
-        if ($.isFunction(error)) error.call(img);
-      });
-
-    if (img.complete && $.isFunction(success)){
-      $img.unbind('.jcloader');
-      success.call(img);
-    }
-  };
-
-  //}}}
-  // Global Defaults {{{
-  $.Jcrop.defaults = {
-
-    // Basic Settings
-    allowSelect: true,
-    allowMove: true,
-    allowResize: true,
-
-    trackDocument: true,
-
-    // Styling Options
-    baseClass: 'jcrop',
-    addClass: null,
-    bgColor: 'black',
-    bgOpacity: 0.6,
-    bgFade: false,
-    borderOpacity: 0.4,
-    handleOpacity: 0.5,
-    handleSize: null,
-
-    aspectRatio: 0,
-    keySupport: true,
-    createHandles: ['n','s','e','w','nw','ne','se','sw'],
-    createDragbars: ['n','s','e','w'],
-    createBorders: ['n','s','e','w'],
-    drawBorders: true,
-    dragEdges: true,
-    fixedSupport: true,
-    touchSupport: null,
-
-    shade: null,
-
-    boxWidth: 0,
-    boxHeight: 0,
-    boundary: 2,
-    fadeTime: 400,
-    animationDelay: 20,
-    swingSpeed: 3,
-
-    minSelect: [0, 0],
-    maxSize: [0, 0],
-    minSize: [0, 0],
-
-    // Callbacks / Event Handlers
-    onChange: function () {},
-    onSelect: function () {},
-    onDblClick: function () {},
-    onRelease: function () {}
-  };
-
-  // }}}
-}(jQuery));
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.min.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-/**
- * jquery.Jcrop.min.js v0.9.12 (build:20130202)
- * jQuery Image Cropping Plugin - released under MIT License
- * Copyright (c) 2008-2013 Tapmodo Interactive LLC
- * https://github.com/tapmodo/Jcrop
- */
-(function(a){a.Jcrop=function(b,c){function i(a){return Math.round(a)+"px"}function j(a){return d.baseClass+"-"+a}function k(){return a.fx.step.hasOwnProperty("backgroundColor")}function l(b){var c=a(b).offset();return[c.left,c.top]}function m(a){return[a.pageX-e[0],a.pageY-e[1]]}function n(b){typeof b!="object"&&(b={}),d=a.extend(d,b),a.each(["onChange","onSelect","onRelease","onDblClick"],function(a,b){typeof d[b]!="function"&&(d[b]=function(){})})}function o(a,b,c){e=l(D),bc.setCursor(a==="move"?a:a+"-resize");if(a==="move")return bc.activateHandlers(q(b),v,c);var d=_.getFixed(),f=r(a),g=_.getCorner(r(f));_.setPressed(_.getCorner(f)),_.setCurrent(g),bc.activateHandlers(p(a,d),v,c)}function p(a,b){return function(c){if(!d.aspectRatio)switch(a){case"e":c[1]=b.y2;break;case"w":c[1]=b.y2;break;case"n":c[0]=b.x2;break;case"s":c[0]=b.x2}else switch(a){case"e":c[1]=b.y+1;break;case"w":c[1]=b.y+1;break;case"n":c[0]=b.x+1;break;case"s":c[0]=b.x+1}_.setCurrent(c),bb.update()}}function q(a){var b=a;return bd.watchKeys
-(),function(a){_.moveOffset([a[0]-b[0],a[1]-b[1]]),b=a,bb.update()}}function r(a){switch(a){case"n":return"sw";case"s":return"nw";case"e":return"nw";case"w":return"ne";case"ne":return"sw";case"nw":return"se";case"se":return"nw";case"sw":return"ne"}}function s(a){return function(b){return d.disabled?!1:a==="move"&&!d.allowMove?!1:(e=l(D),W=!0,o(a,m(b)),b.stopPropagation(),b.preventDefault(),!1)}}function t(a,b,c){var d=a.width(),e=a.height();d>b&&b>0&&(d=b,e=b/a.width()*a.height()),e>c&&c>0&&(e=c,d=c/a.height()*a.width()),T=a.width()/d,U=a.height()/e,a.width(d).height(e)}function u(a){return{x:a.x*T,y:a.y*U,x2:a.x2*T,y2:a.y2*U,w:a.w*T,h:a.h*U}}function v(a){var b=_.getFixed();b.w>d.minSelect[0]&&b.h>d.minSelect[1]?(bb.enableHandles(),bb.done()):bb.release(),bc.setCursor(d.allowSelect?"crosshair":"default")}function w(a){if(d.disabled)return!1;if(!d.allowSelect)return!1;W=!0,e=l(D),bb.disableHandles(),bc.setCursor("crosshair");var b=m(a);return _.setPressed(b),bb.update(),bc.activateHandlers(x,v,a.type.substring
-(0,5)==="touch"),bd.watchKeys(),a.stopPropagation(),a.preventDefault(),!1}function x(a){_.setCurrent(a),bb.update()}function y(){var b=a("<div></div>").addClass(j("tracker"));return g&&b.css({opacity:0,backgroundColor:"white"}),b}function be(a){G.removeClass().addClass(j("holder")).addClass(a)}function bf(a,b){function t(){window.setTimeout(u,l)}var c=a[0]/T,e=a[1]/U,f=a[2]/T,g=a[3]/U;if(X)return;var h=_.flipCoords(c,e,f,g),i=_.getFixed(),j=[i.x,i.y,i.x2,i.y2],k=j,l=d.animationDelay,m=h[0]-j[0],n=h[1]-j[1],o=h[2]-j[2],p=h[3]-j[3],q=0,r=d.swingSpeed;c=k[0],e=k[1],f=k[2],g=k[3],bb.animMode(!0);var s,u=function(){return function(){q+=(100-q)/r,k[0]=Math.round(c+q/100*m),k[1]=Math.round(e+q/100*n),k[2]=Math.round(f+q/100*o),k[3]=Math.round(g+q/100*p),q>=99.8&&(q=100),q<100?(bh(k),t()):(bb.done(),bb.animMode(!1),typeof b=="function"&&b.call(bs))}}();t()}function bg(a){bh([a[0]/T,a[1]/U,a[2]/T,a[3]/U]),d.onSelect.call(bs,u(_.getFixed())),bb.enableHandles()}function bh(a){_.setPressed([a[0],a[1]]),_.setCurrent([a[2],
-a[3]]),bb.update()}function bi(){return u(_.getFixed())}function bj(){return _.getFixed()}function bk(a){n(a),br()}function bl(){d.disabled=!0,bb.disableHandles(),bb.setCursor("default"),bc.setCursor("default")}function bm(){d.disabled=!1,br()}function bn(){bb.done(),bc.activateHandlers(null,null)}function bo(){G.remove(),A.show(),A.css("visibility","visible"),a(b).removeData("Jcrop")}function bp(a,b){bb.release(),bl();var c=new Image;c.onload=function(){var e=c.width,f=c.height,g=d.boxWidth,h=d.boxHeight;D.width(e).height(f),D.attr("src",a),H.attr("src",a),t(D,g,h),E=D.width(),F=D.height(),H.width(E).height(F),M.width(E+L*2).height(F+L*2),G.width(E).height(F),ba.resize(E,F),bm(),typeof b=="function"&&b.call(bs)},c.src=a}function bq(a,b,c){var e=b||d.bgColor;d.bgFade&&k()&&d.fadeTime&&!c?a.animate({backgroundColor:e},{queue:!1,duration:d.fadeTime}):a.css("backgroundColor",e)}function br(a){d.allowResize?a?bb.enableOnly():bb.enableHandles():bb.disableHandles(),bc.setCursor(d.allowSelect?"crosshair":"default"),bb
-.setCursor(d.allowMove?"move":"default"),d.hasOwnProperty("trueSize")&&(T=d.trueSize[0]/E,U=d.trueSize[1]/F),d.hasOwnProperty("setSelect")&&(bg(d.setSelect),bb.done(),delete d.setSelect),ba.refresh(),d.bgColor!=N&&(bq(d.shade?ba.getShades():G,d.shade?d.shadeColor||d.bgColor:d.bgColor),N=d.bgColor),O!=d.bgOpacity&&(O=d.bgOpacity,d.shade?ba.refresh():bb.setBgOpacity(O)),P=d.maxSize[0]||0,Q=d.maxSize[1]||0,R=d.minSize[0]||0,S=d.minSize[1]||0,d.hasOwnProperty("outerImage")&&(D.attr("src",d.outerImage),delete d.outerImage),bb.refresh()}var d=a.extend({},a.Jcrop.defaults),e,f=navigator.userAgent.toLowerCase(),g=/msie/.test(f),h=/msie [1-6]\./.test(f);typeof b!="object"&&(b=a(b)[0]),typeof c!="object"&&(c={}),n(c);var z={border:"none",visibility:"visible",margin:0,padding:0,position:"absolute",top:0,left:0},A=a(b),B=!0;if(b.tagName=="IMG"){if(A[0].width!=0&&A[0].height!=0)A.width(A[0].width),A.height(A[0].height);else{var C=new Image;C.src=A[0].src,A.width(C.width),A.height(C.height)}var D=A.clone().removeAttr("id").
-css(z).show();D.width(A.width()),D.height(A.height()),A.after(D).hide()}else D=A.css(z).show(),B=!1,d.shade===null&&(d.shade=!0);t(D,d.boxWidth,d.boxHeight);var E=D.width(),F=D.height(),G=a("<div />").width(E).height(F).addClass(j("holder")).css({position:"relative",backgroundColor:d.bgColor}).insertAfter(A).append(D);d.addClass&&G.addClass(d.addClass);var H=a("<div />"),I=a("<div />").width("100%").height("100%").css({zIndex:310,position:"absolute",overflow:"hidden"}),J=a("<div />").width("100%").height("100%").css("zIndex",320),K=a("<div />").css({position:"absolute",zIndex:600}).dblclick(function(){var a=_.getFixed();d.onDblClick.call(bs,a)}).insertBefore(D).append(I,J);B&&(H=a("<img />").attr("src",D.attr("src")).css(z).width(E).height(F),I.append(H)),h&&K.css({overflowY:"hidden"});var L=d.boundary,M=y().width(E+L*2).height(F+L*2).css({position:"absolute",top:i(-L),left:i(-L),zIndex:290}).mousedown(w),N=d.bgColor,O=d.bgOpacity,P,Q,R,S,T,U,V=!0,W,X,Y;e=l(D);var Z=function(){function a(){var a={},b=["touchstart"
-,"touchmove","touchend"],c=document.createElement("div"),d;try{for(d=0;d<b.length;d++){var e=b[d];e="on"+e;var f=e in c;f||(c.setAttribute(e,"return;"),f=typeof c[e]=="function"),a[b[d]]=f}return a.touchstart&&a.touchend&&a.touchmove}catch(g){return!1}}function b(){return d.touchSupport===!0||d.touchSupport===!1?d.touchSupport:a()}return{createDragger:function(a){return function(b){return d.disabled?!1:a==="move"&&!d.allowMove?!1:(e=l(D),W=!0,o(a,m(Z.cfilter(b)),!0),b.stopPropagation(),b.preventDefault(),!1)}},newSelection:function(a){return w(Z.cfilter(a))},cfilter:function(a){return a.pageX=a.originalEvent.changedTouches[0].pageX,a.pageY=a.originalEvent.changedTouches[0].pageY,a},isSupported:a,support:b()}}(),_=function(){function h(d){d=n(d),c=a=d[0],e=b=d[1]}function i(a){a=n(a),f=a[0]-c,g=a[1]-e,c=a[0],e=a[1]}function j(){return[f,g]}function k(d){var f=d[0],g=d[1];0>a+f&&(f-=f+a),0>b+g&&(g-=g+b),F<e+g&&(g+=F-(e+g)),E<c+f&&(f+=E-(c+f)),a+=f,c+=f,b+=g,e+=g}function l(a){var b=m();switch(a){case"ne":return[
-b.x2,b.y];case"nw":return[b.x,b.y];case"se":return[b.x2,b.y2];case"sw":return[b.x,b.y2]}}function m(){if(!d.aspectRatio)return p();var f=d.aspectRatio,g=d.minSize[0]/T,h=d.maxSize[0]/T,i=d.maxSize[1]/U,j=c-a,k=e-b,l=Math.abs(j),m=Math.abs(k),n=l/m,r,s,t,u;return h===0&&(h=E*10),i===0&&(i=F*10),n<f?(s=e,t=m*f,r=j<0?a-t:t+a,r<0?(r=0,u=Math.abs((r-a)/f),s=k<0?b-u:u+b):r>E&&(r=E,u=Math.abs((r-a)/f),s=k<0?b-u:u+b)):(r=c,u=l/f,s=k<0?b-u:b+u,s<0?(s=0,t=Math.abs((s-b)*f),r=j<0?a-t:t+a):s>F&&(s=F,t=Math.abs(s-b)*f,r=j<0?a-t:t+a)),r>a?(r-a<g?r=a+g:r-a>h&&(r=a+h),s>b?s=b+(r-a)/f:s=b-(r-a)/f):r<a&&(a-r<g?r=a-g:a-r>h&&(r=a-h),s>b?s=b+(a-r)/f:s=b-(a-r)/f),r<0?(a-=r,r=0):r>E&&(a-=r-E,r=E),s<0?(b-=s,s=0):s>F&&(b-=s-F,s=F),q(o(a,b,r,s))}function n(a){return a[0]<0&&(a[0]=0),a[1]<0&&(a[1]=0),a[0]>E&&(a[0]=E),a[1]>F&&(a[1]=F),[Math.round(a[0]),Math.round(a[1])]}function o(a,b,c,d){var e=a,f=c,g=b,h=d;return c<a&&(e=c,f=a),d<b&&(g=d,h=b),[e,g,f,h]}function p(){var d=c-a,f=e-b,g;return P&&Math.abs(d)>P&&(c=d>0?a+P:a-P),Q&&Math.abs
-(f)>Q&&(e=f>0?b+Q:b-Q),S/U&&Math.abs(f)<S/U&&(e=f>0?b+S/U:b-S/U),R/T&&Math.abs(d)<R/T&&(c=d>0?a+R/T:a-R/T),a<0&&(c-=a,a-=a),b<0&&(e-=b,b-=b),c<0&&(a-=c,c-=c),e<0&&(b-=e,e-=e),c>E&&(g=c-E,a-=g,c-=g),e>F&&(g=e-F,b-=g,e-=g),a>E&&(g=a-F,e-=g,b-=g),b>F&&(g=b-F,e-=g,b-=g),q(o(a,b,c,e))}function q(a){return{x:a[0],y:a[1],x2:a[2],y2:a[3],w:a[2]-a[0],h:a[3]-a[1]}}var a=0,b=0,c=0,e=0,f,g;return{flipCoords:o,setPressed:h,setCurrent:i,getOffset:j,moveOffset:k,getCorner:l,getFixed:m}}(),ba=function(){function f(a,b){e.left.css({height:i(b)}),e.right.css({height:i(b)})}function g(){return h(_.getFixed())}function h(a){e.top.css({left:i(a.x),width:i(a.w),height:i(a.y)}),e.bottom.css({top:i(a.y2),left:i(a.x),width:i(a.w),height:i(F-a.y2)}),e.right.css({left:i(a.x2),width:i(E-a.x2)}),e.left.css({width:i(a.x)})}function j(){return a("<div />").css({position:"absolute",backgroundColor:d.shadeColor||d.bgColor}).appendTo(c)}function k(){b||(b=!0,c.insertBefore(D),g(),bb.setBgOpacity(1,0,1),H.hide(),l(d.shadeColor||d.bgColor,1),bb.
-isAwake()?n(d.bgOpacity,1):n(1,1))}function l(a,b){bq(p(),a,b)}function m(){b&&(c.remove(),H.show(),b=!1,bb.isAwake()?bb.setBgOpacity(d.bgOpacity,1,1):(bb.setBgOpacity(1,1,1),bb.disableHandles()),bq(G,0,1))}function n(a,e){b&&(d.bgFade&&!e?c.animate({opacity:1-a},{queue:!1,duration:d.fadeTime}):c.css({opacity:1-a}))}function o(){d.shade?k():m(),bb.isAwake()&&n(d.bgOpacity)}function p(){return c.children()}var b=!1,c=a("<div />").css({position:"absolute",zIndex:240,opacity:0}),e={top:j(),left:j().height(F),right:j().height(F),bottom:j()};return{update:g,updateRaw:h,getShades:p,setBgColor:l,enable:k,disable:m,resize:f,refresh:o,opacity:n}}(),bb=function(){function k(b){var c=a("<div />").css({position:"absolute",opacity:d.borderOpacity}).addClass(j(b));return I.append(c),c}function l(b,c){var d=a("<div />").mousedown(s(b)).css({cursor:b+"-resize",position:"absolute",zIndex:c}).addClass("ord-"+b);return Z.support&&d.bind("touchstart.jcrop",Z.createDragger(b)),J.append(d),d}function m(a){var b=d.handleSize,e=l(a,c++
-).css({opacity:d.handleOpacity}).addClass(j("handle"));return b&&e.width(b).height(b),e}function n(a){return l(a,c++).addClass("jcrop-dragbar")}function o(a){var b;for(b=0;b<a.length;b++)g[a[b]]=n(a[b])}function p(a){var b,c;for(c=0;c<a.length;c++){switch(a[c]){case"n":b="hline";break;case"s":b="hline bottom";break;case"e":b="vline right";break;case"w":b="vline"}e[a[c]]=k(b)}}function q(a){var b;for(b=0;b<a.length;b++)f[a[b]]=m(a[b])}function r(a,b){d.shade||H.css({top:i(-b),left:i(-a)}),K.css({top:i(b),left:i(a)})}function t(a,b){K.width(Math.round(a)).height(Math.round(b))}function v(){var a=_.getFixed();_.setPressed([a.x,a.y]),_.setCurrent([a.x2,a.y2]),w()}function w(a){if(b)return x(a)}function x(a){var c=_.getFixed();t(c.w,c.h),r(c.x,c.y),d.shade&&ba.updateRaw(c),b||A(),a?d.onSelect.call(bs,u(c)):d.onChange.call(bs,u(c))}function z(a,c,e){if(!b&&!c)return;d.bgFade&&!e?D.animate({opacity:a},{queue:!1,duration:d.fadeTime}):D.css("opacity",a)}function A(){K.show(),d.shade?ba.opacity(O):z(O,!0),b=!0}function B
-(){F(),K.hide(),d.shade?ba.opacity(1):z(1),b=!1,d.onRelease.call(bs)}function C(){h&&J.show()}function E(){h=!0;if(d.allowResize)return J.show(),!0}function F(){h=!1,J.hide()}function G(a){a?(X=!0,F()):(X=!1,E())}function L(){G(!1),v()}var b,c=370,e={},f={},g={},h=!1;d.dragEdges&&a.isArray(d.createDragbars)&&o(d.createDragbars),a.isArray(d.createHandles)&&q(d.createHandles),d.drawBorders&&a.isArray(d.createBorders)&&p(d.createBorders),a(document).bind("touchstart.jcrop-ios",function(b){a(b.currentTarget).hasClass("jcrop-tracker")&&b.stopPropagation()});var M=y().mousedown(s("move")).css({cursor:"move",position:"absolute",zIndex:360});return Z.support&&M.bind("touchstart.jcrop",Z.createDragger("move")),I.append(M),F(),{updateVisible:w,update:x,release:B,refresh:v,isAwake:function(){return b},setCursor:function(a){M.css("cursor",a)},enableHandles:E,enableOnly:function(){h=!0},showHandles:C,disableHandles:F,animMode:G,setBgOpacity:z,done:L}}(),bc=function(){function f(b){M.css({zIndex:450}),b?a(document).bind("touchmove.jcrop"
-,k).bind("touchend.jcrop",l):e&&a(document).bind("mousemove.jcrop",h).bind("mouseup.jcrop",i)}function g(){M.css({zIndex:290}),a(document).unbind(".jcrop")}function h(a){return b(m(a)),!1}function i(a){return a.preventDefault(),a.stopPropagation(),W&&(W=!1,c(m(a)),bb.isAwake()&&d.onSelect.call(bs,u(_.getFixed())),g(),b=function(){},c=function(){}),!1}function j(a,d,e){return W=!0,b=a,c=d,f(e),!1}function k(a){return b(m(Z.cfilter(a))),!1}function l(a){return i(Z.cfilter(a))}function n(a){M.css("cursor",a)}var b=function(){},c=function(){},e=d.trackDocument;return e||M.mousemove(h).mouseup(i).mouseout(i),D.before(M),{activateHandlers:j,setCursor:n}}(),bd=function(){function e(){d.keySupport&&(b.show(),b.focus())}function f(a){b.hide()}function g(a,b,c){d.allowMove&&(_.moveOffset([b,c]),bb.updateVisible(!0)),a.preventDefault(),a.stopPropagation()}function i(a){if(a.ctrlKey||a.metaKey)return!0;Y=a.shiftKey?!0:!1;var b=Y?10:1;switch(a.keyCode){case 37:g(a,-b,0);break;case 39:g(a,b,0);break;case 38:g(a,0,-b);break;
-case 40:g(a,0,b);break;case 27:d.allowSelect&&bb.release();break;case 9:return!0}return!1}var b=a('<input type="radio" />').css({position:"fixed",left:"-120px",width:"12px"}).addClass("jcrop-keymgr"),c=a("<div />").css({position:"absolute",overflow:"hidden"}).append(b);return d.keySupport&&(b.keydown(i).blur(f),h||!d.fixedSupport?(b.css({position:"absolute",left:"-20px"}),c.append(b).insertBefore(D)):b.insertBefore(D)),{watchKeys:e}}();Z.support&&M.bind("touchstart.jcrop",Z.newSelection),J.hide(),br(!0);var bs={setImage:bp,animateTo:bf,setSelect:bg,setOptions:bk,tellSelect:bi,tellScaled:bj,setClass:be,disable:bl,enable:bm,cancel:bn,release:bb.release,destroy:bo,focus:bd.watchKeys,getBounds:function(){return[E*T,F*U]},getWidgetSize:function(){return[E,F]},getScaleFactor:function(){return[T,U]},getOptions:function(){return d},ui:{holder:G,selection:K}};return g&&G.bind("selectstart",function(){return!1}),A.data("Jcrop",bs),bs},a.fn.Jcrop=function(b,c){var d;return this.each(function(){if(a(this).data("Jcrop")){if(
-b==="api")return a(this).data("Jcrop");a(this).data("Jcrop").setOptions(b)}else this.tagName=="IMG"?a.Jcrop.Loader(this,function(){a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d)}):(a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d))}),this},a.Jcrop.Loader=function(b,c,d){function g(){f.complete?(e.unbind(".jcloader"),a.isFunction(c)&&c.call(f)):window.setTimeout(g,50)}var e=a(b),f=e[0];e.bind("load.jcloader",g).bind("error.jcloader",function(b){e.unbind(".jcloader"),a.isFunction(d)&&d.call(f)}),f.complete&&a.isFunction(c)&&(e.unbind(".jcloader"),c.call(f))},a.Jcrop.defaults={allowSelect:!0,allowMove:!0,allowResize:!0,trackDocument:!0,baseClass:"jcrop",addClass:null,bgColor:"black",bgOpacity:.6,bgFade:!1,borderOpacity:.4,handleOpacity:.5,handleSize:null,aspectRatio:0,keySupport:!0,createHandles:["n","s","e","w","nw","ne","se","sw"],createDragbars:["n","s","e","w"],createBorders:["n","s","e","w"],drawBorders:!0,dragEdges
-:!0,fixedSupport:!0,touchSupport:null,shade:null,boxWidth:0,boxHeight:0,boundary:2,fadeTime:400,animationDelay:20,swingSpeed:3,minSelect:[0,0],maxSize:[0,0],minSize:[0,0],onChange:function(){},onSelect:function(){},onDblClick:function(){},onRelease:function(){}}})(jQuery);
\ No newline at end of file
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.color.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,661 +0,0 @@
-/*!
- * jQuery Color Animations v2.0pre
- * http://jquery.org/
- *
- * Copyright 2011 John Resig
- * Dual licensed under the MIT or GPL Version 2 licenses.
- * http://jquery.org/license
- */
-
-(function( jQuery, undefined ){
-	var stepHooks = "backgroundColor borderBottomColor borderLeftColor borderRightColor borderTopColor color outlineColor".split(" "),
-
-		// plusequals test for += 100 -= 100
-		rplusequals = /^([\-+])=\s*(\d+\.?\d*)/,
-		// a set of RE's that can match strings and generate color tuples.
-		stringParsers = [{
-				re: /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
-				parse: function( execResult ) {
-					return [
-						execResult[ 1 ],
-						execResult[ 2 ],
-						execResult[ 3 ],
-						execResult[ 4 ]
-					];
-				}
-			}, {
-				re: /rgba?\(\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
-				parse: function( execResult ) {
-					return [
-						2.55 * execResult[1],
-						2.55 * execResult[2],
-						2.55 * execResult[3],
-						execResult[ 4 ]
-					];
-				}
-			}, {
-				re: /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/,
-				parse: function( execResult ) {
-					return [
-						parseInt( execResult[ 1 ], 16 ),
-						parseInt( execResult[ 2 ], 16 ),
-						parseInt( execResult[ 3 ], 16 )
-					];
-				}
-			}, {
-				re: /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/,
-				parse: function( execResult ) {
-					return [
-						parseInt( execResult[ 1 ] + execResult[ 1 ], 16 ),
-						parseInt( execResult[ 2 ] + execResult[ 2 ], 16 ),
-						parseInt( execResult[ 3 ] + execResult[ 3 ], 16 )
-					];
-				}
-			}, {
-				re: /hsla?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
-				space: "hsla",
-				parse: function( execResult ) {
-					return [
-						execResult[1],
-						execResult[2] / 100,
-						execResult[3] / 100,
-						execResult[4]
-					];
-				}
-			}],
-
-		// jQuery.Color( )
-		color = jQuery.Color = function( color, green, blue, alpha ) {
-			return new jQuery.Color.fn.parse( color, green, blue, alpha );
-		},
-		spaces = {
-			rgba: {
-				cache: "_rgba",
-				props: {
-					red: {
-						idx: 0,
-						type: "byte",
-						empty: true
-					},
-					green: {
-						idx: 1,
-						type: "byte",
-						empty: true
-					},
-					blue: {
-						idx: 2,
-						type: "byte",
-						empty: true
-					},
-					alpha: {
-						idx: 3,
-						type: "percent",
-						def: 1
-					}
-				}
-			},
-			hsla: {
-				cache: "_hsla",
-				props: {
-					hue: {
-						idx: 0,
-						type: "degrees",
-						empty: true
-					},
-					saturation: {
-						idx: 1,
-						type: "percent",
-						empty: true
-					},
-					lightness: {
-						idx: 2,
-						type: "percent",
-						empty: true
-					}
-				}
-			}
-		},
-		propTypes = {
-			"byte": {
-				floor: true,
-				min: 0,
-				max: 255
-			},
-			"percent": {
-				min: 0,
-				max: 1
-			},
-			"degrees": {
-				mod: 360,
-				floor: true
-			}
-		},
-		rgbaspace = spaces.rgba.props,
-		support = color.support = {},
-
-		// colors = jQuery.Color.names
-		colors,
-
-		// local aliases of functions called often
-		each = jQuery.each;
-
-	spaces.hsla.props.alpha = rgbaspace.alpha;
-
-	function clamp( value, prop, alwaysAllowEmpty ) {
-		var type = propTypes[ prop.type ] || {},
-			allowEmpty = prop.empty || alwaysAllowEmpty;
-
-		if ( allowEmpty && value == null ) {
-			return null;
-		}
-		if ( prop.def && value == null ) {
-			return prop.def;
-		}
-		if ( type.floor ) {
-			value = ~~value;
-		} else {
-			value = parseFloat( value );
-		}
-		if ( value == null || isNaN( value ) ) {
-			return prop.def;
-		}
-		if ( type.mod ) {
-			value = value % type.mod;
-			// -10 -> 350
-			return value < 0 ? type.mod + value : value;
-		}
-
-		// for now all property types without mod have min and max
-		return type.min > value ? type.min : type.max < value ? type.max : value;
-	}
-
-	function stringParse( string ) {
-		var inst = color(),
-			rgba = inst._rgba = [];
-
-		string = string.toLowerCase();
-
-		each( stringParsers, function( i, parser ) {
-			var match = parser.re.exec( string ),
-				values = match && parser.parse( match ),
-				parsed,
-				spaceName = parser.space || "rgba",
-				cache = spaces[ spaceName ].cache;
-
-
-			if ( values ) {
-				parsed = inst[ spaceName ]( values );
-
-				// if this was an rgba parse the assignment might happen twice
-				// oh well....
-				inst[ cache ] = parsed[ cache ];
-				rgba = inst._rgba = parsed._rgba;
-
-				// exit each( stringParsers ) here because we matched
-				return false;
-			}
-		});
-
-		// Found a stringParser that handled it
-		if ( rgba.length !== 0 ) {
-
-			// if this came from a parsed string, force "transparent" when alpha is 0
-			// chrome, (and maybe others) return "transparent" as rgba(0,0,0,0)
-			if ( Math.max.apply( Math, rgba ) === 0 ) {
-				jQuery.extend( rgba, colors.transparent );
-			}
-			return inst;
-		}
-
-		// named colors / default - filter back through parse function
-		if ( string = colors[ string ] ) {
-			return string;
-		}
-	}
-
-	color.fn = color.prototype = {
-		constructor: color,
-		parse: function( red, green, blue, alpha ) {
-			if ( red === undefined ) {
-				this._rgba = [ null, null, null, null ];
-				return this;
-			}
-			if ( red instanceof jQuery || red.nodeType ) {
-				red = red instanceof jQuery ? red.css( green ) : jQuery( red ).css( green );
-				green = undefined;
-			}
-
-			var inst = this,
-				type = jQuery.type( red ),
-				rgba = this._rgba = [],
-				source;
-
-			// more than 1 argument specified - assume ( red, green, blue, alpha )
-			if ( green !== undefined ) {
-				red = [ red, green, blue, alpha ];
-				type = "array";
-			}
-
-			if ( type === "string" ) {
-				return this.parse( stringParse( red ) || colors._default );
-			}
-
-			if ( type === "array" ) {
-				each( rgbaspace, function( key, prop ) {
-					rgba[ prop.idx ] = clamp( red[ prop.idx ], prop );
-				});
-				return this;
-			}
-
-			if ( type === "object" ) {
-				if ( red instanceof color ) {
-					each( spaces, function( spaceName, space ) {
-						if ( red[ space.cache ] ) {
-							inst[ space.cache ] = red[ space.cache ].slice();
-						}
-					});
-				} else {
-					each( spaces, function( spaceName, space ) {
-						each( space.props, function( key, prop ) {
-							var cache = space.cache;
-
-							// if the cache doesn't exist, and we know how to convert
-							if ( !inst[ cache ] && space.to ) {
-
-								// if the value was null, we don't need to copy it
-								// if the key was alpha, we don't need to copy it either
-								if ( red[ key ] == null || key === "alpha") {
-									return;
-								}
-								inst[ cache ] = space.to( inst._rgba );
-							}
-
-							// this is the only case where we allow nulls for ALL properties.
-							// call clamp with alwaysAllowEmpty
-							inst[ cache ][ prop.idx ] = clamp( red[ key ], prop, true );
-						});
-					});
-				}
-				return this;
-			}
-		},
-		is: function( compare ) {
-			var is = color( compare ),
-				same = true,
-				myself = this;
-
-			each( spaces, function( _, space ) {
-				var isCache = is[ space.cache ],
-					localCache;
-				if (isCache) {
-					localCache = myself[ space.cache ] || space.to && space.to( myself._rgba ) || [];
-					each( space.props, function( _, prop ) {
-						if ( isCache[ prop.idx ] != null ) {
-							same = ( isCache[ prop.idx ] === localCache[ prop.idx ] );
-							return same;
-						}
-					});
-				}
-				return same;
-			});
-			return same;
-		},
-		_space: function() {
-			var used = [],
-				inst = this;
-			each( spaces, function( spaceName, space ) {
-				if ( inst[ space.cache ] ) {
-					used.push( spaceName );
-				}
-			});
-			return used.pop();
-		},
-		transition: function( other, distance ) {
-			var end = color( other ),
-				spaceName = end._space(),
-				space = spaces[ spaceName ],
-				start = this[ space.cache ] || space.to( this._rgba ),
-				result = start.slice();
-
-			end = end[ space.cache ];
-			each( space.props, function( key, prop ) {
-				var index = prop.idx,
-					startValue = start[ index ],
-					endValue = end[ index ],
-					type = propTypes[ prop.type ] || {};
-
-				// if null, don't override start value
-				if ( endValue === null ) {
-					return;
-				}
-				// if null - use end
-				if ( startValue === null ) {
-					result[ index ] = endValue;
-				} else {
-					if ( type.mod ) {
-						if ( endValue - startValue > type.mod / 2 ) {
-							startValue += type.mod;
-						} else if ( startValue - endValue > type.mod / 2 ) {
-							startValue -= type.mod;
-						}
-					}
-					result[ prop.idx ] = clamp( ( endValue - startValue ) * distance + startValue, prop );
-				}
-			});
-			return this[ spaceName ]( result );
-		},
-		blend: function( opaque ) {
-			// if we are already opaque - return ourself
-			if ( this._rgba[ 3 ] === 1 ) {
-				return this;
-			}
-
-			var rgb = this._rgba.slice(),
-				a = rgb.pop(),
-				blend = color( opaque )._rgba;
-
-			return color( jQuery.map( rgb, function( v, i ) {
-				return ( 1 - a ) * blend[ i ] + a * v;
-			}));
-		},
-		toRgbaString: function() {
-			var prefix = "rgba(",
-				rgba = jQuery.map( this._rgba, function( v, i ) {
-					return v == null ? ( i > 2 ? 1 : 0 ) : v;
-				});
-
-			if ( rgba[ 3 ] === 1 ) {
-				rgba.pop();
-				prefix = "rgb(";
-			}
-
-			return prefix + rgba.join(",") + ")";
-		},
-		toHslaString: function() {
-			var prefix = "hsla(",
-				hsla = jQuery.map( this.hsla(), function( v, i ) {
-					if ( v == null ) {
-						v = i > 2 ? 1 : 0;
-					}
-
-					// catch 1 and 2
-					if ( i && i < 3 ) {
-						v = Math.round( v * 100 ) + "%";
-					}
-					return v;
-				});
-
-			if ( hsla[ 3 ] === 1 ) {
-				hsla.pop();
-				prefix = "hsl(";
-			}
-			return prefix + hsla.join(",") + ")";
-		},
-		toHexString: function( includeAlpha ) {
-			var rgba = this._rgba.slice(),
-				alpha = rgba.pop();
-
-			if ( includeAlpha ) {
-				rgba.push( ~~( alpha * 255 ) );
-			}
-
-			return "#" + jQuery.map( rgba, function( v, i ) {
-
-				// default to 0 when nulls exist
-				v = ( v || 0 ).toString( 16 );
-				return v.length === 1 ? "0" + v : v;
-			}).join("");
-		},
-		toString: function() {
-			return this._rgba[ 3 ] === 0 ? "transparent" : this.toRgbaString();
-		}
-	};
-	color.fn.parse.prototype = color.fn;
-
-	// hsla conversions adapted from:
-	// http://www.google.com/codesearch/p#OAMlx_jo-ck/src/third_party/WebKit/Source/WebCore/inspector/front-end/Color.js&d=7&l=193
-
-	function hue2rgb( p, q, h ) {
-		h = ( h + 1 ) % 1;
-		if ( h * 6 < 1 ) {
-			return p + (q - p) * 6 * h;
-		}
-		if ( h * 2 < 1) {
-			return q;
-		}
-		if ( h * 3 < 2 ) {
-			return p + (q - p) * ((2/3) - h) * 6;
-		}
-		return p;
-	}
-
-	spaces.hsla.to = function ( rgba ) {
-		if ( rgba[ 0 ] == null || rgba[ 1 ] == null || rgba[ 2 ] == null ) {
-			return [ null, null, null, rgba[ 3 ] ];
-		}
-		var r = rgba[ 0 ] / 255,
-			g = rgba[ 1 ] / 255,
-			b = rgba[ 2 ] / 255,
-			a = rgba[ 3 ],
-			max = Math.max( r, g, b ),
-			min = Math.min( r, g, b ),
-			diff = max - min,
-			add = max + min,
-			l = add * 0.5,
-			h, s;
-
-		if ( min === max ) {
-			h = 0;
-		} else if ( r === max ) {
-			h = ( 60 * ( g - b ) / diff ) + 360;
-		} else if ( g === max ) {
-			h = ( 60 * ( b - r ) / diff ) + 120;
-		} else {
-			h = ( 60 * ( r - g ) / diff ) + 240;
-		}
-
-		if ( l === 0 || l === 1 ) {
-			s = l;
-		} else if ( l <= 0.5 ) {
-			s = diff / add;
-		} else {
-			s = diff / ( 2 - add );
-		}
-		return [ Math.round(h) % 360, s, l, a == null ? 1 : a ];
-	};
-
-	spaces.hsla.from = function ( hsla ) {
-		if ( hsla[ 0 ] == null || hsla[ 1 ] == null || hsla[ 2 ] == null ) {
-			return [ null, null, null, hsla[ 3 ] ];
-		}
-		var h = hsla[ 0 ] / 360,
-			s = hsla[ 1 ],
-			l = hsla[ 2 ],
-			a = hsla[ 3 ],
-			q = l <= 0.5 ? l * ( 1 + s ) : l + s - l * s,
-			p = 2 * l - q,
-			r, g, b;
-
-		return [
-			Math.round( hue2rgb( p, q, h + ( 1 / 3 ) ) * 255 ),
-			Math.round( hue2rgb( p, q, h ) * 255 ),
-			Math.round( hue2rgb( p, q, h - ( 1 / 3 ) ) * 255 ),
-			a
-		];
-	};
-
-
-	each( spaces, function( spaceName, space ) {
-		var props = space.props,
-			cache = space.cache,
-			to = space.to,
-			from = space.from;
-
-		// makes rgba() and hsla()
-		color.fn[ spaceName ] = function( value ) {
-
-			// generate a cache for this space if it doesn't exist
-			if ( to && !this[ cache ] ) {
-				this[ cache ] = to( this._rgba );
-			}
-			if ( value === undefined ) {
-				return this[ cache ].slice();
-			}
-
-			var type = jQuery.type( value ),
-				arr = ( type === "array" || type === "object" ) ? value : arguments,
-				local = this[ cache ].slice(),
-				ret;
-
-			each( props, function( key, prop ) {
-				var val = arr[ type === "object" ? key : prop.idx ];
-				if ( val == null ) {
-					val = local[ prop.idx ];
-				}
-				local[ prop.idx ] = clamp( val, prop );
-			});
-
-			if ( from ) {
-				ret = color( from( local ) );
-				ret[ cache ] = local;
-				return ret;
-			} else {
-				return color( local );
-			}
-		};
-
-		// makes red() green() blue() alpha() hue() saturation() lightness()
-		each( props, function( key, prop ) {
-			// alpha is included in more than one space
-			if ( color.fn[ key ] ) {
-				return;
-			}
-			color.fn[ key ] = function( value ) {
-				var vtype = jQuery.type( value ),
-					fn = ( key === 'alpha' ? ( this._hsla ? 'hsla' : 'rgba' ) : spaceName ),
-					local = this[ fn ](),
-					cur = local[ prop.idx ],
-					match;
-
-				if ( vtype === "undefined" ) {
-					return cur;
-				}
-
-				if ( vtype === "function" ) {
-					value = value.call( this, cur );
-					vtype = jQuery.type( value );
-				}
-				if ( value == null && prop.empty ) {
-					return this;
-				}
-				if ( vtype === "string" ) {
-					match = rplusequals.exec( value );
-					if ( match ) {
-						value = cur + parseFloat( match[ 2 ] ) * ( match[ 1 ] === "+" ? 1 : -1 );
-					}
-				}
-				local[ prop.idx ] = value;
-				return this[ fn ]( local );
-			};
-		});
-	});
-
-	// add .fx.step functions
-	each( stepHooks, function( i, hook ) {
-		jQuery.cssHooks[ hook ] = {
-			set: function( elem, value ) {
-				var parsed, backgroundColor, curElem;
-
-				if ( jQuery.type( value ) !== 'string' || ( parsed = stringParse( value ) ) )
-				{
-					value = color( parsed || value );
-					if ( !support.rgba && value._rgba[ 3 ] !== 1 ) {
-						curElem = hook === "backgroundColor" ? elem.parentNode : elem;
-						do {
-							backgroundColor = jQuery.curCSS( curElem, "backgroundColor" );
-						} while (
-							( backgroundColor === "" || backgroundColor === "transparent" ) &&
-							( curElem = curElem.parentNode ) &&
-							curElem.style
-						);
-
-						value = value.blend( backgroundColor && backgroundColor !== "transparent" ?
-							backgroundColor :
-							"_default" );
-					}
-
-					value = value.toRgbaString();
-				}
-				elem.style[ hook ] = value;
-			}
-		};
-		jQuery.fx.step[ hook ] = function( fx ) {
-			if ( !fx.colorInit ) {
-				fx.start = color( fx.elem, hook );
-				fx.end = color( fx.end );
-				fx.colorInit = true;
-			}
-			jQuery.cssHooks[ hook ].set( fx.elem, fx.start.transition( fx.end, fx.pos ) );
-		};
-	});
-
-	// detect rgba support
-	jQuery(function() {
-		var div = document.createElement( "div" ),
-			div_style = div.style;
-
-		div_style.cssText = "background-color:rgba(1,1,1,.5)";
-		support.rgba = div_style.backgroundColor.indexOf( "rgba" ) > -1;
-	});
-
-	// Some named colors to work with
-	// From Interface by Stefan Petre
-	// http://interface.eyecon.ro/
-	colors = jQuery.Color.names = {
-		aqua: "#00ffff",
-		azure: "#f0ffff",
-		beige: "#f5f5dc",
-		black: "#000000",
-		blue: "#0000ff",
-		brown: "#a52a2a",
-		cyan: "#00ffff",
-		darkblue: "#00008b",
-		darkcyan: "#008b8b",
-		darkgrey: "#a9a9a9",
-		darkgreen: "#006400",
-		darkkhaki: "#bdb76b",
-		darkmagenta: "#8b008b",
-		darkolivegreen: "#556b2f",
-		darkorange: "#ff8c00",
-		darkorchid: "#9932cc",
-		darkred: "#8b0000",
-		darksalmon: "#e9967a",
-		darkviolet: "#9400d3",
-		fuchsia: "#ff00ff",
-		gold: "#ffd700",
-		green: "#008000",
-		indigo: "#4b0082",
-		khaki: "#f0e68c",
-		lightblue: "#add8e6",
-		lightcyan: "#e0ffff",
-		lightgreen: "#90ee90",
-		lightgrey: "#d3d3d3",
-		lightpink: "#ffb6c1",
-		lightyellow: "#ffffe0",
-		lime: "#00ff00",
-		magenta: "#ff00ff",
-		maroon: "#800000",
-		navy: "#000080",
-		olive: "#808000",
-		orange: "#ffa500",
-		pink: "#ffc0cb",
-		purple: "#800080",
-		violet: "#800080",
-		red: "#ff0000",
-		silver: "#c0c0c0",
-		white: "#ffffff",
-		yellow: "#ffff00",
-		transparent: [ null, null, null, 0 ],
-		_default: "#ffffff"
-	};
-})( jQuery );
--- a/light9/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.min.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-/*! jQuery v1.9.0 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license */(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}function f(e,t,n){if(t=t||0,st.isFunction(t))return st.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return st.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=st.grep(e,function(e){return 1===e.nodeType});if(Wt.test(t))return st.filter(t,r,!n);t=st.filter(t,r)}return st.grep(e,function(e){return st.inArray(e,t)>=0===n})}function p(e){var t=zt.split("|"),n=e.createDocumentFragment();if(n.createElement)for(;t.length;)n.createElement(t.pop());return n}function d(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function h(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function g(e){var t=nn.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function m(e,t){for(var n,r=0;null!=(n=e[r]);r++)st._data(n,"globalEval",!t||st._data(t[r],"globalEval"))}function y(e,t){if(1===t.nodeType&&st.hasData(e)){var n,r,i,o=st._data(e),a=st._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)st.event.add(t,n,s[n][r])}a.data&&(a.data=st.extend({},a.data))}}function v(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!st.support.noCloneEvent&&t[st.expando]){r=st._data(t);for(i in r.events)st.removeEvent(t,i,r.handle);t.removeAttribute(st.expando)}"script"===n&&t.text!==e.text?(h(t).text=e.text,g(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),st.support.html5Clone&&e.innerHTML&&!st.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Zt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}function b(e,n){var r,i,o=0,a=e.getElementsByTagName!==t?e.getElementsByTagName(n||"*"):e.querySelectorAll!==t?e.querySelectorAll(n||"*"):t;if(!a)for(a=[],r=e.childNodes||e;null!=(i=r[o]);o++)!n||st.nodeName(i,n)?a.push(i):st.merge(a,b(i,n));return n===t||n&&st.nodeName(e,n)?st.merge([e],a):a}function x(e){Zt.test(e.type)&&(e.defaultChecked=e.checked)}function T(e,t){if(t in e)return t;for(var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Nn.length;i--;)if(t=Nn[i]+n,t in e)return t;return r}function w(e,t){return e=t||e,"none"===st.css(e,"display")||!st.contains(e.ownerDocument,e)}function N(e,t){for(var n,r=[],i=0,o=e.length;o>i;i++)n=e[i],n.style&&(r[i]=st._data(n,"olddisplay"),t?(r[i]||"none"!==n.style.display||(n.style.display=""),""===n.style.display&&w(n)&&(r[i]=st._data(n,"olddisplay",S(n.nodeName)))):r[i]||w(n)||st._data(n,"olddisplay",st.css(n,"display")));for(i=0;o>i;i++)n=e[i],n.style&&(t&&"none"!==n.style.display&&""!==n.style.display||(n.style.display=t?r[i]||"":"none"));return e}function C(e,t,n){var r=mn.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function k(e,t,n,r,i){for(var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;4>o;o+=2)"margin"===n&&(a+=st.css(e,n+wn[o],!0,i)),r?("content"===n&&(a-=st.css(e,"padding"+wn[o],!0,i)),"margin"!==n&&(a-=st.css(e,"border"+wn[o]+"Width",!0,i))):(a+=st.css(e,"padding"+wn[o],!0,i),"padding"!==n&&(a+=st.css(e,"border"+wn[o]+"Width",!0,i)));return a}function E(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=ln(e),a=st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=un(e,t,o),(0>i||null==i)&&(i=e.style[t]),yn.test(i))return i;r=a&&(st.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+k(e,t,n||(a?"border":"content"),r,o)+"px"}function S(e){var t=V,n=bn[e];return n||(n=A(e,t),"none"!==n&&n||(cn=(cn||st("<iframe frameborder='0' width='0' height='0'/>").css("cssText","display:block !important")).appendTo(t.documentElement),t=(cn[0].contentWindow||cn[0].contentDocument).document,t.write("<!doctype html><html><body>"),t.close(),n=A(e,t),cn.detach()),bn[e]=n),n}function A(e,t){var n=st(t.createElement(e)).appendTo(t.body),r=st.css(n[0],"display");return n.remove(),r}function j(e,t,n,r){var i;if(st.isArray(t))st.each(t,function(t,i){n||kn.test(e)?r(e,i):j(e+"["+("object"==typeof i?t:"")+"]",i,n,r)});else if(n||"object"!==st.type(t))r(e,t);else for(i in t)j(e+"["+i+"]",t[i],n,r)}function D(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(lt)||[];if(st.isFunction(n))for(;r=o[i++];)"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function L(e,n,r,i){function o(u){var l;return a[u]=!0,st.each(e[u]||[],function(e,u){var c=u(n,r,i);return"string"!=typeof c||s||a[c]?s?!(l=c):t:(n.dataTypes.unshift(c),o(c),!1)}),l}var a={},s=e===$n;return o(n.dataTypes[0])||!a["*"]&&o("*")}function H(e,n){var r,i,o=st.ajaxSettings.flatOptions||{};for(r in n)n[r]!==t&&((o[r]?e:i||(i={}))[r]=n[r]);return i&&st.extend(!0,e,i),e}function M(e,n,r){var i,o,a,s,u=e.contents,l=e.dataTypes,c=e.responseFields;for(o in c)o in r&&(n[c[o]]=r[o]);for(;"*"===l[0];)l.shift(),i===t&&(i=e.mimeType||n.getResponseHeader("Content-Type"));if(i)for(o in u)if(u[o]&&u[o].test(i)){l.unshift(o);break}if(l[0]in r)a=l[0];else{for(o in r){if(!l[0]||e.converters[o+" "+l[0]]){a=o;break}s||(s=o)}a=a||s}return a?(a!==l[0]&&l.unshift(a),r[a]):t}function q(e,t){var n,r,i,o,a={},s=0,u=e.dataTypes.slice(),l=u[0];if(e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u[1])for(n in e.converters)a[n.toLowerCase()]=e.converters[n];for(;i=u[++s];)if("*"!==i){if("*"!==l&&l!==i){if(n=a[l+" "+i]||a["* "+i],!n)for(r in a)if(o=r.split(" "),o[1]===i&&(n=a[l+" "+o[0]]||a["* "+o[0]])){n===!0?n=a[r]:a[r]!==!0&&(i=o[0],u.splice(s--,0,i));break}if(n!==!0)if(n&&e["throws"])t=n(t);else try{t=n(t)}catch(c){return{state:"parsererror",error:n?c:"No conversion from "+l+" to "+i}}}l=i}return{state:"success",data:t}}function _(){try{return new e.XMLHttpRequest}catch(t){}}function F(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(t){}}function O(){return setTimeout(function(){Qn=t}),Qn=st.now()}function B(e,t){st.each(t,function(t,n){for(var r=(rr[t]||[]).concat(rr["*"]),i=0,o=r.length;o>i;i++)if(r[i].call(e,t,n))return})}function P(e,t,n){var r,i,o=0,a=nr.length,s=st.Deferred().always(function(){delete u.elem}),u=function(){if(i)return!1;for(var t=Qn||O(),n=Math.max(0,l.startTime+l.duration-t),r=n/l.duration||0,o=1-r,a=0,u=l.tweens.length;u>a;a++)l.tweens[a].run(o);return s.notifyWith(e,[l,o,n]),1>o&&u?n:(s.resolveWith(e,[l]),!1)},l=s.promise({elem:e,props:st.extend({},t),opts:st.extend(!0,{specialEasing:{}},n),originalProperties:t,originalOptions:n,startTime:Qn||O(),duration:n.duration,tweens:[],createTween:function(t,n){var r=st.Tween(e,l.opts,t,n,l.opts.specialEasing[t]||l.opts.easing);return l.tweens.push(r),r},stop:function(t){var n=0,r=t?l.tweens.length:0;if(i)return this;for(i=!0;r>n;n++)l.tweens[n].run(1);return t?s.resolveWith(e,[l,t]):s.rejectWith(e,[l,t]),this}}),c=l.props;for(R(c,l.opts.specialEasing);a>o;o++)if(r=nr[o].call(l,e,c,l.opts))return r;return B(l,c),st.isFunction(l.opts.start)&&l.opts.start.call(e,l),st.fx.timer(st.extend(u,{elem:e,anim:l,queue:l.opts.queue})),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always)}function R(e,t){var n,r,i,o,a;for(n in e)if(r=st.camelCase(n),i=t[r],o=e[n],st.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),a=st.cssHooks[r],a&&"expand"in a){o=a.expand(o),delete e[r];for(n in o)n in e||(e[n]=o[n],t[n]=i)}else t[r]=i}function W(e,t,n){var r,i,o,a,s,u,l,c,f,p=this,d=e.style,h={},g=[],m=e.nodeType&&w(e);n.queue||(c=st._queueHooks(e,"fx"),null==c.unqueued&&(c.unqueued=0,f=c.empty.fire,c.empty.fire=function(){c.unqueued||f()}),c.unqueued++,p.always(function(){p.always(function(){c.unqueued--,st.queue(e,"fx").length||c.empty.fire()})})),1===e.nodeType&&("height"in t||"width"in t)&&(n.overflow=[d.overflow,d.overflowX,d.overflowY],"inline"===st.css(e,"display")&&"none"===st.css(e,"float")&&(st.support.inlineBlockNeedsLayout&&"inline"!==S(e.nodeName)?d.zoom=1:d.display="inline-block")),n.overflow&&(d.overflow="hidden",st.support.shrinkWrapBlocks||p.done(function(){d.overflow=n.overflow[0],d.overflowX=n.overflow[1],d.overflowY=n.overflow[2]}));for(r in t)if(o=t[r],Zn.exec(o)){if(delete t[r],u=u||"toggle"===o,o===(m?"hide":"show"))continue;g.push(r)}if(a=g.length){s=st._data(e,"fxshow")||st._data(e,"fxshow",{}),"hidden"in s&&(m=s.hidden),u&&(s.hidden=!m),m?st(e).show():p.done(function(){st(e).hide()}),p.done(function(){var t;st._removeData(e,"fxshow");for(t in h)st.style(e,t,h[t])});for(r=0;a>r;r++)i=g[r],l=p.createTween(i,m?s[i]:0),h[i]=s[i]||st.style(e,i),i in s||(s[i]=l.start,m&&(l.end=l.start,l.start="width"===i||"height"===i?1:0))}}function $(e,t,n,r,i){return new $.prototype.init(e,t,n,r,i)}function I(e,t){var n,r={height:e},i=0;for(t=t?1:0;4>i;i+=2-t)n=wn[i],r["margin"+n]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}function z(e){return st.isWindow(e)?e:9===e.nodeType?e.defaultView||e.parentWindow:!1}var X,U,V=e.document,Y=e.location,J=e.jQuery,G=e.$,Q={},K=[],Z="1.9.0",et=K.concat,tt=K.push,nt=K.slice,rt=K.indexOf,it=Q.toString,ot=Q.hasOwnProperty,at=Z.trim,st=function(e,t){return new st.fn.init(e,t,X)},ut=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,lt=/\S+/g,ct=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,ft=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,pt=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,dt=/^[\],:{}\s]*$/,ht=/(?:^|:|,)(?:\s*\[)+/g,gt=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,mt=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,yt=/^-ms-/,vt=/-([\da-z])/gi,bt=function(e,t){return t.toUpperCase()},xt=function(){V.addEventListener?(V.removeEventListener("DOMContentLoaded",xt,!1),st.ready()):"complete"===V.readyState&&(V.detachEvent("onreadystatechange",xt),st.ready())};st.fn=st.prototype={jquery:Z,constructor:st,init:function(e,n,r){var i,o;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:ft.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof st?n[0]:n,st.merge(this,st.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:V,!0)),pt.test(i[1])&&st.isPlainObject(n))for(i in n)st.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(o=V.getElementById(i[2]),o&&o.parentNode){if(o.id!==i[2])return r.find(e);this.length=1,this[0]=o}return this.context=V,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):st.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),st.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return nt.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=st.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return st.each(this,e,t)},ready:function(e){return st.ready.promise().done(e),this},slice:function(){return this.pushStack(nt.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(st.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:tt,sort:[].sort,splice:[].splice},st.fn.init.prototype=st.fn,st.extend=st.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||st.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(e=arguments[u]))for(n in e)r=s[n],i=e[n],s!==i&&(c&&i&&(st.isPlainObject(i)||(o=st.isArray(i)))?(o?(o=!1,a=r&&st.isArray(r)?r:[]):a=r&&st.isPlainObject(r)?r:{},s[n]=st.extend(c,a,i)):i!==t&&(s[n]=i));return s},st.extend({noConflict:function(t){return e.$===st&&(e.$=G),t&&e.jQuery===st&&(e.jQuery=J),st},isReady:!1,readyWait:1,holdReady:function(e){e?st.readyWait++:st.ready(!0)},ready:function(e){if(e===!0?!--st.readyWait:!st.isReady){if(!V.body)return setTimeout(st.ready);st.isReady=!0,e!==!0&&--st.readyWait>0||(U.resolveWith(V,[st]),st.fn.trigger&&st(V).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===st.type(e)},isArray:Array.isArray||function(e){return"array"===st.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?Q[it.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==st.type(e)||e.nodeType||st.isWindow(e))return!1;try{if(e.constructor&&!ot.call(e,"constructor")&&!ot.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||ot.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||V;var r=pt.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=st.buildFragment([e],t,i),i&&st(i).remove(),st.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=st.trim(n),n&&dt.test(n.replace(gt,"@").replace(mt,"]").replace(ht,"")))?Function("return "+n)():(st.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||st.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&st.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(yt,"ms-").replace(vt,bt)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,r){var i,o=0,a=e.length,s=n(e);if(r){if(s)for(;a>o&&(i=t.apply(e[o],r),i!==!1);o++);else for(o in e)if(i=t.apply(e[o],r),i===!1)break}else if(s)for(;a>o&&(i=t.call(e[o],o,e[o]),i!==!1);o++);else for(o in e)if(i=t.call(e[o],o,e[o]),i===!1)break;return e},trim:at&&!at.call("\ufeff\u00a0")?function(e){return null==e?"":at.call(e)}:function(e){return null==e?"":(e+"").replace(ct,"")},makeArray:function(e,t){var r=t||[];return null!=e&&(n(Object(e))?st.merge(r,"string"==typeof e?[e]:e):tt.call(r,e)),r},inArray:function(e,t,n){var r;if(t){if(rt)return rt.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else for(;n[o]!==t;)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,r){var i,o=0,a=e.length,s=n(e),u=[];if(s)for(;a>o;o++)i=t(e[o],o,r),null!=i&&(u[u.length]=i);else for(o in e)i=t(e[o],o,r),null!=i&&(u[u.length]=i);return et.apply([],u)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(r=e[n],n=e,e=r),st.isFunction(e)?(i=nt.call(arguments,2),o=function(){return e.apply(n||this,i.concat(nt.call(arguments)))},o.guid=e.guid=e.guid||st.guid++,o):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===st.type(r)){o=!0;for(u in r)st.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,st.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(st(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),st.ready.promise=function(t){if(!U)if(U=st.Deferred(),"complete"===V.readyState)setTimeout(st.ready);else if(V.addEventListener)V.addEventListener("DOMContentLoaded",xt,!1),e.addEventListener("load",st.ready,!1);else{V.attachEvent("onreadystatechange",xt),e.attachEvent("onload",st.ready);var n=!1;try{n=null==e.frameElement&&V.documentElement}catch(r){}n&&n.doScroll&&function i(){if(!st.isReady){try{n.doScroll("left")}catch(e){return setTimeout(i,50)}st.ready()}}()}return U.promise(t)},st.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){Q["[object "+t+"]"]=t.toLowerCase()}),X=st(V);var Tt={};st.Callbacks=function(e){e="string"==typeof e?Tt[e]||r(e):st.extend({},e);var n,i,o,a,s,u,l=[],c=!e.once&&[],f=function(t){for(n=e.memory&&t,i=!0,u=a||0,a=0,s=l.length,o=!0;l&&s>u;u++)if(l[u].apply(t[0],t[1])===!1&&e.stopOnFalse){n=!1;break}o=!1,l&&(c?c.length&&f(c.shift()):n?l=[]:p.disable())},p={add:function(){if(l){var t=l.length;(function r(t){st.each(t,function(t,n){var i=st.type(n);"function"===i?e.unique&&p.has(n)||l.push(n):n&&n.length&&"string"!==i&&r(n)})})(arguments),o?s=l.length:n&&(a=t,f(n))}return this},remove:function(){return l&&st.each(arguments,function(e,t){for(var n;(n=st.inArray(t,l,n))>-1;)l.splice(n,1),o&&(s>=n&&s--,u>=n&&u--)}),this},has:function(e){return st.inArray(e,l)>-1},empty:function(){return l=[],this},disable:function(){return l=c=n=t,this},disabled:function(){return!l},lock:function(){return c=t,n||p.disable(),this},locked:function(){return!c},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!l||i&&!c||(o?c.push(t):f(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},st.extend({Deferred:function(e){var t=[["resolve","done",st.Callbacks("once memory"),"resolved"],["reject","fail",st.Callbacks("once memory"),"rejected"],["notify","progress",st.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return st.Deferred(function(n){st.each(t,function(t,o){var a=o[0],s=st.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&st.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?st.extend(e,r):r}},i={};return r.pipe=r.then,st.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t,n,r,i=0,o=nt.call(arguments),a=o.length,s=1!==a||e&&st.isFunction(e.promise)?a:0,u=1===s?e:st.Deferred(),l=function(e,n,r){return function(i){n[e]=this,r[e]=arguments.length>1?nt.call(arguments):i,r===t?u.notifyWith(n,r):--s||u.resolveWith(n,r)}};if(a>1)for(t=Array(a),n=Array(a),r=Array(a);a>i;i++)o[i]&&st.isFunction(o[i].promise)?o[i].promise().done(l(i,r,o)).fail(u.reject).progress(l(i,n,t)):--s;return s||u.resolveWith(r,o),u.promise()}}),st.support=function(){var n,r,i,o,a,s,u,l,c,f,p=V.createElement("div");if(p.setAttribute("className","t"),p.innerHTML="  <link/><table></table><a href='/a'>a</a><input type='checkbox'/>",r=p.getElementsByTagName("*"),i=p.getElementsByTagName("a")[0],!r||!i||!r.length)return{};o=V.createElement("select"),a=o.appendChild(V.createElement("option")),s=p.getElementsByTagName("input")[0],i.style.cssText="top:1px;float:left;opacity:.5",n={getSetAttribute:"t"!==p.className,leadingWhitespace:3===p.firstChild.nodeType,tbody:!p.getElementsByTagName("tbody").length,htmlSerialize:!!p.getElementsByTagName("link").length,style:/top/.test(i.getAttribute("style")),hrefNormalized:"/a"===i.getAttribute("href"),opacity:/^0.5/.test(i.style.opacity),cssFloat:!!i.style.cssFloat,checkOn:!!s.value,optSelected:a.selected,enctype:!!V.createElement("form").enctype,html5Clone:"<:nav></:nav>"!==V.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===V.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},s.checked=!0,n.noCloneChecked=s.cloneNode(!0).checked,o.disabled=!0,n.optDisabled=!a.disabled;try{delete p.test}catch(d){n.deleteExpando=!1}s=V.createElement("input"),s.setAttribute("value",""),n.input=""===s.getAttribute("value"),s.value="t",s.setAttribute("type","radio"),n.radioValue="t"===s.value,s.setAttribute("checked","t"),s.setAttribute("name","t"),u=V.createDocumentFragment(),u.appendChild(s),n.appendChecked=s.checked,n.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,p.attachEvent&&(p.attachEvent("onclick",function(){n.noCloneEvent=!1}),p.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})p.setAttribute(l="on"+f,"t"),n[f+"Bubbles"]=l in e||p.attributes[l].expando===!1;return p.style.backgroundClip="content-box",p.cloneNode(!0).style.backgroundClip="",n.clearCloneStyle="content-box"===p.style.backgroundClip,st(function(){var r,i,o,a="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",s=V.getElementsByTagName("body")[0];s&&(r=V.createElement("div"),r.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",s.appendChild(r).appendChild(p),p.innerHTML="<table><tr><td></td><td>t</td></tr></table>",o=p.getElementsByTagName("td"),o[0].style.cssText="padding:0;margin:0;border:0;display:none",c=0===o[0].offsetHeight,o[0].style.display="",o[1].style.display="none",n.reliableHiddenOffsets=c&&0===o[0].offsetHeight,p.innerHTML="",p.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",n.boxSizing=4===p.offsetWidth,n.doesNotIncludeMarginInBodyOffset=1!==s.offsetTop,e.getComputedStyle&&(n.pixelPosition="1%"!==(e.getComputedStyle(p,null)||{}).top,n.boxSizingReliable="4px"===(e.getComputedStyle(p,null)||{width:"4px"}).width,i=p.appendChild(V.createElement("div")),i.style.cssText=p.style.cssText=a,i.style.marginRight=i.style.width="0",p.style.width="1px",n.reliableMarginRight=!parseFloat((e.getComputedStyle(i,null)||{}).marginRight)),p.style.zoom!==t&&(p.innerHTML="",p.style.cssText=a+"width:1px;padding:1px;display:inline;zoom:1",n.inlineBlockNeedsLayout=3===p.offsetWidth,p.style.display="block",p.innerHTML="<div></div>",p.firstChild.style.width="5px",n.shrinkWrapBlocks=3!==p.offsetWidth,s.style.zoom=1),s.removeChild(r),r=p=o=i=null)}),r=o=u=a=i=s=null,n}();var wt=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,Nt=/([A-Z])/g;st.extend({cache:{},expando:"jQuery"+(Z+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?st.cache[e[st.expando]]:e[st.expando],!!e&&!s(e)},data:function(e,t,n){return i(e,t,n,!1)},removeData:function(e,t){return o(e,t,!1)},_data:function(e,t,n){return i(e,t,n,!0)},_removeData:function(e,t){return o(e,t,!0)},acceptData:function(e){var t=e.nodeName&&st.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),st.fn.extend({data:function(e,n){var r,i,o=this[0],s=0,u=null;if(e===t){if(this.length&&(u=st.data(o),1===o.nodeType&&!st._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>s;s++)i=r[s].name,i.indexOf("data-")||(i=st.camelCase(i.substring(5)),a(o,i,u[i]));st._data(o,"parsedAttrs",!0)}return u}return"object"==typeof e?this.each(function(){st.data(this,e)}):st.access(this,function(n){return n===t?o?a(o,e,st.data(o,e)):null:(this.each(function(){st.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){st.removeData(this,e)})}}),st.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=st._data(e,n),r&&(!i||st.isArray(r)?i=st._data(e,n,st.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=st.queue(e,t),r=n.length,i=n.shift(),o=st._queueHooks(e,t),a=function(){st.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return st._data(e,n)||st._data(e,n,{empty:st.Callbacks("once memory").add(function(){st._removeData(e,t+"queue"),st._removeData(e,n)})})}}),st.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?st.queue(this[0],e):n===t?this:this.each(function(){var t=st.queue(this,e,n);st._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&st.dequeue(this,e)})},dequeue:function(e){return this.each(function(){st.dequeue(this,e)})},delay:function(e,t){return e=st.fx?st.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=st.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};for("string"!=typeof e&&(n=e,e=t),e=e||"fx";s--;)r=st._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var Ct,kt,Et=/[\t\r\n]/g,St=/\r/g,At=/^(?:input|select|textarea|button|object)$/i,jt=/^(?:a|area)$/i,Dt=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,Lt=/^(?:checked|selected)$/i,Ht=st.support.getSetAttribute,Mt=st.support.input;st.fn.extend({attr:function(e,t){return st.access(this,st.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){st.removeAttr(this,e)})},prop:function(e,t){return st.access(this,st.prop,e,t,arguments.length>1)},removeProp:function(e){return e=st.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(st.isFunction(e))return this.each(function(t){st(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(lt)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(Et," "):" ")){for(o=0;i=t[o++];)0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=st.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(st.isFunction(e))return this.each(function(t){st(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(lt)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(Et," "):"")){for(o=0;i=t[o++];)for(;r.indexOf(" "+i+" ")>=0;)r=r.replace(" "+i+" "," ");n.className=e?st.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return st.isFunction(e)?this.each(function(n){st(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n)for(var i,o=0,a=st(this),s=t,u=e.match(lt)||[];i=u[o++];)s=r?s:!a.hasClass(i),a[s?"addClass":"removeClass"](i);else("undefined"===n||"boolean"===n)&&(this.className&&st._data(this,"__className__",this.className),this.className=this.className||e===!1?"":st._data(this,"__className__")||"")})},hasClass:function(e){for(var t=" "+e+" ",n=0,r=this.length;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(Et," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=st.isFunction(e),this.each(function(r){var o,a=st(this);1===this.nodeType&&(o=i?e.call(this,r,a.val()):e,null==o?o="":"number"==typeof o?o+="":st.isArray(o)&&(o=st.map(o,function(e){return null==e?"":e+""})),n=st.valHooks[this.type]||st.valHooks[this.nodeName.toLowerCase()],n&&"set"in n&&n.set(this,o,"value")!==t||(this.value=o))});if(o)return n=st.valHooks[o.type]||st.valHooks[o.nodeName.toLowerCase()],n&&"get"in n&&(r=n.get(o,"value"))!==t?r:(r=o.value,"string"==typeof r?r.replace(St,""):null==r?"":r)}}}),st.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){for(var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(st.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&st.nodeName(n.parentNode,"optgroup"))){if(t=st(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=st.makeArray(t);return st(e).find("option").each(function(){this.selected=st.inArray(st(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return e.getAttribute===t?st.prop(e,n,r):(a=1!==s||!st.isXMLDoc(e),a&&(n=n.toLowerCase(),o=st.attrHooks[n]||(Dt.test(n)?kt:Ct)),r===t?o&&a&&"get"in o&&null!==(i=o.get(e,n))?i:(e.getAttribute!==t&&(i=e.getAttribute(n)),null==i?t:i):null!==r?o&&a&&"set"in o&&(i=o.set(e,r,n))!==t?i:(e.setAttribute(n,r+""),r):(st.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(lt);if(o&&1===e.nodeType)for(;n=o[i++];)r=st.propFix[n]||n,Dt.test(n)?!Ht&&Lt.test(n)?e[st.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:st.attr(e,n,""),e.removeAttribute(Ht?n:r)},attrHooks:{type:{set:function(e,t){if(!st.support.radioValue&&"radio"===t&&st.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!st.isXMLDoc(e),a&&(n=st.propFix[n]||n,o=st.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):At.test(e.nodeName)||jt.test(e.nodeName)&&e.href?0:t}}}}),kt={get:function(e,n){var r=st.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?Mt&&Ht?null!=i:Lt.test(n)?e[st.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?st.removeAttr(e,n):Mt&&Ht||!Lt.test(n)?e.setAttribute(!Ht&&st.propFix[n]||n,n):e[st.camelCase("default-"+n)]=e[n]=!0,n}},Mt&&Ht||(st.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return st.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t
-},set:function(e,n,r){return st.nodeName(e,"input")?(e.defaultValue=n,t):Ct&&Ct.set(e,n,r)}}),Ht||(Ct=st.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},st.attrHooks.contenteditable={get:Ct.get,set:function(e,t,n){Ct.set(e,""===t?!1:t,n)}},st.each(["width","height"],function(e,n){st.attrHooks[n]=st.extend(st.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),st.support.hrefNormalized||(st.each(["href","src","width","height"],function(e,n){st.attrHooks[n]=st.extend(st.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),st.each(["href","src"],function(e,t){st.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),st.support.style||(st.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),st.support.optSelected||(st.propHooks.selected=st.extend(st.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),st.support.enctype||(st.propFix.enctype="encoding"),st.support.checkOn||st.each(["radio","checkbox"],function(){st.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),st.each(["radio","checkbox"],function(){st.valHooks[this]=st.extend(st.valHooks[this],{set:function(e,n){return st.isArray(n)?e.checked=st.inArray(st(e).val(),n)>=0:t}})});var qt=/^(?:input|select|textarea)$/i,_t=/^key/,Ft=/^(?:mouse|contextmenu)|click/,Ot=/^(?:focusinfocus|focusoutblur)$/,Bt=/^([^.]*)(?:\.(.+)|)$/;st.event={global:{},add:function(e,n,r,i,o){var a,s,u,l,c,f,p,d,h,g,m,y=3!==e.nodeType&&8!==e.nodeType&&st._data(e);if(y){for(r.handler&&(a=r,r=a.handler,o=a.selector),r.guid||(r.guid=st.guid++),(l=y.events)||(l=y.events={}),(s=y.handle)||(s=y.handle=function(e){return st===t||e&&st.event.triggered===e.type?t:st.event.dispatch.apply(s.elem,arguments)},s.elem=e),n=(n||"").match(lt)||[""],c=n.length;c--;)u=Bt.exec(n[c])||[],h=m=u[1],g=(u[2]||"").split(".").sort(),p=st.event.special[h]||{},h=(o?p.delegateType:p.bindType)||h,p=st.event.special[h]||{},f=st.extend({type:h,origType:m,data:i,handler:r,guid:r.guid,selector:o,needsContext:o&&st.expr.match.needsContext.test(o),namespace:g.join(".")},a),(d=l[h])||(d=l[h]=[],d.delegateCount=0,p.setup&&p.setup.call(e,i,g,s)!==!1||(e.addEventListener?e.addEventListener(h,s,!1):e.attachEvent&&e.attachEvent("on"+h,s))),p.add&&(p.add.call(e,f),f.handler.guid||(f.handler.guid=r.guid)),o?d.splice(d.delegateCount++,0,f):d.push(f),st.event.global[h]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,m=st.hasData(e)&&st._data(e);if(m&&(u=m.events)){for(t=(t||"").match(lt)||[""],l=t.length;l--;)if(s=Bt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){for(f=st.event.special[d]||{},d=(r?f.delegateType:f.bindType)||d,p=u[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;o--;)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&f.teardown.call(e,h,m.handle)!==!1||st.removeEvent(e,d,m.handle),delete u[d])}else for(d in u)st.event.remove(e,d+t[l],n,r,!0);st.isEmptyObject(u)&&(delete m.handle,st._removeData(e,"events"))}},trigger:function(n,r,i,o){var a,s,u,l,c,f,p,d=[i||V],h=n.type||n,g=n.namespace?n.namespace.split("."):[];if(s=u=i=i||V,3!==i.nodeType&&8!==i.nodeType&&!Ot.test(h+st.event.triggered)&&(h.indexOf(".")>=0&&(g=h.split("."),h=g.shift(),g.sort()),c=0>h.indexOf(":")&&"on"+h,n=n[st.expando]?n:new st.Event(h,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=g.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:st.makeArray(r,[n]),p=st.event.special[h]||{},o||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!o&&!p.noBubble&&!st.isWindow(i)){for(l=p.delegateType||h,Ot.test(l+h)||(s=s.parentNode);s;s=s.parentNode)d.push(s),u=s;u===(i.ownerDocument||V)&&d.push(u.defaultView||u.parentWindow||e)}for(a=0;(s=d[a++])&&!n.isPropagationStopped();)n.type=a>1?l:p.bindType||h,f=(st._data(s,"events")||{})[n.type]&&st._data(s,"handle"),f&&f.apply(s,r),f=c&&s[c],f&&st.acceptData(s)&&f.apply&&f.apply(s,r)===!1&&n.preventDefault();if(n.type=h,!(o||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===h&&st.nodeName(i,"a")||!st.acceptData(i)||!c||!i[h]||st.isWindow(i))){u=i[c],u&&(i[c]=null),st.event.triggered=h;try{i[h]()}catch(m){}st.event.triggered=t,u&&(i[c]=u)}return n.result}},dispatch:function(e){e=st.event.fix(e);var n,r,i,o,a,s=[],u=nt.call(arguments),l=(st._data(this,"events")||{})[e.type]||[],c=st.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){for(s=st.event.handlers.call(this,e,l),n=0;(o=s[n++])&&!e.isPropagationStopped();)for(e.currentTarget=o.elem,r=0;(a=o.handlers[r++])&&!e.isImmediatePropagationStopped();)(!e.namespace_re||e.namespace_re.test(a.namespace))&&(e.handleObj=a,e.data=a.data,i=((st.event.special[a.origType]||{}).handle||a.handler).apply(o.elem,u),i!==t&&(e.result=i)===!1&&(e.preventDefault(),e.stopPropagation()));return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(l.disabled!==!0||"click"!==e.type){for(i=[],r=0;u>r;r++)a=n[r],o=a.selector+" ",i[o]===t&&(i[o]=a.needsContext?st(o,this).index(l)>=0:st.find(o,this,null,[l]).length),i[o]&&i.push(a);i.length&&s.push({elem:l,handlers:i})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[st.expando])return e;var t,n,r=e,i=st.event.fixHooks[e.type]||{},o=i.props?this.props.concat(i.props):this.props;for(e=new st.Event(r),t=o.length;t--;)n=o[t],e[n]=r[n];return e.target||(e.target=r.srcElement||V),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,i.filter?i.filter(e,r):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,o,a=n.button,s=n.fromElement;return null==e.pageX&&null!=n.clientX&&(r=e.target.ownerDocument||V,i=r.documentElement,o=r.body,e.pageX=n.clientX+(i&&i.scrollLeft||o&&o.scrollLeft||0)-(i&&i.clientLeft||o&&o.clientLeft||0),e.pageY=n.clientY+(i&&i.scrollTop||o&&o.scrollTop||0)-(i&&i.clientTop||o&&o.clientTop||0)),!e.relatedTarget&&s&&(e.relatedTarget=s===e.target?n.toElement:s),e.which||a===t||(e.which=1&a?1:2&a?3:4&a?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return st.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==V.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===V.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=st.extend(new st.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?st.event.trigger(i,null,t):st.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},st.removeEvent=V.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,n,r){var i="on"+n;e.detachEvent&&(e[i]===t&&(e[i]=null),e.detachEvent(i,r))},st.Event=function(e,n){return this instanceof st.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?u:l):this.type=e,n&&st.extend(this,n),this.timeStamp=e&&e.timeStamp||st.now(),this[st.expando]=!0,t):new st.Event(e,n)},st.Event.prototype={isDefaultPrevented:l,isPropagationStopped:l,isImmediatePropagationStopped:l,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=u,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=u,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=u,this.stopPropagation()}},st.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){st.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!st.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),st.support.submitBubbles||(st.event.special.submit={setup:function(){return st.nodeName(this,"form")?!1:(st.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=st.nodeName(n,"input")||st.nodeName(n,"button")?n.form:t;r&&!st._data(r,"submitBubbles")&&(st.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),st._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&st.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return st.nodeName(this,"form")?!1:(st.event.remove(this,"._submit"),t)}}),st.support.changeBubbles||(st.event.special.change={setup:function(){return qt.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(st.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),st.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),st.event.simulate("change",this,e,!0)})),!1):(st.event.add(this,"beforeactivate._change",function(e){var t=e.target;qt.test(t.nodeName)&&!st._data(t,"changeBubbles")&&(st.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||st.event.simulate("change",this.parentNode,e,!0)}),st._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return st.event.remove(this,"._change"),!qt.test(this.nodeName)}}),st.support.focusinBubbles||st.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){st.event.simulate(t,e.target,st.event.fix(e),!0)};st.event.special[t]={setup:function(){0===n++&&V.addEventListener(e,r,!0)},teardown:function(){0===--n&&V.removeEventListener(e,r,!0)}}}),st.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(s in e)this.on(s,n,r,e[s],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=l;else if(!i)return this;return 1===o&&(a=i,i=function(e){return st().off(e),a.apply(this,arguments)},i.guid=a.guid||(a.guid=st.guid++)),this.each(function(){st.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,st(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=l),this.each(function(){st.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){st.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?st.event.trigger(e,n,r,!0):t},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),st.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(e,t){st.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)},_t.test(t)&&(st.event.fixHooks[t]=st.event.keyHooks),Ft.test(t)&&(st.event.fixHooks[t]=st.event.mouseHooks)}),function(e,t){function n(e){return ht.test(e+"")}function r(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>C.cacheLength&&delete e[t.shift()],e[n]=r}}function i(e){return e[P]=!0,e}function o(e){var t=L.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function a(e,t,n,r){var i,o,a,s,u,l,c,d,h,g;if((t?t.ownerDocument||t:R)!==L&&D(t),t=t||L,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!M&&!r){if(i=gt.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&O(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return Q.apply(n,K.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&W.getByClassName&&t.getElementsByClassName)return Q.apply(n,K.call(t.getElementsByClassName(a),0)),n}if(W.qsa&&!q.test(e)){if(c=!0,d=P,h=t,g=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){for(l=f(e),(c=t.getAttribute("id"))?d=c.replace(vt,"\\$&"):t.setAttribute("id",d),d="[id='"+d+"'] ",u=l.length;u--;)l[u]=d+p(l[u]);h=dt.test(e)&&t.parentNode||t,g=l.join(",")}if(g)try{return Q.apply(n,K.call(h.querySelectorAll(g),0)),n}catch(m){}finally{c||t.removeAttribute("id")}}}return x(e.replace(at,"$1"),t,n,r)}function s(e,t){for(var n=e&&t&&e.nextSibling;n;n=n.nextSibling)if(n===t)return-1;return e?1:-1}function u(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function l(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function c(e){return i(function(t){return t=+t,i(function(n,r){for(var i,o=e([],n.length,t),a=o.length;a--;)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function f(e,t){var n,r,i,o,s,u,l,c=X[e+" "];if(c)return t?0:c.slice(0);for(s=e,u=[],l=C.preFilter;s;){(!n||(r=ut.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(i=[])),n=!1,(r=lt.exec(s))&&(n=r.shift(),i.push({value:n,type:r[0].replace(at," ")}),s=s.slice(n.length));for(o in C.filter)!(r=pt[o].exec(s))||l[o]&&!(r=l[o](r))||(n=r.shift(),i.push({value:n,type:o,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?a.error(e):X(e,u).slice(0)}function p(e){for(var t=0,n=e.length,r="";n>t;t++)r+=e[t].value;return r}function d(e,t,n){var r=t.dir,i=n&&"parentNode"===t.dir,o=I++;return t.first?function(t,n,o){for(;t=t[r];)if(1===t.nodeType||i)return e(t,n,o)}:function(t,n,a){var s,u,l,c=$+" "+o;if(a){for(;t=t[r];)if((1===t.nodeType||i)&&e(t,n,a))return!0}else for(;t=t[r];)if(1===t.nodeType||i)if(l=t[P]||(t[P]={}),(u=l[r])&&u[0]===c){if((s=u[1])===!0||s===N)return s===!0}else if(u=l[r]=[c],u[1]=e(t,n,a)||N,u[1]===!0)return!0}}function h(e){return e.length>1?function(t,n,r){for(var i=e.length;i--;)if(!e[i](t,n,r))return!1;return!0}:e[0]}function g(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function m(e,t,n,r,o,a){return r&&!r[P]&&(r=m(r)),o&&!o[P]&&(o=m(o,a)),i(function(i,a,s,u){var l,c,f,p=[],d=[],h=a.length,m=i||b(t||"*",s.nodeType?[s]:s,[]),y=!e||!i&&t?m:g(m,p,e,s,u),v=n?o||(i?e:h||r)?[]:a:y;if(n&&n(y,v,s,u),r)for(l=g(v,d),r(l,[],s,u),c=l.length;c--;)(f=l[c])&&(v[d[c]]=!(y[d[c]]=f));if(i){if(o||e){if(o){for(l=[],c=v.length;c--;)(f=v[c])&&l.push(y[c]=f);o(null,v=[],l,u)}for(c=v.length;c--;)(f=v[c])&&(l=o?Z.call(i,f):p[c])>-1&&(i[l]=!(a[l]=f))}}else v=g(v===a?v.splice(h,v.length):v),o?o(null,a,v,u):Q.apply(a,v)})}function y(e){for(var t,n,r,i=e.length,o=C.relative[e[0].type],a=o||C.relative[" "],s=o?1:0,u=d(function(e){return e===t},a,!0),l=d(function(e){return Z.call(t,e)>-1},a,!0),c=[function(e,n,r){return!o&&(r||n!==j)||((t=n).nodeType?u(e,n,r):l(e,n,r))}];i>s;s++)if(n=C.relative[e[s].type])c=[d(h(c),n)];else{if(n=C.filter[e[s].type].apply(null,e[s].matches),n[P]){for(r=++s;i>r&&!C.relative[e[r].type];r++);return m(s>1&&h(c),s>1&&p(e.slice(0,s-1)).replace(at,"$1"),n,r>s&&y(e.slice(s,r)),i>r&&y(e=e.slice(r)),i>r&&p(e))}c.push(n)}return h(c)}function v(e,t){var n=0,r=t.length>0,o=e.length>0,s=function(i,s,u,l,c){var f,p,d,h=[],m=0,y="0",v=i&&[],b=null!=c,x=j,T=i||o&&C.find.TAG("*",c&&s.parentNode||s),w=$+=null==x?1:Math.E;for(b&&(j=s!==L&&s,N=n);null!=(f=T[y]);y++){if(o&&f){for(p=0;d=e[p];p++)if(d(f,s,u)){l.push(f);break}b&&($=w,N=++n)}r&&((f=!d&&f)&&m--,i&&v.push(f))}if(m+=y,r&&y!==m){for(p=0;d=t[p];p++)d(v,h,s,u);if(i){if(m>0)for(;y--;)v[y]||h[y]||(h[y]=G.call(l));h=g(h)}Q.apply(l,h),b&&!i&&h.length>0&&m+t.length>1&&a.uniqueSort(l)}return b&&($=w,j=x),v};return r?i(s):s}function b(e,t,n){for(var r=0,i=t.length;i>r;r++)a(e,t[r],n);return n}function x(e,t,n,r){var i,o,a,s,u,l=f(e);if(!r&&1===l.length){if(o=l[0]=l[0].slice(0),o.length>2&&"ID"===(a=o[0]).type&&9===t.nodeType&&!M&&C.relative[o[1].type]){if(t=C.find.ID(a.matches[0].replace(xt,Tt),t)[0],!t)return n;e=e.slice(o.shift().value.length)}for(i=pt.needsContext.test(e)?-1:o.length-1;i>=0&&(a=o[i],!C.relative[s=a.type]);i--)if((u=C.find[s])&&(r=u(a.matches[0].replace(xt,Tt),dt.test(o[0].type)&&t.parentNode||t))){if(o.splice(i,1),e=r.length&&p(o),!e)return Q.apply(n,K.call(r,0)),n;break}}return S(e,l)(r,t,M,n,dt.test(e)),n}function T(){}var w,N,C,k,E,S,A,j,D,L,H,M,q,_,F,O,B,P="sizzle"+-new Date,R=e.document,W={},$=0,I=0,z=r(),X=r(),U=r(),V=typeof t,Y=1<<31,J=[],G=J.pop,Q=J.push,K=J.slice,Z=J.indexOf||function(e){for(var t=0,n=this.length;n>t;t++)if(this[t]===e)return t;return-1},et="[\\x20\\t\\r\\n\\f]",tt="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",nt=tt.replace("w","w#"),rt="([*^$|!~]?=)",it="\\["+et+"*("+tt+")"+et+"*(?:"+rt+et+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+nt+")|)|)"+et+"*\\]",ot=":("+tt+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+it.replace(3,8)+")*)|.*)\\)|)",at=RegExp("^"+et+"+|((?:^|[^\\\\])(?:\\\\.)*)"+et+"+$","g"),ut=RegExp("^"+et+"*,"+et+"*"),lt=RegExp("^"+et+"*([\\x20\\t\\r\\n\\f>+~])"+et+"*"),ct=RegExp(ot),ft=RegExp("^"+nt+"$"),pt={ID:RegExp("^#("+tt+")"),CLASS:RegExp("^\\.("+tt+")"),NAME:RegExp("^\\[name=['\"]?("+tt+")['\"]?\\]"),TAG:RegExp("^("+tt.replace("w","w*")+")"),ATTR:RegExp("^"+it),PSEUDO:RegExp("^"+ot),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+et+"*(even|odd|(([+-]|)(\\d*)n|)"+et+"*(?:([+-]|)"+et+"*(\\d+)|))"+et+"*\\)|)","i"),needsContext:RegExp("^"+et+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+et+"*((?:-\\d)?\\d*)"+et+"*\\)|)(?=[^-]|$)","i")},dt=/[\x20\t\r\n\f]*[+~]/,ht=/\{\s*\[native code\]\s*\}/,gt=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,mt=/^(?:input|select|textarea|button)$/i,yt=/^h\d$/i,vt=/'|\\/g,bt=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,xt=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,Tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{K.call(H.childNodes,0)[0].nodeType}catch(wt){K=function(e){for(var t,n=[];t=this[e];e++)n.push(t);return n}}E=a.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},D=a.setDocument=function(e){var r=e?e.ownerDocument||e:R;return r!==L&&9===r.nodeType&&r.documentElement?(L=r,H=r.documentElement,M=E(r),W.tagNameNoComments=o(function(e){return e.appendChild(r.createComment("")),!e.getElementsByTagName("*").length}),W.attributes=o(function(e){e.innerHTML="<select></select>";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),W.getByClassName=o(function(e){return e.innerHTML="<div class='hidden e'></div><div class='hidden'></div>",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),W.getByName=o(function(e){e.id=P+0,e.innerHTML="<a name='"+P+"'></a><div name='"+P+"'></div>",H.insertBefore(e,H.firstChild);var t=r.getElementsByName&&r.getElementsByName(P).length===2+r.getElementsByName(P+0).length;return W.getIdNotName=!r.getElementById(P),H.removeChild(e),t}),C.attrHandle=o(function(e){return e.innerHTML="<a href='#'></a>",e.firstChild&&typeof e.firstChild.getAttribute!==V&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},W.getIdNotName?(C.find.ID=function(e,t){if(typeof t.getElementById!==V&&!M){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},C.filter.ID=function(e){var t=e.replace(xt,Tt);return function(e){return e.getAttribute("id")===t}}):(C.find.ID=function(e,n){if(typeof n.getElementById!==V&&!M){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==V&&r.getAttributeNode("id").value===e?[r]:t:[]}},C.filter.ID=function(e){var t=e.replace(xt,Tt);return function(e){var n=typeof e.getAttributeNode!==V&&e.getAttributeNode("id");return n&&n.value===t}}),C.find.TAG=W.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==V?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){for(;n=o[i];i++)1===n.nodeType&&r.push(n);return r}return o},C.find.NAME=W.getByName&&function(e,n){return typeof n.getElementsByName!==V?n.getElementsByName(name):t},C.find.CLASS=W.getByClassName&&function(e,n){return typeof n.getElementsByClassName===V||M?t:n.getElementsByClassName(e)},_=[],q=[":focus"],(W.qsa=n(r.querySelectorAll))&&(o(function(e){e.innerHTML="<select><option selected=''></option></select>",e.querySelectorAll("[selected]").length||q.push("\\["+et+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||q.push(":checked")}),o(function(e){e.innerHTML="<input type='hidden' i=''/>",e.querySelectorAll("[i^='']").length&&q.push("[*^$]="+et+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),q.push(",.*:")})),(W.matchesSelector=n(F=H.matchesSelector||H.mozMatchesSelector||H.webkitMatchesSelector||H.oMatchesSelector||H.msMatchesSelector))&&o(function(e){W.disconnectedMatch=F.call(e,"div"),F.call(e,"[s!='']:x"),_.push("!=",ot)}),q=RegExp(q.join("|")),_=RegExp(_.join("|")),O=n(H.contains)||H.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},B=H.compareDocumentPosition?function(e,t){var n;return e===t?(A=!0,0):(n=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&n||e.parentNode&&11===e.parentNode.nodeType?e===r||O(R,e)?-1:t===r||O(R,t)?1:0:4&n?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var n,i=0,o=e.parentNode,a=t.parentNode,u=[e],l=[t];if(e===t)return A=!0,0;if(e.sourceIndex&&t.sourceIndex)return(~t.sourceIndex||Y)-(O(R,e)&&~e.sourceIndex||Y);if(!o||!a)return e===r?-1:t===r?1:o?-1:a?1:0;if(o===a)return s(e,t);for(n=e;n=n.parentNode;)u.unshift(n);for(n=t;n=n.parentNode;)l.unshift(n);for(;u[i]===l[i];)i++;return i?s(u[i],l[i]):u[i]===R?-1:l[i]===R?1:0},A=!1,[0,0].sort(B),W.detectDuplicates=A,L):L},a.matches=function(e,t){return a(e,null,null,t)},a.matchesSelector=function(e,t){if((e.ownerDocument||e)!==L&&D(e),t=t.replace(bt,"='$1']"),!(!W.matchesSelector||M||_&&_.test(t)||q.test(t)))try{var n=F.call(e,t);if(n||W.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return a(t,L,null,[e]).length>0},a.contains=function(e,t){return(e.ownerDocument||e)!==L&&D(e),O(e,t)},a.attr=function(e,t){var n;return(e.ownerDocument||e)!==L&&D(e),M||(t=t.toLowerCase()),(n=C.attrHandle[t])?n(e):M||W.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},a.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},a.uniqueSort=function(e){var t,n=[],r=1,i=0;if(A=!W.detectDuplicates,e.sort(B),A){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));for(;i--;)e.splice(n[i],1)}return e},k=a.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=k(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=k(t);return n},C=a.selectors={cacheLength:50,createPseudo:i,match:pt,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(xt,Tt),e[3]=(e[4]||e[5]||"").replace(xt,Tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||a.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&a.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return pt.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&ct.test(n)&&(t=f(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(xt,Tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=z[e+" "];return t||(t=RegExp("(^|"+et+")"+e+"("+et+"|$)"))&&z(e,function(e){return t.test(e.className||typeof e.getAttribute!==V&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=a.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.substr(i.length-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.substr(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){for(;g;){for(f=t;f=f[g];)if(s?f.nodeName.toLowerCase()===y:1===f.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){for(c=m[P]||(m[P]={}),l=c[e]||[],d=l[0]===$&&l[1],p=l[0]===$&&l[2],f=d&&m.childNodes[d];f=++d&&f&&f[g]||(p=d=0)||h.pop();)if(1===f.nodeType&&++p&&f===t){c[e]=[$,d,p];break}}else if(v&&(l=(t[P]||(t[P]={}))[e])&&l[0]===$)p=l[1];else for(;(f=++d&&f&&f[g]||(p=d=0)||h.pop())&&((s?f.nodeName.toLowerCase()!==y:1!==f.nodeType)||!++p||(v&&((f[P]||(f[P]={}))[e]=[$,p]),f!==t)););return p-=i,p===r||0===p%r&&p/r>=0}}},PSEUDO:function(e,t){var n,r=C.pseudos[e]||C.setFilters[e.toLowerCase()]||a.error("unsupported pseudo: "+e);return r[P]?r(t):r.length>1?(n=[e,e,"",t],C.setFilters.hasOwnProperty(e.toLowerCase())?i(function(e,n){for(var i,o=r(e,t),a=o.length;a--;)i=Z.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:i(function(e){var t=[],n=[],r=S(e.replace(at,"$1"));return r[P]?i(function(e,t,n,i){for(var o,a=r(e,null,i,[]),s=e.length;s--;)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:i(function(e){return function(t){return a(e,t).length>0}}),contains:i(function(e){return function(t){return(t.textContent||t.innerText||k(t)).indexOf(e)>-1}}),lang:i(function(e){return ft.test(e||"")||a.error("unsupported lang: "+e),e=e.replace(xt,Tt).toLowerCase(),function(t){var n;do if(n=M?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===H},focus:function(e){return e===L.activeElement&&(!L.hasFocus||L.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!C.pseudos.empty(e)},header:function(e){return yt.test(e.nodeName)},input:function(e){return mt.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:c(function(){return[0]}),last:c(function(e,t){return[t-1]}),eq:c(function(e,t,n){return[0>n?n+t:n]}),even:c(function(e,t){for(var n=0;t>n;n+=2)e.push(n);return e}),odd:c(function(e,t){for(var n=1;t>n;n+=2)e.push(n);return e}),lt:c(function(e,t,n){for(var r=0>n?n+t:n;--r>=0;)e.push(r);return e}),gt:c(function(e,t,n){for(var r=0>n?n+t:n;t>++r;)e.push(r);return e})}};for(w in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})C.pseudos[w]=u(w);for(w in{submit:!0,reset:!0})C.pseudos[w]=l(w);S=a.compile=function(e,t){var n,r=[],i=[],o=U[e+" "];if(!o){for(t||(t=f(e)),n=t.length;n--;)o=y(t[n]),o[P]?r.push(o):i.push(o);o=U(e,v(i,r))}return o},C.pseudos.nth=C.pseudos.eq,C.filters=T.prototype=C.pseudos,C.setFilters=new T,D(),a.attr=st.attr,st.find=a,st.expr=a.selectors,st.expr[":"]=st.expr.pseudos,st.unique=a.uniqueSort,st.text=a.getText,st.isXMLDoc=a.isXML,st.contains=a.contains}(e);var Pt=/Until$/,Rt=/^(?:parents|prev(?:Until|All))/,Wt=/^.[^:#\[\.,]*$/,$t=st.expr.match.needsContext,It={children:!0,contents:!0,next:!0,prev:!0};st.fn.extend({find:function(e){var t,n,r;if("string"!=typeof e)return r=this,this.pushStack(st(e).filter(function(){for(t=0;r.length>t;t++)if(st.contains(r[t],this))return!0}));for(n=[],t=0;this.length>t;t++)st.find(e,this[t],n);return n=this.pushStack(st.unique(n)),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=st(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(st.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(f(this,e,!1))},filter:function(e){return this.pushStack(f(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?$t.test(e)?st(e,this.context).index(this[0])>=0:st.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){for(var n,r=0,i=this.length,o=[],a=$t.test(e)||"string"!=typeof e?st(e,t||this.context):0;i>r;r++)for(n=this[r];n&&n.ownerDocument&&n!==t&&11!==n.nodeType;){if(a?a.index(n)>-1:st.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}return this.pushStack(o.length>1?st.unique(o):o)},index:function(e){return e?"string"==typeof e?st.inArray(this[0],st(e)):st.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?st(e,t):st.makeArray(e&&e.nodeType?[e]:e),r=st.merge(this.get(),n);return this.pushStack(st.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),st.fn.andSelf=st.fn.addBack,st.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return st.dir(e,"parentNode")},parentsUntil:function(e,t,n){return st.dir(e,"parentNode",n)},next:function(e){return c(e,"nextSibling")},prev:function(e){return c(e,"previousSibling")
-},nextAll:function(e){return st.dir(e,"nextSibling")},prevAll:function(e){return st.dir(e,"previousSibling")},nextUntil:function(e,t,n){return st.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return st.dir(e,"previousSibling",n)},siblings:function(e){return st.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return st.sibling(e.firstChild)},contents:function(e){return st.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:st.merge([],e.childNodes)}},function(e,t){st.fn[e]=function(n,r){var i=st.map(this,t,n);return Pt.test(e)||(r=n),r&&"string"==typeof r&&(i=st.filter(r,i)),i=this.length>1&&!It[e]?st.unique(i):i,this.length>1&&Rt.test(e)&&(i=i.reverse()),this.pushStack(i)}}),st.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?st.find.matchesSelector(t[0],e)?[t[0]]:[]:st.find.matches(e,t)},dir:function(e,n,r){for(var i=[],o=e[n];o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!st(o).is(r));)1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});var zt="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",Xt=/ jQuery\d+="(?:null|\d+)"/g,Ut=RegExp("<(?:"+zt+")[\\s/>]","i"),Vt=/^\s+/,Yt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,Jt=/<([\w:]+)/,Gt=/<tbody/i,Qt=/<|&#?\w+;/,Kt=/<(?:script|style|link)/i,Zt=/^(?:checkbox|radio)$/i,en=/checked\s*(?:[^=]|=\s*.checked.)/i,tn=/^$|\/(?:java|ecma)script/i,nn=/^true\/(.*)/,rn=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,on={option:[1,"<select multiple='multiple'>","</select>"],legend:[1,"<fieldset>","</fieldset>"],area:[1,"<map>","</map>"],param:[1,"<object>","</object>"],thead:[1,"<table>","</table>"],tr:[2,"<table><tbody>","</tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:st.support.htmlSerialize?[0,"",""]:[1,"X<div>","</div>"]},an=p(V),sn=an.appendChild(V.createElement("div"));on.optgroup=on.option,on.tbody=on.tfoot=on.colgroup=on.caption=on.thead,on.th=on.td,st.fn.extend({text:function(e){return st.access(this,function(e){return e===t?st.text(this):this.empty().append((this[0]&&this[0].ownerDocument||V).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(st.isFunction(e))return this.each(function(t){st(this).wrapAll(e.call(this,t))});if(this[0]){var t=st(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){for(var e=this;e.firstChild&&1===e.firstChild.nodeType;)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return st.isFunction(e)?this.each(function(t){st(this).wrapInner(e.call(this,t))}):this.each(function(){var t=st(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=st.isFunction(e);return this.each(function(n){st(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){st.nodeName(this,"body")||st(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){for(var n,r=0;null!=(n=this[r]);r++)(!e||st.filter(e,[n]).length>0)&&(t||1!==n.nodeType||st.cleanData(b(n)),n.parentNode&&(t&&st.contains(n.ownerDocument,n)&&m(b(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){for(var e,t=0;null!=(e=this[t]);t++){for(1===e.nodeType&&st.cleanData(b(e,!1));e.firstChild;)e.removeChild(e.firstChild);e.options&&st.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return st.clone(this,e,t)})},html:function(e){return st.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(Xt,""):t;if(!("string"!=typeof e||Kt.test(e)||!st.support.htmlSerialize&&Ut.test(e)||!st.support.leadingWhitespace&&Vt.test(e)||on[(Jt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(Yt,"<$1></$2>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(st.cleanData(b(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=st.isFunction(e);return t||"string"==typeof e||(e=st(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;(n&&1===this.nodeType||11===this.nodeType)&&(st(this).remove(),t?t.parentNode.insertBefore(e,t):n.appendChild(e))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=et.apply([],e);var i,o,a,s,u,l,c=0,f=this.length,p=this,m=f-1,y=e[0],v=st.isFunction(y);if(v||!(1>=f||"string"!=typeof y||st.support.checkClone)&&en.test(y))return this.each(function(i){var o=p.eq(i);v&&(e[0]=y.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(f&&(i=st.buildFragment(e,this[0].ownerDocument,!1,this),o=i.firstChild,1===i.childNodes.length&&(i=o),o)){for(n=n&&st.nodeName(o,"tr"),a=st.map(b(i,"script"),h),s=a.length;f>c;c++)u=i,c!==m&&(u=st.clone(u,!0,!0),s&&st.merge(a,b(u,"script"))),r.call(n&&st.nodeName(this[c],"table")?d(this[c],"tbody"):this[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,st.map(a,g),c=0;s>c;c++)u=a[c],tn.test(u.type||"")&&!st._data(u,"globalEval")&&st.contains(l,u)&&(u.src?st.ajax({url:u.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):st.globalEval((u.text||u.textContent||u.innerHTML||"").replace(rn,"")));i=o=null}return this}}),st.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){st.fn[e]=function(e){for(var n,r=0,i=[],o=st(e),a=o.length-1;a>=r;r++)n=r===a?this:this.clone(!0),st(o[r])[t](n),tt.apply(i,n.get());return this.pushStack(i)}}),st.extend({clone:function(e,t,n){var r,i,o,a,s,u=st.contains(e.ownerDocument,e);if(st.support.html5Clone||st.isXMLDoc(e)||!Ut.test("<"+e.nodeName+">")?s=e.cloneNode(!0):(sn.innerHTML=e.outerHTML,sn.removeChild(s=sn.firstChild)),!(st.support.noCloneEvent&&st.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||st.isXMLDoc(e)))for(r=b(s),i=b(e),a=0;null!=(o=i[a]);++a)r[a]&&v(o,r[a]);if(t)if(n)for(i=i||b(e),r=r||b(s),a=0;null!=(o=i[a]);a++)y(o,r[a]);else y(e,s);return r=b(s,"script"),r.length>0&&m(r,!u&&b(e,"script")),r=i=o=null,s},buildFragment:function(e,t,n,r){for(var i,o,a,s,u,l,c,f=e.length,d=p(t),h=[],g=0;f>g;g++)if(o=e[g],o||0===o)if("object"===st.type(o))st.merge(h,o.nodeType?[o]:o);else if(Qt.test(o)){for(s=s||d.appendChild(t.createElement("div")),a=(Jt.exec(o)||["",""])[1].toLowerCase(),u=on[a]||on._default,s.innerHTML=u[1]+o.replace(Yt,"<$1></$2>")+u[2],c=u[0];c--;)s=s.lastChild;if(!st.support.leadingWhitespace&&Vt.test(o)&&h.push(t.createTextNode(Vt.exec(o)[0])),!st.support.tbody)for(o="table"!==a||Gt.test(o)?"<table>"!==u[1]||Gt.test(o)?0:s:s.firstChild,c=o&&o.childNodes.length;c--;)st.nodeName(l=o.childNodes[c],"tbody")&&!l.childNodes.length&&o.removeChild(l);for(st.merge(h,s.childNodes),s.textContent="";s.firstChild;)s.removeChild(s.firstChild);s=d.lastChild}else h.push(t.createTextNode(o));for(s&&d.removeChild(s),st.support.appendChecked||st.grep(b(h,"input"),x),g=0;o=h[g++];)if((!r||-1===st.inArray(o,r))&&(i=st.contains(o.ownerDocument,o),s=b(d.appendChild(o),"script"),i&&m(s),n))for(c=0;o=s[c++];)tn.test(o.type||"")&&n.push(o);return s=null,d},cleanData:function(e,n){for(var r,i,o,a,s=0,u=st.expando,l=st.cache,c=st.support.deleteExpando,f=st.event.special;null!=(o=e[s]);s++)if((n||st.acceptData(o))&&(i=o[u],r=i&&l[i])){if(r.events)for(a in r.events)f[a]?st.event.remove(o,a):st.removeEvent(o,a,r.handle);l[i]&&(delete l[i],c?delete o[u]:o.removeAttribute!==t?o.removeAttribute(u):o[u]=null,K.push(i))}}});var un,ln,cn,fn=/alpha\([^)]*\)/i,pn=/opacity\s*=\s*([^)]*)/,dn=/^(top|right|bottom|left)$/,hn=/^(none|table(?!-c[ea]).+)/,gn=/^margin/,mn=RegExp("^("+ut+")(.*)$","i"),yn=RegExp("^("+ut+")(?!px)[a-z%]+$","i"),vn=RegExp("^([+-])=("+ut+")","i"),bn={BODY:"block"},xn={position:"absolute",visibility:"hidden",display:"block"},Tn={letterSpacing:0,fontWeight:400},wn=["Top","Right","Bottom","Left"],Nn=["Webkit","O","Moz","ms"];st.fn.extend({css:function(e,n){return st.access(this,function(e,n,r){var i,o,a={},s=0;if(st.isArray(n)){for(i=ln(e),o=n.length;o>s;s++)a[n[s]]=st.css(e,n[s],!1,i);return a}return r!==t?st.style(e,n,r):st.css(e,n)},e,n,arguments.length>1)},show:function(){return N(this,!0)},hide:function(){return N(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:w(this))?st(this).show():st(this).hide()})}}),st.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=un(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":st.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=st.camelCase(n),l=e.style;if(n=st.cssProps[u]||(st.cssProps[u]=T(l,u)),s=st.cssHooks[n]||st.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=vn.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(st.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||st.cssNumber[u]||(r+="px"),st.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=st.camelCase(n);return n=st.cssProps[u]||(st.cssProps[u]=T(e.style,u)),s=st.cssHooks[n]||st.cssHooks[u],s&&"get"in s&&(o=s.get(e,!0,r)),o===t&&(o=un(e,n,i)),"normal"===o&&n in Tn&&(o=Tn[n]),r?(a=parseFloat(o),r===!0||st.isNumeric(a)?a||0:o):o},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(ln=function(t){return e.getComputedStyle(t,null)},un=function(e,n,r){var i,o,a,s=r||ln(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||st.contains(e.ownerDocument,e)||(u=st.style(e,n)),yn.test(u)&&gn.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):V.documentElement.currentStyle&&(ln=function(e){return e.currentStyle},un=function(e,n,r){var i,o,a,s=r||ln(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),yn.test(u)&&!dn.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u}),st.each(["height","width"],function(e,n){st.cssHooks[n]={get:function(e,r,i){return r?0===e.offsetWidth&&hn.test(st.css(e,"display"))?st.swap(e,xn,function(){return E(e,n,i)}):E(e,n,i):t},set:function(e,t,r){var i=r&&ln(e);return C(e,t,r?k(e,n,r,st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,i),i):0)}}}),st.support.opacity||(st.cssHooks.opacity={get:function(e,t){return pn.test((t&&e.currentStyle?e.currentStyle.filter:e.style.filter)||"")?.01*parseFloat(RegExp.$1)+"":t?"1":""},set:function(e,t){var n=e.style,r=e.currentStyle,i=st.isNumeric(t)?"alpha(opacity="+100*t+")":"",o=r&&r.filter||n.filter||"";n.zoom=1,(t>=1||""===t)&&""===st.trim(o.replace(fn,""))&&n.removeAttribute&&(n.removeAttribute("filter"),""===t||r&&!r.filter)||(n.filter=fn.test(o)?o.replace(fn,i):o+" "+i)}}),st(function(){st.support.reliableMarginRight||(st.cssHooks.marginRight={get:function(e,n){return n?st.swap(e,{display:"inline-block"},un,[e,"marginRight"]):t}}),!st.support.pixelPosition&&st.fn.position&&st.each(["top","left"],function(e,n){st.cssHooks[n]={get:function(e,r){return r?(r=un(e,n),yn.test(r)?st(e).position()[n]+"px":r):t}}})}),st.expr&&st.expr.filters&&(st.expr.filters.hidden=function(e){return 0===e.offsetWidth&&0===e.offsetHeight||!st.support.reliableHiddenOffsets&&"none"===(e.style&&e.style.display||st.css(e,"display"))},st.expr.filters.visible=function(e){return!st.expr.filters.hidden(e)}),st.each({margin:"",padding:"",border:"Width"},function(e,t){st.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];4>r;r++)i[e+wn[r]+t]=o[r]||o[r-2]||o[0];return i}},gn.test(e)||(st.cssHooks[e+t].set=C)});var Cn=/%20/g,kn=/\[\]$/,En=/\r?\n/g,Sn=/^(?:submit|button|image|reset)$/i,An=/^(?:input|select|textarea|keygen)/i;st.fn.extend({serialize:function(){return st.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=st.prop(this,"elements");return e?st.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!st(this).is(":disabled")&&An.test(this.nodeName)&&!Sn.test(e)&&(this.checked||!Zt.test(e))}).map(function(e,t){var n=st(this).val();return null==n?null:st.isArray(n)?st.map(n,function(e){return{name:t.name,value:e.replace(En,"\r\n")}}):{name:t.name,value:n.replace(En,"\r\n")}}).get()}}),st.param=function(e,n){var r,i=[],o=function(e,t){t=st.isFunction(t)?t():null==t?"":t,i[i.length]=encodeURIComponent(e)+"="+encodeURIComponent(t)};if(n===t&&(n=st.ajaxSettings&&st.ajaxSettings.traditional),st.isArray(e)||e.jquery&&!st.isPlainObject(e))st.each(e,function(){o(this.name,this.value)});else for(r in e)j(r,e[r],n,o);return i.join("&").replace(Cn,"+")};var jn,Dn,Ln=st.now(),Hn=/\?/,Mn=/#.*$/,qn=/([?&])_=[^&]*/,_n=/^(.*?):[ \t]*([^\r\n]*)\r?$/gm,Fn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,On=/^(?:GET|HEAD)$/,Bn=/^\/\//,Pn=/^([\w.+-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/,Rn=st.fn.load,Wn={},$n={},In="*/".concat("*");try{Dn=Y.href}catch(zn){Dn=V.createElement("a"),Dn.href="",Dn=Dn.href}jn=Pn.exec(Dn.toLowerCase())||[],st.fn.load=function(e,n,r){if("string"!=typeof e&&Rn)return Rn.apply(this,arguments);var i,o,a,s=this,u=e.indexOf(" ");return u>=0&&(i=e.slice(u,e.length),e=e.slice(0,u)),st.isFunction(n)?(r=n,n=t):n&&"object"==typeof n&&(o="POST"),s.length>0&&st.ajax({url:e,type:o,dataType:"html",data:n}).done(function(e){a=arguments,s.html(i?st("<div>").append(st.parseHTML(e)).find(i):e)}).complete(r&&function(e,t){s.each(r,a||[e.responseText,t,e])}),this},st.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){st.fn[t]=function(e){return this.on(t,e)}}),st.each(["get","post"],function(e,n){st[n]=function(e,r,i,o){return st.isFunction(r)&&(o=o||i,i=r,r=t),st.ajax({url:e,type:n,dataType:o,data:r,success:i})}}),st.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Dn,type:"GET",isLocal:Fn.test(jn[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":In,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":e.String,"text html":!0,"text json":st.parseJSON,"text xml":st.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?H(H(e,st.ajaxSettings),t):H(st.ajaxSettings,e)},ajaxPrefilter:D(Wn),ajaxTransport:D($n),ajax:function(e,n){function r(e,n,r,s){var l,f,v,b,T,N=n;2!==x&&(x=2,u&&clearTimeout(u),i=t,a=s||"",w.readyState=e>0?4:0,r&&(b=M(p,w,r)),e>=200&&300>e||304===e?(p.ifModified&&(T=w.getResponseHeader("Last-Modified"),T&&(st.lastModified[o]=T),T=w.getResponseHeader("etag"),T&&(st.etag[o]=T)),304===e?(l=!0,N="notmodified"):(l=q(p,b),N=l.state,f=l.data,v=l.error,l=!v)):(v=N,(e||!N)&&(N="error",0>e&&(e=0))),w.status=e,w.statusText=(n||N)+"",l?g.resolveWith(d,[f,N,w]):g.rejectWith(d,[w,N,v]),w.statusCode(y),y=t,c&&h.trigger(l?"ajaxSuccess":"ajaxError",[w,p,l?f:v]),m.fireWith(d,[w,N]),c&&(h.trigger("ajaxComplete",[w,p]),--st.active||st.event.trigger("ajaxStop")))}"object"==typeof e&&(n=e,e=t),n=n||{};var i,o,a,s,u,l,c,f,p=st.ajaxSetup({},n),d=p.context||p,h=p.context&&(d.nodeType||d.jquery)?st(d):st.event,g=st.Deferred(),m=st.Callbacks("once memory"),y=p.statusCode||{},v={},b={},x=0,T="canceled",w={readyState:0,getResponseHeader:function(e){var t;if(2===x){if(!s)for(s={};t=_n.exec(a);)s[t[1].toLowerCase()]=t[2];t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===x?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return x||(e=b[n]=b[n]||e,v[e]=t),this},overrideMimeType:function(e){return x||(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(2>x)for(t in e)y[t]=[y[t],e[t]];else w.always(e[w.status]);return this},abort:function(e){var t=e||T;return i&&i.abort(t),r(0,t),this}};if(g.promise(w).complete=m.add,w.success=w.done,w.error=w.fail,p.url=((e||p.url||Dn)+"").replace(Mn,"").replace(Bn,jn[1]+"//"),p.type=n.method||n.type||p.method||p.type,p.dataTypes=st.trim(p.dataType||"*").toLowerCase().match(lt)||[""],null==p.crossDomain&&(l=Pn.exec(p.url.toLowerCase()),p.crossDomain=!(!l||l[1]===jn[1]&&l[2]===jn[2]&&(l[3]||("http:"===l[1]?80:443))==(jn[3]||("http:"===jn[1]?80:443)))),p.data&&p.processData&&"string"!=typeof p.data&&(p.data=st.param(p.data,p.traditional)),L(Wn,p,n,w),2===x)return w;c=p.global,c&&0===st.active++&&st.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!On.test(p.type),o=p.url,p.hasContent||(p.data&&(o=p.url+=(Hn.test(o)?"&":"?")+p.data,delete p.data),p.cache===!1&&(p.url=qn.test(o)?o.replace(qn,"$1_="+Ln++):o+(Hn.test(o)?"&":"?")+"_="+Ln++)),p.ifModified&&(st.lastModified[o]&&w.setRequestHeader("If-Modified-Since",st.lastModified[o]),st.etag[o]&&w.setRequestHeader("If-None-Match",st.etag[o])),(p.data&&p.hasContent&&p.contentType!==!1||n.contentType)&&w.setRequestHeader("Content-Type",p.contentType),w.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+In+"; q=0.01":""):p.accepts["*"]);for(f in p.headers)w.setRequestHeader(f,p.headers[f]);if(p.beforeSend&&(p.beforeSend.call(d,w,p)===!1||2===x))return w.abort();T="abort";for(f in{success:1,error:1,complete:1})w[f](p[f]);if(i=L($n,p,n,w)){w.readyState=1,c&&h.trigger("ajaxSend",[w,p]),p.async&&p.timeout>0&&(u=setTimeout(function(){w.abort("timeout")},p.timeout));try{x=1,i.send(v,r)}catch(N){if(!(2>x))throw N;r(-1,N)}}else r(-1,"No Transport");return w},getScript:function(e,n){return st.get(e,t,n,"script")},getJSON:function(e,t,n){return st.get(e,t,n,"json")}}),st.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(e){return st.globalEval(e),e}}}),st.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),st.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=V.head||st("head")[0]||V.documentElement;return{send:function(t,i){n=V.createElement("script"),n.async=!0,e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,t){(t||!n.readyState||/loaded|complete/.test(n.readyState))&&(n.onload=n.onreadystatechange=null,n.parentNode&&n.parentNode.removeChild(n),n=null,t||i(200,"success"))},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(t,!0)}}}});var Xn=[],Un=/(=)\?(?=&|$)|\?\?/;st.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xn.pop()||st.expando+"_"+Ln++;return this[e]=!0,e}}),st.ajaxPrefilter("json jsonp",function(n,r,i){var o,a,s,u=n.jsonp!==!1&&(Un.test(n.url)?"url":"string"==typeof n.data&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Un.test(n.data)&&"data");return u||"jsonp"===n.dataTypes[0]?(o=n.jsonpCallback=st.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,u?n[u]=n[u].replace(Un,"$1"+o):n.jsonp!==!1&&(n.url+=(Hn.test(n.url)?"&":"?")+n.jsonp+"="+o),n.converters["script json"]=function(){return s||st.error(o+" was not called"),s[0]},n.dataTypes[0]="json",a=e[o],e[o]=function(){s=arguments},i.always(function(){e[o]=a,n[o]&&(n.jsonpCallback=r.jsonpCallback,Xn.push(o)),s&&st.isFunction(a)&&a(s[0]),s=a=t}),"script"):t});var Vn,Yn,Jn=0,Gn=e.ActiveXObject&&function(){var e;for(e in Vn)Vn[e](t,!0)};st.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&_()||F()}:_,Yn=st.ajaxSettings.xhr(),st.support.cors=!!Yn&&"withCredentials"in Yn,Yn=st.support.ajax=!!Yn,Yn&&st.ajaxTransport(function(n){if(!n.crossDomain||st.support.cors){var r;return{send:function(i,o){var a,s,u=n.xhr();if(n.username?u.open(n.type,n.url,n.async,n.username,n.password):u.open(n.type,n.url,n.async),n.xhrFields)for(s in n.xhrFields)u[s]=n.xhrFields[s];n.mimeType&&u.overrideMimeType&&u.overrideMimeType(n.mimeType),n.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");try{for(s in i)u.setRequestHeader(s,i[s])}catch(l){}u.send(n.hasContent&&n.data||null),r=function(e,i){var s,l,c,f,p;try{if(r&&(i||4===u.readyState))if(r=t,a&&(u.onreadystatechange=st.noop,Gn&&delete Vn[a]),i)4!==u.readyState&&u.abort();else{f={},s=u.status,p=u.responseXML,c=u.getAllResponseHeaders(),p&&p.documentElement&&(f.xml=p),"string"==typeof u.responseText&&(f.text=u.responseText);try{l=u.statusText}catch(d){l=""}s||!n.isLocal||n.crossDomain?1223===s&&(s=204):s=f.text?200:404}}catch(h){i||o(-1,h)}f&&o(s,l,f,c)},n.async?4===u.readyState?setTimeout(r):(a=++Jn,Gn&&(Vn||(Vn={},st(e).unload(Gn)),Vn[a]=r),u.onreadystatechange=r):r()},abort:function(){r&&r(t,!0)}}}});var Qn,Kn,Zn=/^(?:toggle|show|hide)$/,er=RegExp("^(?:([+-])=|)("+ut+")([a-z%]*)$","i"),tr=/queueHooks$/,nr=[W],rr={"*":[function(e,t){var n,r,i=this.createTween(e,t),o=er.exec(t),a=i.cur(),s=+a||0,u=1,l=20;if(o){if(n=+o[2],r=o[3]||(st.cssNumber[e]?"":"px"),"px"!==r&&s){s=st.css(i.elem,e,!0)||n||1;do u=u||".5",s/=u,st.style(i.elem,e,s+r);while(u!==(u=i.cur()/a)&&1!==u&&--l)}i.unit=r,i.start=s,i.end=o[1]?s+(o[1]+1)*n:n}return i}]};st.Animation=st.extend(P,{tweener:function(e,t){st.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");for(var n,r=0,i=e.length;i>r;r++)n=e[r],rr[n]=rr[n]||[],rr[n].unshift(t)},prefilter:function(e,t){t?nr.unshift(e):nr.push(e)}}),st.Tween=$,$.prototype={constructor:$,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||"swing",this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(st.cssNumber[n]?"":"px")},cur:function(){var e=$.propHooks[this.prop];return e&&e.get?e.get(this):$.propHooks._default.get(this)},run:function(e){var t,n=$.propHooks[this.prop];return this.pos=t=this.options.duration?st.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):$.propHooks._default.set(this),this}},$.prototype.init.prototype=$.prototype,$.propHooks={_default:{get:function(e){var t;return null==e.elem[e.prop]||e.elem.style&&null!=e.elem.style[e.prop]?(t=st.css(e.elem,e.prop,"auto"),t&&"auto"!==t?t:0):e.elem[e.prop]},set:function(e){st.fx.step[e.prop]?st.fx.step[e.prop](e):e.elem.style&&(null!=e.elem.style[st.cssProps[e.prop]]||st.cssHooks[e.prop])?st.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},$.propHooks.scrollTop=$.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},st.each(["toggle","show","hide"],function(e,t){var n=st.fn[t];st.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(I(t,!0),e,r,i)}}),st.fn.extend({fadeTo:function(e,t,n,r){return this.filter(w).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=st.isEmptyObject(e),o=st.speed(t,n,r),a=function(){var t=P(this,st.extend({},e),o);a.finish=function(){t.stop(!0)},(i||st._data(this,"finish"))&&t.stop(!0)};return a.finish=a,i||o.queue===!1?this.each(a):this.queue(o.queue,a)},stop:function(e,n,r){var i=function(e){var t=e.stop;delete e.stop,t(r)};return"string"!=typeof e&&(r=n,n=e,e=t),n&&e!==!1&&this.queue(e||"fx",[]),this.each(function(){var t=!0,n=null!=e&&e+"queueHooks",o=st.timers,a=st._data(this);if(n)a[n]&&a[n].stop&&i(a[n]);else for(n in a)a[n]&&a[n].stop&&tr.test(n)&&i(a[n]);for(n=o.length;n--;)o[n].elem!==this||null!=e&&o[n].queue!==e||(o[n].anim.stop(r),t=!1,o.splice(n,1));(t||!r)&&st.dequeue(this,e)})},finish:function(e){return e!==!1&&(e=e||"fx"),this.each(function(){var t,n=st._data(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=st.timers,a=r?r.length:0;for(n.finish=!0,st.queue(this,e,[]),i&&i.cur&&i.cur.finish&&i.cur.finish.call(this),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;a>t;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}}),st.each({slideDown:I("show"),slideUp:I("hide"),slideToggle:I("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){st.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),st.speed=function(e,t,n){var r=e&&"object"==typeof e?st.extend({},e):{complete:n||!n&&t||st.isFunction(e)&&e,duration:e,easing:n&&t||t&&!st.isFunction(t)&&t};return r.duration=st.fx.off?0:"number"==typeof r.duration?r.duration:r.duration in st.fx.speeds?st.fx.speeds[r.duration]:st.fx.speeds._default,(null==r.queue||r.queue===!0)&&(r.queue="fx"),r.old=r.complete,r.complete=function(){st.isFunction(r.old)&&r.old.call(this),r.queue&&st.dequeue(this,r.queue)},r},st.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2}},st.timers=[],st.fx=$.prototype.init,st.fx.tick=function(){var e,n=st.timers,r=0;for(Qn=st.now();n.length>r;r++)e=n[r],e()||n[r]!==e||n.splice(r--,1);n.length||st.fx.stop(),Qn=t},st.fx.timer=function(e){e()&&st.timers.push(e)&&st.fx.start()},st.fx.interval=13,st.fx.start=function(){Kn||(Kn=setInterval(st.fx.tick,st.fx.interval))},st.fx.stop=function(){clearInterval(Kn),Kn=null},st.fx.speeds={slow:600,fast:200,_default:400},st.fx.step={},st.expr&&st.expr.filters&&(st.expr.filters.animated=function(e){return st.grep(st.timers,function(t){return e===t.elem}).length}),st.fn.offset=function(e){if(arguments.length)return e===t?this:this.each(function(t){st.offset.setOffset(this,e,t)});var n,r,i={top:0,left:0},o=this[0],a=o&&o.ownerDocument;if(a)return n=a.documentElement,st.contains(n,o)?(o.getBoundingClientRect!==t&&(i=o.getBoundingClientRect()),r=z(a),{top:i.top+(r.pageYOffset||n.scrollTop)-(n.clientTop||0),left:i.left+(r.pageXOffset||n.scrollLeft)-(n.clientLeft||0)}):i},st.offset={setOffset:function(e,t,n){var r=st.css(e,"position");"static"===r&&(e.style.position="relative");var i,o,a=st(e),s=a.offset(),u=st.css(e,"top"),l=st.css(e,"left"),c=("absolute"===r||"fixed"===r)&&st.inArray("auto",[u,l])>-1,f={},p={};c?(p=a.position(),i=p.top,o=p.left):(i=parseFloat(u)||0,o=parseFloat(l)||0),st.isFunction(t)&&(t=t.call(e,n,s)),null!=t.top&&(f.top=t.top-s.top+i),null!=t.left&&(f.left=t.left-s.left+o),"using"in t?t.using.call(e,f):a.css(f)}},st.fn.extend({position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===st.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),st.nodeName(e[0],"html")||(n=e.offset()),n.top+=st.css(e[0],"borderTopWidth",!0),n.left+=st.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-st.css(r,"marginTop",!0),left:t.left-n.left-st.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent||V.documentElement;e&&!st.nodeName(e,"html")&&"static"===st.css(e,"position");)e=e.offsetParent;return e||V.documentElement})}}),st.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);st.fn[e]=function(i){return st.access(this,function(e,i,o){var a=z(e);return o===t?a?n in a?a[n]:a.document.documentElement[i]:e[i]:(a?a.scrollTo(r?st(a).scrollLeft():o,r?o:st(a).scrollTop()):e[i]=o,t)},e,i,arguments.length,null)}}),st.each({Height:"height",Width:"width"},function(e,n){st.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){st.fn[i]=function(i,o){var a=arguments.length&&(r||"boolean"!=typeof i),s=r||(i===!0||o===!0?"margin":"border");return st.access(this,function(n,r,i){var o;return st.isWindow(n)?n.document.documentElement["client"+e]:9===n.nodeType?(o=n.documentElement,Math.max(n.body["scroll"+e],o["scroll"+e],n.body["offset"+e],o["offset"+e],o["client"+e])):i===t?st.css(n,r,s):st.style(n,r,i,s)},n,a?i:t,a,null)}})}),e.jQuery=e.$=st,"function"==typeof define&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return st})})(window);
-//@ sourceMappingURL=jquery.min.map
\ No newline at end of file
--- a/light9/web/light9-collector-client.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-
-<dom-module id="light9-collector-client">
-  <template>
-    <iron-ajax url="/collector/attrs" method="PUT" id="put"></iron-ajax>
-    <span>{{status}} ([[sent]] sent)</span>
-  </template>
-  <script>
-   Polymer({
-       is: "light9-collector-client",
-       properties: {
-           status: {type: String, value: 'init'},
-           clientSession: {value: ""+Date.now()},
-           self: {type: Object, notify: true},
-           sent: {type: Number, value: 0},
-       },
-       ready: function() {
-           this.self = this;
-           var self = this;
-           this.lastSent = [];
-           
-           self.$.put.addEventListener(
-               'error', function() { self.status = 'err'; });
-           self.$.put.addEventListener(
-               'request', function() { self.status = 'send'; });
-           self.$.put.addEventListener(
-               'response', function() { self.status = 'ok'; });
-           // collector gives up on clients after 10sec
-           setInterval(self.ping.bind(self), 9000);
-           self.status = 'ready';
-       },               
-       ping: function() {
-           this.send(this.lastSent);
-       },
-       send: function(settings) {
-           this.$.put.body = JSON.stringify({
-               "settings": settings,
-               "client": window.location.href,
-               "clientSession": this.clientSession,
-               "sendTime": Date.now() / 1000
-           });
-           this.$.put.generateRequest();
-           this.sent += 1;
-           this.lastSent = settings.slice();
-       }
-   });
-  </script>
-</dom-module>
--- a/light9/web/light9-color-picker.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,106 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValueMap } from "lit";
-import { customElement, property, queryAsync, state } from "lit/decorators.js";
-import color from "onecolor";
-import { ClientCoord, pickerFloat } from "./floating_color_picker";
-export { Slider } from "@material/mwc-slider";
-
-const log = debug("control.color");
-type int8 = number;
-
-@customElement("light9-color-picker")
-export class Light9ColorPicker extends LitElement {
-  static styles = [
-    css`
-      :host {
-        position: relative;
-        display: flex;
-        align-items: center;
-        flex-wrap: wrap;
-        user-select: none;
-      }
-
-      #swatch {
-        display: inline-block;
-        width: 50px;
-        height: 30px;
-        margin-right: 3px;
-        border: 1px solid #333;
-      }
-
-      mwc-slider {
-        width: 160px;
-      }
-
-      #vee {
-        display: flex;
-        align-items: center;
-      }
-    `,
-  ];
-  render() {
-    return html`
-      <div id="swatch" style="background-color: ${this.color}; border-color: ${this.hueSatColor}" @mousedown=${this.startFloatingPick}></div>
-      <span id="vee"> V: <mwc-slider id="value" .value=${this.value} step="1" min="0" max="255" @input=${this.onVSliderChange}></mwc-slider> </span>
-    `;
-  }
-
-  // Selected color. Read/write. Equal to value*hueSatColor. Never null.
-  @property() color: string = "#000";
-
-  @state() hueSatColor: string = "#fff"; // always full value
-  @state() value: int8 = 0;
-
-  @queryAsync("#swatch") swatchEl!: Promise<HTMLElement>;
-
-  connectedCallback(): void {
-    super.connectedCallback();
-    pickerFloat.pageInit();
-  }
-  update(changedProperties: PropertyValueMap<this>) {
-    super.update(changedProperties);
-    if (changedProperties.has("color")) {
-      this.setColor(this.color);
-    }
-    if (changedProperties.has("value") || changedProperties.has("hueSatColor")) {
-      this.updateColorFromHSV();
-
-      this.dispatchEvent(new CustomEvent("input", { detail: { value: this.color } }));
-
-      this.swatchEl.then((sw) => {
-        sw.style.borderColor = this.hueSatColor;
-      });
-    }
-  }
-
-  private updateColorFromHSV() {
-    this.color = color(this.hueSatColor)
-      .value(this.value / 255)
-      .hex();
-  }
-
-  private onVSliderChange(ev: CustomEvent) {
-    this.value = ev.detail.value;
-  }
-
-  // for outside users of the component
-  setColor(col: string) {
-    if (col === null) throw new Error("col===null");
-    if (typeof col !== "string") throw new Error("typeof col=" + typeof col);
-    this.value = color(col).value() * 255;
-
-    // don't update this if only the value changed, or we desaturate
-    this.hueSatColor = color(col).value(1).hex();
-  }
-
-  private startFloatingPick(ev: MouseEvent) {
-    if (this.value < (20 as int8)) {
-      log("boost");
-      this.value = 255 as int8;
-      this.updateColorFromHSV();
-    }
-    pickerFloat.startPick(new ClientCoord(ev.clientX, ev.clientY), this.color, (hsc: string) => {
-      this.hueSatColor = hsc;
-    });
-  }
-}
--- a/light9/web/light9-color-picker_test.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>light9-color-picker test</title>
-    <meta charset="utf-8">
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <script src="/node_modules/mocha/mocha.js"></script>
-    <script src="/node_modules/chai/chai.js"></script>
-    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
-    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
-
-    <link rel="import" href="light9-color-picker.html">
-  </head>
-  <body>
-    <div id="mocha"><p><a href=".">Index</a></p></div>
-    <div id="messages"></div>
-    <div id="fixtures">
-      <dom-bind>
-        <template>
-          <light9-color-picker id="pick" color="{{color}}"></light9-color-picker>
-        </template>
-      </dom-bind>
-    </div>
-    
-    <script>
-     mocha.setup('bdd');
-     const assert = chai.assert;
-     
-     describe("RainbowCanvas", () => {
-       it("loads rainbow", (done) => {
-         const rc = new RainbowCanvas('/colorpick_rainbow_large.png', [400, 200]);
-         rc.onLoad(() => {
-           assert.equal(rc.colorAt([200, 100]), '#ff38eb');
-           done();
-         });
-       });
-     }); 
-     mocha.run();
-    </script>
-  </body>
-</html>
--- a/light9/web/light9-music.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,73 +0,0 @@
-log = debug('music')
-
-# port of light9/curvecalc/musicaccess.py
-coffeeElementSetup(class Music extends Polymer.Element
-  @is: "light9-music",
-  @getter_properties:
-    status: { type: String, notify: true }
-    statusTitle: { type: String, notify: true }
-    turboSign: { type: String, notify: true }
-    
-    duration: { type: Number, notify: true }
-    song: { type: String, notify: true }
-    # It does not yet work to write back to the playing/t
-    # properties. See seekPlayOrPause.
-    playing: { type: Boolean, notify: true }
-    t: { type: Number, notify: true }
-    
-  ready: ->
-    super.ready()
-    @turboUntil = 0
-    @poll()
-    setInterval(@estimateTimeLoop.bind(@), 30)
-
-  onError: (e) ->
-    req = @$.getTime.lastRequest
-    @status = "✘"
-    @statusTitle = "GET "+req.url+ " -> " + req.status + " " + req.statusText
-    setTimeout(@poll.bind(@), 2000)
-    
-  estimateTimeLoop: ->
-    if @playing
-      @t = @remoteT + (Date.now() - @remoteAsOfMs) / 1000
-    else
-      @t = @remoteT
-    
-  poll: ->
-    if not @$?.getTime?
-      setTimeout(@poll.bind(@), 200)
-      return
-    clearTimeout(@nextPoll) if @nextPoll
-    @$.getTime.generateRequest()
-    @status = "♫"
-    
-  onResponse: ->
-    @status = " "
-    @lastResponse = @$.getTime.lastResponse
-    now = Date.now()
-    if !@lastResponse.playing && @lastResponse.t != @remoteT
-      # likely seeking in another tool
-      @turboUntil = now + 1000
-    if now < @turboUntil
-      @turboSign = "⚡"
-      delay = 20
-    else
-      @turboSign = " "
-      delay = 700
-    
-    @nextPoll = setTimeout(@poll.bind(@), delay)
-    @duration = @lastResponse.duration
-    @playing = @lastResponse.playing
-    @song = @lastResponse.song
-
-    @remoteT = @lastResponse.t
-    @remoteAsOfMs = now
-
-  seekPlayOrPause: (t) ->
-    @$.seek.body = {t: t}
-    @$.seek.generateRequest()
-    
-    @turboUntil = Date.now() + 1000
-    @poll()
-)
-
--- a/light9/web/light9-music.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer-element.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-
-<!-- remote control of ascoltami -->
-<dom-module id="light9-music">
-  <template>
-    <style>
-     span {
-         font-family: monospace;
-         white-space: pre;
-         background: black;
-         color: white;
-         border: 1px solid #2782ad;
-         font-size: 12px;
-     }
-    </style>
-    <iron-ajax id="getTime" on-response="onResponse" on-error="onError" url="/ascoltami/time"></iron-ajax>
-    <iron-ajax id="seek"
-               method="POST"
-               url="/ascoltami/seekPlayOrPause"
-               content-type="application/json"></iron-ajax>
-    <span>[[status]][[turboSign]]</span>
-  </template>
-  <script src="coffee_element.js"></script>
-  <script src="light9-music.js"></script>
-</dom-module>
--- a/light9/web/light9-timeline-audio.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,114 +0,0 @@
-import { debug } from "debug";
-import { html, LitElement, PropertyValues } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { getTopGraph } from "./RdfdbSyncedGraph";
-import { SyncedGraph } from "./SyncedGraph";
-
-const log = debug("audio");
-
-export interface Zoom {
-  duration: number | null;
-  t1: number;
-  t2: number;
-}
-
-function nodeHasChanged(newVal?: NamedNode, oldVal?: NamedNode): boolean {
-  if (newVal === undefined && oldVal === undefined) {
-    return false;
-  }
-  if (newVal === undefined || oldVal === undefined) {
-    return true;
-  }
-  return !newVal.equals(oldVal);
-}
-
-// (potentially-zoomed) spectrogram view
-@customElement("light9-timeline-audio")
-export class Light9TimelineAudio extends LitElement {
-  graph!: SyncedGraph;
-  render() {
-    return html`
-      <style>
-        :host {
-          display: block;
-          /* shouldn't be seen, but black is correct for 'no
-         audio'. Maybe loading stripes would be better */
-          background: #202322;
-        }
-        div {
-          width: 100%;
-          height: 100%;
-          overflow: hidden;
-        }
-        img {
-          height: 100%;
-          position: relative;
-          transition: left 0.1s linear;
-        }
-      </style>
-      <div>
-        <img src=${this.imgSrc} style="width: ${this.imgWidth}; left: ${this.imgLeft}" />
-      </div>
-    `;
-  }
-  @property({ hasChanged: nodeHasChanged }) show!: NamedNode;
-  @property({ hasChanged: nodeHasChanged }) song!: NamedNode;
-  @property() zoom: Zoom = { duration: null, t1: 0, t2: 1 };
-  @state() imgSrc: string = "#";
-  @state() imgWidth: string = "0"; // css
-  @state() imgLeft: string = "0"; // css
-
-  constructor() {
-    super();
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-    });
-  }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("song") || changedProperties.has("show")) {
-      if (this.song && this.show) {
-        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song);
-      }
-    }
-    if (changedProperties.has("zoom")) {
-      this.imgWidth = this._imgWidth(this.zoom);
-      this.imgLeft = this._imgLeft(this.zoom);
-    }
-  }
-
-  setImgSrc() {
-    try {
-      var root = this.graph.stringValue(this.show, this.graph.Uri(":spectrogramUrlRoot"));
-    } catch (e) {
-      return;
-    }
-
-    try {
-      var filename = this.graph.stringValue(this.song, this.graph.Uri(":songFilename"));
-    } catch (e) {
-      return;
-    }
-
-    this.imgSrc = root + "/" + filename.replace(".wav", ".png").replace(".ogg", ".png");
-    log(`imgSrc ${this.imgSrc}`);
-  }
-
-  _imgWidth(zoom: Zoom): string {
-    if (!zoom.duration) {
-      return "100%";
-    }
-
-    return 100 / ((zoom.t2 - zoom.t1) / zoom.duration) + "%";
-  }
-  _imgLeft(zoom: Zoom): string {
-    if (!zoom.duration) {
-      return "0";
-    }
-
-    var percentPerSec = 100 / (zoom.t2 - zoom.t1);
-    return -percentPerSec * zoom.t1 + "%";
-  }
-}
--- a/light9/web/light9-vidref-live.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
-import { rounding }  from '/node_modules/significant-rounding/index.js';
-import './light9-vidref-replay.js';
-
-import debug from '/lib/debug/debug-build-es6.js';
-const log = debug('live');
-
-class Light9VidrefLive extends LitElement {
-    
-    static get properties() {
-        return {
-            description: { type: String },
-            enabled: { type: Boolean }
-        };
-    }
-    
-    constructor() {
-        super();
-        this.live = null;
-    }
-    
-    onEnabled() {
-        if (this.shadowRoot.querySelector('#enabled').checked) {
-            
-            this.live = reconnectingWebSocket(
-                'live', (msg) => {
-                    this.shadowRoot.querySelector('#live').src = 'data:image/jpeg;base64,' + msg.jpeg;
-                    this.description = msg.description;
-                });
-            this.shadowRoot.querySelector('#liveWidget').style.display = 'block';
-        } else {
-            if (this.live) {
-                this.live.disconnect();
-                this.live = null;
-                this.shadowRoot.querySelector('#liveWidget').style.display = 'none';
-            }
-        }
-    }
-
-    disconnectedCallback() {
-        log('bye');
-        //close socket
-        
-    }
-
-    static get styles() {
-        return css`
-        :host {
-            display: inline-block;
-        }
-#live {
-border: 4px solid orange;
-}
-        `;
-    }
-    
-    render() {
-        return html`
-  <label><input type="checkbox" id="enabled" ?checked="${this.enabled}" @change="${this.onEnabled}">Show live</label>
-  <div id="liveWidget" style="display: none"><img id="live" ></div>
-`;
-
-    }
-}
-customElements.define('light9-vidref-live', Light9VidrefLive);
--- a/light9/web/light9-vidref-replay-stack.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,183 +0,0 @@
-import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
-import debug from '/lib/debug/debug-build-es6.js';
-import _ from '/lib/underscore/underscore-min-es6.js';
-import { rounding }  from '/node_modules/significant-rounding/index.js';
-
-const log = debug('stack');
-
-class Light9VidrefReplayStack extends LitElement {
-    
-    static get properties() {
-        return {
-            songTime: { type: Number, attribute: false }, // from musicState.t but higher res
-            musicState: { type: Object, attribute: false },
-            players: { type: Array, attribute: false },
-            size: { type: String, attribute: true }
-        };
-    }
-
-    constructor() {
-        super();
-        this.musicState = {};
-    }
-
-    setVideoTimesFromSongTime() {
-        this.shadowRoot.querySelectorAll('light9-vidref-replay').forEach(
-            (r) => {
-                r.setVideoTimeFromSongTime(this.songTime, this.musicState.playing);
-            });
-    }
-    nudgeTime(dt) {
-        this.songTime += dt;
-        log('song now', this.songTime);
-    }
-    fineTime() {
-        if (this.musicState.playing) {
-            const sinceLastUpdate = (Date.now() - this.musicState.reportTime) / 1000;
-            this.songTime = sinceLastUpdate + this.musicState.tStart;
-        } else if (this.lastFineTimePlayingState)  {
-            this.songTime = this.musicState.t;
-        }
-        this.lastFineTimePlayingState = this.musicState.playing;
-        requestAnimationFrame(this.fineTime.bind(this));
-    }
-
-    updated(changedProperties) {
-        if (changedProperties.has('songTime')) {
-            this.setVideoTimesFromSongTime();
-        }
-    }
-
-    firstUpdated() {
-        this.songTimeRangeInput = this.shadowRoot.querySelector('#songTime');
-
-        const ws = reconnectingWebSocket('../ascoltami/time/stream',
-                                         this.receivedSongAndTime.bind(this));
-        reconnectingWebSocket('../vidref/time/stream', this.receivedRemoteScrubbedTime.bind(this));
-        // bug: upon connecting, clear this.song
-        this.fineTime();
-    }
-
-    receivedSongAndTime(msg) {
-        this.musicState = msg;
-        this.musicState.reportTime = Date.now();
-        this.musicState.tStart = this.musicState.t;            
-
-        this.songTimeRangeInput.max = this.musicState.duration;
-
-        if (this.musicState.song != this.song) {
-            this.song = this.musicState.song;
-            this.getReplayMapForSong(this.song);
-        }
-    }
-
-    receivedRemoteScrubbedTime(msg) {
-        this.songTime = msg.st;
-
-        // This doesn't work completely since it will keep getting
-        // updates from ascoltami slow updates.
-        if (msg.song != this.song) {
-            this.song = msg.song;
-            this.getReplayMapForSong(this.song);
-        }
-    }
-        
-    getReplayMapForSong(song) {
-        const u = new URL(window.location.href);
-        u.pathname = '/vidref/replayMap'
-        u.searchParams.set('song', song);
-        u.searchParams.set('maxClips', this.size == "small" ? '1' : '3');
-        fetch(u.toString()).then((resp) => {
-            if (resp.ok) {
-                resp.json().then((msg) => {
-                    this.players = msg.map(this.makeClipRow.bind(this));
-                    this.updateComplete.then(this.setupClipRows.bind(this, msg));
-                });
-            }
-        });          
-    }
-    
-    setupClipRows(msg) {
-        const nodes = this.shadowRoot.querySelectorAll('light9-vidref-replay');
-        nodes.forEach((node, i) => {
-            node.uri = msg[i].uri;
-            node.videoUrl = msg[i].videoUrl;
-            node.songToVideo = msg[i].songToVideo;
-        });
-        this.setVideoTimesFromSongTime();
-    }
-    
-    makeClipRow(clip) {
-        return html`<light9-vidref-replay @clips-changed="${this.onClipsChanged}" size="${this.size}"></light9-vidref-replay>`;
-    }
-    
-    onClipsChanged(ev) {
-        this.getReplayMapForSong(this.song);
-    }
-    
-    disconnectedCallback() {
-        log('bye');
-        //close socket
-    }
-
-    userMovedSongTime(ev) {
-        const st = this.songTimeRangeInput.valueAsNumber;
-        this.songTime = st;
-
-        fetch('/ascoltami/seekPlayOrPause', {
-            method: 'POST',
-            body: JSON.stringify({scrub: st}),
-        });
-    }
-
-    static get styles() {
-        return css`
-        :host {
-           display: inline-block;
-        }
-        #songTime {
-            width: 100%;
-        }
-        #clips {
-            display: flex;
-            flex-direction: column;
-        }
-        a {
-            color: rgb(97, 97, 255);
-        }
-        #songTime {
-            font-size: 27px;
-        }
-        light9-vidref-replay {
-            margin: 5px;
-        }
-        `;
-    }
-    
-    render() {
-        const songTimeRange = this.size != "small" ? html`<input id="songTime" type="range" 
-           .value="${this.songTime}" 
-           @input="${this.userMovedSongTime}" 
-           min="0" max="0" step=".001"></div>
-  <div><a href="${this.musicState.song}">${this.musicState.song}</a></div>` : '';
-
-
-        const globalCommands = this.size != 'small' ? html`
-  <div>
-    <button @click="${this.onClipsChanged}">Refresh clips for song</button>
-  </div>
-` : '';
-        return html`
-  <div>
-    ${songTimeRange}
-  <div id="songTime">showing song time ${rounding(this.songTime, 3)}</div>
-  <div>clips:</div>
-  <div id="clips">
-    ${this.players}
-  </div>
-  ${globalCommands}
-`;
-
-    }
-}
-customElements.define('light9-vidref-replay-stack', Light9VidrefReplayStack);
--- a/light9/web/light9-vidref-replay.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,142 +0,0 @@
-import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
-import debug from '/lib/debug/debug-build-es6.js';
-import _ from '/lib/underscore/underscore-min-es6.js';
-import { rounding }  from '/node_modules/significant-rounding/index.js';
-
-const log = debug('replay');
-
-class Light9VidrefReplay extends LitElement {
-    
-    static get properties() {
-        return {
-            uri: { type: String },
-            videoUrl: { type: String },
-            songToVideo: { type: Object },
-            videoTime: { type: Number },
-            outVideoCurrentTime: { type: Number },
-            timeErr: { type: Number },
-            playRate: { type: Number },
-            size: { type: String, attribute: true }
-        };
-    }
-
-    estimateRate() {
-        const n = this.songToVideo.length;
-        const x0 = Math.round(n * .3);
-        const x1 = Math.round(n * .6);
-        const pt0 = this.songToVideo[x0];
-        const pt1 = this.songToVideo[x1];
-        return (pt1[1] - pt0[1]) / (pt1[0] - pt0[0]);
-    }
-
-    setVideoTimeFromSongTime(songTime, isPlaying) {
-        if (!this.songToVideo || !this.outVideo || this.outVideo.readyState < 1) {
-            return;
-        }
-        const i = _.sortedIndex(this.songToVideo, [songTime],
-                                (row) => { return row[0]; });
-        if (i == 0 || i > this.songToVideo.length - 1) {
-            isPlaying = false;
-        }
-        
-        this.videoTime = this.songToVideo[Math.max(0, i - 1)][1];
-
-        this.outVideoCurrentTime = this.outVideo.currentTime;
-        
-        if (isPlaying) {
-            if (this.outVideo.paused) {
-                this.outVideo.play();
-                this.setRate(this.estimateRate());
-            }
-            const err = this.outVideo.currentTime - this.videoTime;
-            this.timeErr = err;
-
-            if (Math.abs(err) > window.thresh) {
-                this.outVideo.currentTime = this.videoTime;
-                const p = window.p;
-                if (err > 0) {
-                    this.setRate(this.playRate - err * p);
-                } else {
-                    this.setRate(this.playRate - err * p);
-                }
-            }
-        } else {
-            this.outVideo.pause();
-            this.outVideoCurrentTime = this.outVideo.currentTime = this.videoTime;
-            this.timeErr = 0;
-        }
-    }
-
-    setRate(r) {
-        this.playRate = Math.max(.1, Math.min(4, r));
-        this.outVideo.playbackRate = this.playRate;
-    }
-
-    firstUpdated() {
-        this.outVideo = this.shadowRoot.querySelector('#replay');
-        this.playRate = this.outVideo.playbackRate = 1.0;
-    }
-
-    onDelete() {
-        const u = new URL(window.location.href);
-        u.pathname = '/vidref/clips'
-        u.searchParams.set('uri', this.uri);
-        fetch(u.toString(), {method: 'DELETE'}).then((resp) => {
-            let event = new CustomEvent('clips-changed', {detail: {}});
-            this.dispatchEvent(event);
-        });
-    }
-
-    static get styles() {
-        return css`
-        :host {
-            border: 2px solid #46a79f;
-            display: flex;
-            flex-direction: column;
-        }
-        div {
-            padding: 5px;
-        }
-        .num {
-            display: inline-block;
-            width: 4em;
-            color: #29ffa0;
-        }
-        a {
-            color: rgb(97, 97, 255);
-        }
-        video {
-            width: 100%;
-        }
-        `;
-    }
-    
-    render() {
-        let details = '';
-        if (this.size != 'small') {
-            details = html`
-  <div>
-    take is <a href="${this.uri}">${this.uri}</a> 
-    (${Object.keys(this.songToVideo).length} frames)
-    <button @click="${this.onDelete}">Delete</button>
-  </div>
-  <!-- here, put a little canvas showing what coverage we have with the 
-       actual/goal time cursors -->
-  <div>
-    video time should be <span class="num">${this.videoTime} </span>
-    actual = <span class="num">${rounding(this.outVideoCurrentTime, 3, 3, true)}</span>, 
-    err = <span class="num">${rounding(this.timeErr, 3, 4, true)}</span>
-    rate = <span class="num">${rounding(this.playRate, 3, 3, true)}</span>
-  </div>
-            `;
-        }
-        return html`
-          <video id="replay" class="size-${this.size}" src="${this.videoUrl}"></video>
-          ${details}
-        `;
-
-    }
-}
-customElements.define('light9-vidref-replay', Light9VidrefReplay);
-window.thresh=.3
-window.p=.3
--- a/light9/web/live/Effect.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,277 +0,0 @@
-import debug from "debug";
-import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
-import { some } from "underscore";
-import { Patch } from "../patch";
-import { SyncedGraph } from "../SyncedGraph";
-import { shortShow } from "../show_specific";
-import { SubEvent } from "sub-events";
-
-// todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
-type Color = string;
-export type ControlValue = number | Color | NamedNode;
-
-const log = debug("effect");
-
-function isUri(x: Term | number | string): x is NamedNode {
-  return typeof x == "object" && x.termType == "NamedNode";
-}
-
-// todo: eliminate this. address the scaling when we actually scale
-// stuff, instead of making a mess of every setting
-function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
-  const U = graph.U();
-  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
-  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
-    return U(":value");
-  } else {
-    return U(":value");
-  }
-}
-
-// also see resourcedisplay's version of this
-function effContext(graph: SyncedGraph, uri: NamedNode): NamedNode {
-  return graph.Uri(uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
-}
-
-export function newEffect(graph: SyncedGraph): NamedNode {
-  // wrong- this should be our editor's scratch effect, promoted to a
-  // real one when you name it.
-  const uri = graph.nextNumberedResource(graph.Uri("http://light9.bigasterisk.com/effect/effect"));
-
-  const effect = new Effect(graph, uri);
-  const U = graph.U();
-  const ctx = effContext(graph, uri);
-  const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => graph.Quad(s, p, o, ctx);
-
-  const addQuads = [
-    quad(uri, U("rdf:type"), U(":Effect")),
-    quad(uri, U("rdfs:label"), graph.Literal(uri.value.replace(/.*\//, ""))),
-    quad(uri, U(":publishAttr"), U(":strength")),
-    quad(uri, U(":effectFunction"), U(":effectFunction/scale")),
-  ];
-  const patch = new Patch([], addQuads);
-  log("init new effect", patch);
-  graph.applyAndSendPatch(patch);
-
-  return effect.uri;
-}
-
-// effect settings data; r/w sync with the graph
-export class Effect {
-  // :effect1 a Effect; :setting ?eset . ?eset :effectAttr :deviceSettings; :value ?dset . ?dset :device ..
-  private eset?: NamedNode;
-  private dsettings: Array<{ dset: NamedNode; device: NamedNode; deviceAttr: NamedNode; value: ControlValue }> = [];
-
-  private ctxForEffect: NamedNode;
-  settingsChanged: SubEvent<void> = new SubEvent();
-
-  constructor(public graph: SyncedGraph, public uri: NamedNode) {
-    this.ctxForEffect = effContext(this.graph, this.uri);
-    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
-  }
-
-  private getExistingEset(): NamedNode | null {
-    const U = this.graph.U();
-    for (let eset of this.graph.objects(this.uri, U(":setting"))) {
-      if (this.graph.uriValue(eset as Quad_Subject, U(":effectAttr")).equals(U(":deviceSettings"))) {
-        return eset as NamedNode;
-      }
-    }
-    return null;
-  }
-  private getExistingEsetValueNode(): NamedNode | null {
-    const U = this.graph.U();
-    const eset = this.getExistingEset();
-    if (eset === null) return null;
-    try {
-      return this.graph.uriValue(eset, U(":value"));
-    } catch (e) {
-      return null;
-    }
-  }
-  private patchForANewEset(): { p: Patch; eset: NamedNode } {
-    const U = this.graph.U();
-    const eset = this.graph.nextNumberedResource(U(":e_set"));
-    return {
-      eset: eset,
-      p: new Patch(
-        [],
-        [
-          //
-          new Quad(this.uri, U(":setting"), eset, this.ctxForEffect),
-          new Quad(eset, U(":effectAttr"), U(":deviceSettings"), this.ctxForEffect),
-        ]
-      ),
-    };
-  }
-
-  private rebuildSettingsFromGraph(patch?: Patch) {
-    const U = this.graph.U();
-
-    log("syncFromGraph", this.uri);
-
-    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
-    const newSettings = [];
-
-    const deviceSettingsNode = this.getExistingEsetValueNode();
-    if (deviceSettingsNode !== null) {
-      for (let dset of Array.from(this.graph.objects(deviceSettingsNode, U(":setting"))) as NamedNode[]) {
-        //   // log(`  setting ${setting.value}`);
-        //   if (!isUri(dset)) throw new Error();
-        let value: ControlValue;
-        const device = this.graph.uriValue(dset, U(":device"));
-        const deviceAttr = this.graph.uriValue(dset, U(":deviceAttr"));
-
-        const pred = valuePred(this.graph, deviceAttr);
-        try {
-          value = this.graph.uriValue(dset, pred);
-          if (!(value as NamedNode).id.match(/^http/)) {
-            throw new Error("not uri");
-          }
-        } catch (error) {
-          try {
-            value = this.graph.floatValue(dset, pred);
-          } catch (error1) {
-            value = this.graph.stringValue(dset, pred); // this may find multi values and throw
-          }
-        }
-        //   log(`change: graph contains ${deviceAttr.value} ${value}`);
-
-        newSettings.push({ dset, device, deviceAttr, value });
-      }
-    }
-    this.dsettings = newSettings;
-    log(`settings is rebuilt to length ${this.dsettings.length}`);
-    this.settingsChanged.emit(); // maybe one emitter per dev+attr?
-    // this.onValuesChanged();
-  }
-
-  currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
-    for (let s of this.dsettings) {
-      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
-        return s.value;
-      }
-    }
-    return null;
-  }
-
-  // change this object now, but return the patch to be applied to the graph so it can be coalesced.
-  edit(device: NamedNode, deviceAttr: NamedNode, newValue: ControlValue | null): Patch {
-    log(`edit: value=${newValue}`);
-    let existingSetting: NamedNode | null = null;
-    let result = new Patch([], []);
-
-    for (let s of this.dsettings) {
-      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
-        if (existingSetting !== null) {
-          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
-          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.dset}`);
-          result = result.update(this.removeEffectSetting(s.dset));
-        }
-        existingSetting = s.dset;
-      }
-    }
-
-    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
-      if (existingSetting === null) {
-        result = result.update(this.addEffectSetting(device, deviceAttr, newValue));
-      } else {
-        result = result.update(this.patchExistingDevSetting(existingSetting, deviceAttr, newValue));
-      }
-    } else {
-      if (existingSetting !== null) {
-        result = result.update(this.removeEffectSetting(existingSetting));
-      }
-    }
-    return result;
-  }
-
-  shouldBeStored(deviceAttr: NamedNode, value: ControlValue | null): boolean {
-    // this is a bug for zoom=0, since collector will default it to
-    // stick at the last setting if we don't explicitly send the
-    // 0. rx/ry similar though not the exact same deal because of
-    // their remap.
-    return value != null && value !== 0 && value !== "#000000";
-  }
-
-  private addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
-    log("  _addEffectSetting", deviceAttr.value, value);
-    const U = (x: string) => this.graph.Uri(x);
-    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
-
-    let patch = new Patch([], []);
-
-    let eset = this.getExistingEset();
-    if (eset === null) {
-      const ret = this.patchForANewEset();
-      patch = patch.update(ret.p);
-      eset = ret.eset;
-    }
-
-    let dsValue;
-    try {
-      dsValue = this.graph.uriValue(eset, U(":value"));
-    } catch (e) {
-      dsValue = this.graph.nextNumberedResource(U(":ds_val"));
-      patch = patch.update(new Patch([], [quad(eset, U(":value"), dsValue)]));
-    }
-
-    const dset = this.graph.nextNumberedResource(this.uri.value + "_set");
-
-    patch = patch.update(
-      new Patch(
-        [],
-        [
-          quad(dsValue, U(":setting"), dset),
-          quad(dset, U(":device"), device),
-          quad(dset, U(":deviceAttr"), deviceAttr),
-          quad(dset, valuePred(this.graph, deviceAttr), this.nodeForValue(value)),
-        ]
-      )
-    );
-    log("  save", patch);
-    this.dsettings.push({ dset, device, deviceAttr, value });
-    return patch;
-  }
-
-  private patchExistingDevSetting(devSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
-    log("  patch existing", devSetting.value);
-    return this.graph.getObjectPatch(
-      devSetting, //
-      valuePred(this.graph, deviceAttr),
-      this.nodeForValue(value),
-      this.ctxForEffect
-    );
-  }
-
-  private removeEffectSetting(effectSetting: NamedNode): Patch {
-    const U = (x: string) => this.graph.Uri(x);
-    log("  _removeEffectSetting", effectSetting.value);
-
-    const eset = this.getExistingEset();
-    if (eset === null) throw "unexpected";
-    const dsValue = this.graph.uriValue(eset, U(":value"));
-    if (dsValue === null) throw "unexpected";
-    const toDel = [this.graph.Quad(dsValue, U(":setting"), effectSetting, this.ctxForEffect)];
-    for (let q of this.graph.subjectStatements(effectSetting)) {
-      toDel.push(q);
-    }
-    return new Patch(toDel, []);
-  }
-
-  clearAllSettings() {
-    for (let s of this.dsettings) {
-      this.graph.applyAndSendPatch(this.removeEffectSetting(s.dset));
-    }
-  }
-
-  private nodeForValue(value: ControlValue): NamedNode | Literal {
-    if (value === null) {
-      throw new Error("no value");
-    }
-    if (isUri(value)) {
-      return value;
-    }
-    return this.graph.prettyLiteral(value);
-  }
-}
--- a/light9/web/live/Light9AttrControl.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { Literal, NamedNode } from "n3";
-import { SubEvent } from "sub-events";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { ControlValue, Effect } from "./Effect";
-import { DeviceAttrRow } from "./Light9DeviceControl";
-export { Slider } from "@material/mwc-slider";
-export { Light9ColorPicker } from "../light9-color-picker";
-export { Light9Listbox } from "./Light9Listbox";
-const log = debug("settings.dev.attr");
-
-type DataTypeNames = "scalar" | "color" | "choice";
-const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
-
-// UI for one device attr (of any type).
-@customElement("light9-attr-control")
-export class Light9AttrControl extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      #colorControls {
-        display: flex;
-        align-items: center;
-      }
-      #colorControls > * {
-        margin: 0 3px;
-      }
-      :host {
-      }
-      mwc-slider {
-        width: 250px;
-      }
-    `,
-  ];
-
-  @property() deviceAttrRow: DeviceAttrRow | null = null;
-  @state() dataType: DataTypeNames = "scalar";
-  @property() effect: Effect | null = null;
-  @property() enableChange: boolean = false;
-  @property() value: ControlValue | null = null; // e.g. color string
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      if (this.deviceAttrRow === null) throw new Error();
-    });
-  }
-
-  connectedCallback(): void {
-    super.connectedCallback();
-    setTimeout(() => {
-      // only needed once per page layout
-      this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
-    }, 1);
-  }
-
-  render() {
-    if (this.deviceAttrRow === null) throw new Error();
-    if (this.dataType == "scalar") {
-      const v = this.value || 0;
-      return html`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `;
-    } else if ((this.dataType = "color")) {
-      const v = this.value || "#000";
-      return html`
-        <div id="colorControls">
-          <button @click=${this.goBlack}>0.0</button>
-          <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker>
-        </div>
-      `;
-    } else if (this.dataType == "choice") {
-      return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `;
-    }
-  }
-
-  updated(changedProperties: PropertyValues<this>) {
-    super.updated(changedProperties);
-
-    if (changedProperties.has("deviceAttrRow")) {
-      this.onDeviceAttrRowProperty();
-    }
-    if (changedProperties.has("effect")) {
-      this.onEffectProperty();
-    }
-    if (changedProperties.has("value")) {
-      this.onValueProperty();
-    }
-  }
-
-  private onValueProperty() {
-    if (this.deviceAttrRow === null) throw new Error();
-    if (!this.graph) {
-      log('ignoring value change- no graph yet')
-      return;
-    }
-    if (this.effect === null) {
-      this.value = null;
-    } else {
-      const p = this.effect.edit(
-        //
-        this.deviceAttrRow.device,
-        this.deviceAttrRow.uri,
-        this.value
-      );
-      if (!p.isEmpty()) {
-        log("Effect told us to graph.patch this:\n", p.dump());
-        this.graph.applyAndSendPatch(p);
-      }
-    }
-  }
-
-  private onEffectProperty() {
-    if (this.effect === null) {
-      log('no effect obj yet')
-      return;
-    }
-    // effect will read graph changes on its own, but emit an event when it does
-    this.effect.settingsChanged.subscribe(() => {
-      this.effectSettingsChanged();
-    });
-    this.effectSettingsChanged();
-  }
-
-  private effectSettingsChanged() {
-    // something in the settings graph is new
-    if (this.deviceAttrRow === null) throw new Error();
-    if (this.effect === null) throw new Error();
-    // log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
-    const v = this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
-    this.onGraphValueChanged(v);
-  }
-
-  private onDeviceAttrRowProperty() {
-    if (this.deviceAttrRow === null) throw new Error();
-    const d = this.deviceAttrRow.dataType;
-    if (d.equals(makeType("scalar"))) {
-      this.dataType = "scalar";
-    } else if (d.equals(makeType("color"))) {
-      this.dataType = "color";
-    } else if (d.equals(makeType("choice"))) {
-      this.dataType = "choice";
-    }
-  }
-
-  onValueInput(ev: CustomEvent) {
-    if (ev.detail === undefined) {
-      // not sure what this is, but it seems to be followed by good events
-      return;
-    }
-    // log(ev.type, ev.detail.value);
-    this.value = ev.detail.value;
-    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
-  }
-
-  onGraphValueChanged(v: ControlValue | null) {
-    if (this.deviceAttrRow === null) throw new Error();
-    // log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
-    // this.enableChange = false;
-    if (this.dataType == "scalar") {
-      if (v !== null) {
-        this.value = v;
-      } else {
-        this.value = 0;
-      }
-    } else if (this.dataType == "color") {
-      this.value = v;
-    }
-  }
-
-  goBlack() {
-    this.value = "#000000";
-  }
-
-  onChoice(value: any) {
-    // if (value != null) {
-    //   value = this.graph.Uri(value);
-    // } else {
-    //   value = null;
-    // }
-  }
-
-  onChange(value: any) {
-    // if (typeof value === "number" && isNaN(value)) {
-    //   return;
-    // } // let onChoice do it
-    // //log('change: control tells graph', @deviceAttrRow.uri.value, value)
-    // if (value === undefined) {
-    //   value = null;
-    // }
-  }
-}
--- a/light9/web/live/Light9DeviceControl.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,210 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { unique } from "underscore";
-import { Patch } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { Choice } from "./Light9Listbox";
-import { Light9AttrControl } from "./Light9AttrControl";
-import { Effect } from "./Effect";
-export { ResourceDisplay } from "../ResourceDisplay";
-export { Light9AttrControl };
-const log = debug("settings.dev");
-
-export interface DeviceAttrRow {
-  uri: NamedNode; //devattr
-  device: NamedNode;
-  attrClasses: string; // the css kind
-  dataType: NamedNode;
-  choices: Choice[];
-  // choiceSize: number;
-  // max: number;
-}
-
-// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
-@customElement("light9-device-control")
-export class Light9DeviceControl extends LitElement {
-  graph!: SyncedGraph;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-      }
-      .device {
-        border: 2px solid #151e2d;
-        margin: 4px;
-        padding: 1px;
-        background: #171717; /* deviceClass gradient added later */
-        break-inside: avoid-column;
-        width: 335px;
-      }
-      .deviceAttr {
-        border-top: 1px solid #272727;
-        padding-bottom: 2px;
-        display: flex;
-      }
-      .deviceAttr > span {
-      }
-      .deviceAttr > light9-live-control {
-        flex-grow: 1;
-      }
-      h2 {
-        font-size: 110%;
-        padding: 4px;
-        margin-top: 0;
-        margin-bottom: 0;
-      }
-      .device,
-      h2 {
-        border-top-right-radius: 15px;
-      }
-
-      #mainLabel {
-        font-size: 120%;
-        color: #9ab8fd;
-        text-decoration: initial;
-      }
-      .device.selected h2 {
-        outline: 3px solid #ffff0047;
-      }
-      .deviceAttr.selected {
-        background: #cada1829;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <div class="device ${this.devClasses}">
-        <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}>
-          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
-          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
-        </h2>
-
-        ${this.deviceAttrs.map(
-          (dattr: DeviceAttrRow) => html`
-            <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
-              <span>
-                attr
-                <resource-display minor .uri=${dattr.uri}></resource-display>
-              </span>
-              <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}>
-              </light9-attr-control>
-            </div>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  @property() uri!: NamedNode;
-  @property() effect!: Effect;
-
-  @property() devClasses: string = ""; // the css kind
-  @property() deviceAttrs: DeviceAttrRow[] = [];
-  @property() deviceClass: NamedNode | null = null;
-  @property() selectedAttrs: Set<NamedNode> = new Set();
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
-    });
-    this.selectedAttrs = new Set();
-  }
-
-  _bgStyle(deviceClass: NamedNode | null): string {
-    if (!deviceClass) return "";
-    let hash = 0;
-    const u = deviceClass.value;
-    for (let i = u.length - 10; i < u.length; i++) {
-      hash += u.charCodeAt(i);
-    }
-    const hue = (hash * 8) % 360;
-    const accent = `hsl(${hue}, 49%, 22%)`;
-    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
-  }
-
-  setDeviceSelected(isSel: any) {
-    this.devClasses = isSel ? "selected" : "";
-  }
-
-  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
-    if (isSel) {
-      this.selectedAttrs.add(devAttr);
-    } else {
-      this.selectedAttrs.delete(devAttr);
-    }
-  }
-
-  syncDeviceAttrsFromGraph(patch?: Patch) {
-    const U = this.graph.U();
-    if (patch && !patch.containsAnyPreds([U("rdf:type"), U(":deviceAttr"), U(":dataType"), U(":choice")])) {
-      return;
-    }
-    try {
-      this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
-    } catch (e) {
-      // what's likely is we're going through a graph reload and the graph
-      // is gone but the controls remain
-    }
-    this.deviceAttrs = [];
-    Array.from(unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(":deviceAttr"))))).map((da: NamedNode) =>
-      this.deviceAttrs.push(this.attrRow(da))
-    );
-    this.requestUpdate();
-  }
-
-  attrRow(devAttr: NamedNode): DeviceAttrRow {
-    let x: NamedNode;
-    const U = (x: string) => this.graph.Uri(x);
-    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
-    const daRow = {
-      uri: devAttr,
-      device: this.uri,
-      dataType,
-      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
-      choices: [] as Choice[],
-      choiceSize: 0,
-      max: 1,
-    };
-     if (dataType.equals(U(":choice"))) {
-      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
-      daRow.choices = (() => {
-        const result = [];
-        for (x of Array.from(choiceUris)) {
-          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
-        }
-        return result;
-      })();
-      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
-    } else {
-      daRow.max = 1;
-      if (dataType.equals(U(":angle"))) {
-        // varies
-        daRow.max = 1;
-      }
-    }
-    return daRow;
-  }
-
-  clear() {
-    // why can't we just set their values ? what's diff about
-    // the clear state, and should it be represented with `null` value?
-    throw new Error();
-    // Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
-  }
-
-  onClick(ev: any) {
-    log("click", this.uri);
-    // select, etc
-  }
-
-  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
-    log("attr click", this.uri, ev.model.dattr.uri);
-    // select
-  }
-}
--- a/light9/web/live/Light9DeviceSettings.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,153 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { sortBy, uniq } from "underscore";
-import { Patch } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { Effect, newEffect } from "./Effect";
-export { EditChoice } from "../EditChoice";
-export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
-const log = debug("settings");
-
-@customElement("light9-device-settings")
-export class Light9DeviceSettings extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      #preview {
-        width: 100%;
-      }
-      #deviceControls {
-        flex-grow: 1;
-        position: relative;
-        width: 100%;
-        overflow-y: auto;
-      }
-
-      light9-device-control > div {
-        break-inside: avoid-column;
-      }
-      light9-device-control {
-        vertical-align: top;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>effect DeviceSettings</h1>
-
-      <div id="save">
-        <div>
-          <button @click=${this.newEffect}>New effect</button>
-          <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice>
-          <button @click=${this.clearAll}>clear settings in this effect</button>
-        </div>
-      </div>
-
-      <div id="deviceControls">
-        ${this.devices.map(
-          (device: NamedNode) => html`
-            <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  devices: Array<NamedNode> = [];
-  @property() currentEffect: Effect | null = null;
-  okToWriteUrl: boolean = false;
-
-  constructor() {
-    super();
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this), "findDevices");
-      this.setEffectFromUrl();
-    });
-  }
-
-  onEffectChoice2(ev: CustomEvent) {
-    const uri = ev.detail.newValue as NamedNode;
-    this.setCurrentEffect(uri);
-  }
-  setCurrentEffect(uri: NamedNode) {
-    if (uri === null) {
-      this.currentEffect = null;
-      // todo: wipe the UI settings
-    } else {
-      this.currentEffect = new Effect(this.graph, uri);
-    }
-  }
-
-  updated(changedProperties: PropertyValues<this>) {
-    log("ctls udpated", changedProperties);
-    if (changedProperties.has("currentEffect")) {
-      log(`effectChoice to ${this.currentEffect?.uri?.value}`);
-      this.writeToUrl(this.currentEffect?.uri);
-    }
-    // this.graphToControls?.debugDump();
-  }
-
-  // Note that this doesn't fetch setting values, so it only should get rerun
-  // upon (rarer) changes to the devices etc. todo: make that be true
-  private compile(patch?: Patch) {
-    const U = this.graph.U();
-    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
-    //   return;
-    // }
-
-    this.devices = [];
-    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
-    log(`found ${classes.length} device classes`);
-    uniq(sortBy(classes, "value"), true).forEach((dc) => {
-      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        this.devices.push(dev as NamedNode);
-      });
-    });
-    this.requestUpdate();
-  }
-
-  setEffectFromUrl() {
-    // not a continuous bidi link between url and effect; it only reads
-    // the url when the page loads.
-    const effect = new URL(window.location.href).searchParams.get("effect");
-    if (effect != null) {
-      this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
-    }
-    this.okToWriteUrl = true;
-  }
-
-  writeToUrl(effect: NamedNode | undefined) {
-    const effectStr = effect ? this.graph.shorten(effect) : "";
-    if (!this.okToWriteUrl) {
-      return;
-    }
-    const u = new URL(window.location.href);
-    if ((u.searchParams.get("effect") || "") === effectStr) {
-      return;
-    }
-    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
-    window.history.replaceState({}, "", u.href);
-    log("wrote new url", u.href);
-  }
-
-  newEffect() {
-    this.setCurrentEffect(newEffect(this.graph));
-  }
-
-  clearAll() {
-    this.currentEffect?.clearAllSettings()
-  }
-}
--- a/light9/web/live/Light9Listbox.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,76 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-const log = debug("listbox");
-export type Choice = { uri: string; label: string };
-
-@customElement("light9-listbox")
-export class Light9Listbox extends LitElement {
-  static styles = [
-    css`
-      paper-listbox {
-        --paper-listbox-background-color: none;
-        --paper-listbox-color: white;
-        --paper-listbox: {
-          /* measure biggest item? use flex for columns? */
-          column-width: 9em;
-        }
-      }
-      paper-item {
-        --paper-item-min-height: 0;
-        --paper-item: {
-          display: block;
-          border: 1px outset #0f440f;
-          margin: 0 1px 5px 0;
-          background: #0b1d0b;
-        }
-      }
-      paper-item.iron-selected {
-        background: #7b7b4a;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
-        <paper-item on-focus="selectOnFocus">None</paper-item>
-        <template is="dom-repeat" items="{{choices}}">
-          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
-        </template>
-      </paper-listbox>
-    `;
-  }
-  @property() choices: Array<Choice> = [];
-  @property() value: String | null = null;
-
-  constructor() {
-    super();
-  }
-  selectOnFocus(ev) {
-    if (ev.target.uri === undefined) {
-      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
-      //this.clear();
-      //return;
-    }
-    this.value = ev.target.uri;
-  }
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("value")) {
-      if (this.value === null) {
-        this.clear();
-      }
-    }
-  }
-  onValue(value: String | null) {
-    if (value === null) {
-      this.clear();
-    }
-  }
-  clear() {
-    this.querySelectorAll("paper-item").forEach(function (item) {
-      item.blur();
-    });
-    this.value = null;
-  }
-}
--- a/light9/web/live/README.md	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-This is an editor of :Effect resources, which have graphs like this:
-
-    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
-    rdfs:label "effect43";
-    :publishAttr :strength;
-    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
-
-    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
-
-# Objects
-
-SyncedGraph has the true data.
-
-Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
-sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
-(_probably_ at their zero position, but this is not always true) have a null value.
-
-GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
-
-We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
-:setting for that device+attribute
-
-SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
-the Effect would live as long as the page too)
--- a/light9/web/live/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>device settings</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="./Light9DeviceSettings"></script>
-  </head>
-  <body>
-    <style>
-      body,
-      html {
-        margin: 0;
-      }
-      light9-device-settings {
-        position: absolute;
-        left: 2px;
-        top: 2px;
-        right: 8px;
-        bottom: 0;
-      }
-    </style>
-    <light9-device-settings></light9-device-settings>
-  </body>
-</html>
--- a/light9/web/metrics/ServiceButtonRow.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-import { LitElement, html, css } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export { StatsLine } from "./StatsLine";
-
-@customElement("service-button-row")
-export class ServiceButtonRow extends LitElement {
-  @property() name: string = "?";
-  @property({ type:Boolean, attribute: "metrics" }) hasMetrics: boolean = false;
-  static styles = [
-    css`
-      :host {
-        padding-bottom: 10px;
-        border-bottom: 1px solid #333;
-      }
-      a {
-        color: #7d7dec;
-      }
-      div {
-        display: flex;
-        justify-content: space-between;
-        padding: 2px 3px;
-      }
-      .left {
-        display: inline-block;
-        margin-right: 3px;
-        flex-grow: 1;
-        min-width: 9em;
-      }
-      .window {
-      }
-      .serviceGrid > td {
-        border: 5px solid red;
-        display: inline-block;
-      }
-      .big {
-        font-size: 120%;
-        display: inline-block;
-        padding: 10px 0;
-      }
-
-      :host > div {
-        display: inline-block;
-        vertical-align: top;
-      }
-      :host > div:nth-child(2) {
-        width: 9em;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <div>
-        <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div>
-        <div class="window"><button @click="${this.click}">window</button></div>
-        ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""}
-      </div>
-
-      ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""}
-      `;
-  }
-
-  click() {
-    window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0");
-  }
-}
--- a/light9/web/metrics/StatsLine.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,301 +0,0 @@
-import { css, html, LitElement, TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export { StatsProcess } from "./StatsProcess";
-import parsePrometheusTextFormat from "parse-prometheus-text-format";
-import debug from "debug";
-import { clamp } from "../floating_color_picker";
-const log = debug("home");
-
-interface Value {
-  labels: { string: string };
-  value?: string;
-  count?: number;
-  sum?: number;
-  buckets?: { [value: string]: string };
-}
-interface Metric {
-  name: string;
-  help: string;
-  type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED";
-  metrics: Value[];
-}
-type Metrics = Metric[];
-
-function nonBoring(m: Metric) {
-  return (
-    !m.name.endsWith("_created") && //
-    !m.name.startsWith("python_gc_") &&
-    m.name != "python_info" &&
-    m.name != "process_max_fds" &&
-    m.name != "process_virtual_memory_bytes" &&
-    m.name != "process_resident_memory_bytes" &&
-    m.name != "process_start_time_seconds" &&
-    m.name != "process_cpu_seconds_total"
-  );
-}
-
-@customElement("stats-line")
-export class StatsLine extends LitElement {
-  @property() name = "?";
-  @property() stats: Metrics = [];
-
-  prevCpuNow = 0;
-  prevCpuTotal = 0;
-  @property() cpu = 0;
-  @property() mem = 0;
-
-  updated(changedProperties: any) {
-    changedProperties.forEach((oldValue: any, propName: string) => {
-      if (propName == "name") {
-        const reload = () => {
-          fetch("/service/" + this.name + "/metrics").then((resp) => {
-            if (resp.ok) {
-              resp
-                .text()
-                .then((msg) => {
-                  this.stats = parsePrometheusTextFormat(msg) as Metrics;
-                  this.extractProcessStats(this.stats);
-                  setTimeout(reload, 1000);
-                })
-                .catch((err) => {
-                  log(`${this.name} failing`, err);
-                  setTimeout(reload, 1000);
-                });
-            } else {
-              if (resp.status == 502) {
-                setTimeout(reload, 5000);
-              }
-              // 404: likely not mapped to a responding server
-            }
-          });
-        };
-        reload();
-      }
-    });
-  }
-  extractProcessStats(stats: Metrics) {
-    stats.forEach((row: Metric) => {
-      if (row.name == "process_resident_memory_bytes") {
-        this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024;
-      }
-      if (row.name == "process_cpu_seconds_total") {
-        const now = Date.now() / 1000;
-        const cpuSecondsTotal = parseFloat(row.metrics[0].value!);
-        this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow);
-        this.prevCpuTotal = cpuSecondsTotal;
-        this.prevCpuNow = now;
-      }
-    });
-  }
-
-  static styles = [
-    css`
-      :host {
-        border: 2px solid #46a79f;
-        display: inline-block;
-      }
-      table {
-        border-collapse: collapse;
-        background: #000;
-        color: #ccc;
-        font-family: sans-serif;
-      }
-      th,
-      td {
-        outline: 1px solid #000;
-      }
-      th {
-        padding: 2px 4px;
-        background: #2f2f2f;
-        text-align: left;
-      }
-      td {
-        padding: 0;
-        vertical-align: top;
-        text-align: center;
-      }
-      td.val {
-        padding: 2px 4px;
-        background: #3b5651;
-      }
-      .recents {
-        display: flex;
-        align-items: flex-end;
-        height: 30px;
-      }
-      .recents > div {
-        width: 3px;
-        background: red;
-        border-right: 1px solid black;
-      }
-      .bigInt {
-        min-width: 6em;
-      }
-    `,
-  ];
-
-  tdWrap(content: TemplateResult): TemplateResult {
-    return html`<td>${content}</td>`;
-  }
-
-  recents(d: any, path: string[]): TemplateResult {
-    const hi = Math.max.apply(null, d.recents);
-    const scl = 30 / hi;
-
-    const bar = (y: number) => {
-      let color;
-      if (y < d.average) {
-        color = "#6a6aff";
-      } else {
-        color = "#d09e4c";
-      }
-      return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`;
-    };
-    return html`<td>
-      <div class="recents">${d.recents.map(bar)}</div>
-      <div>avg=${d.average.toPrecision(3)}</div>
-    </td>`;
-  }
-
-  table(d: Metrics, path: string[]): TemplateResult {
-    const byName = new Map<string, Metric>();
-    d.forEach((row) => {
-      byName.set(row.name, row);
-    });
-    let cols = d.map((row) => row.name);
-    cols.sort();
-
-    if (path.length == 0) {
-      ["webServer", "process"].forEach((earlyKey) => {
-        let i = cols.indexOf(earlyKey);
-        if (i != -1) {
-          cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1));
-        }
-      });
-    }
-
-    const th = (col: string): TemplateResult => {
-      return html`<th>${col}</th>`;
-    };
-    const td = (col: string): TemplateResult => {
-      const cell = byName.get(col)!;
-      return html`${this.drawLevel(cell, path.concat(col))}`;
-    };
-    return html` <table>
-      <tr>
-        ${cols.map(th)}
-      </tr>
-      <tr>
-        ${cols.map(td)}
-      </tr>
-    </table>`;
-  }
-
-  drawLevel(d: Metric, path: string[]) {
-    return html`[NEW ${JSON.stringify(d)} ${path}]`;
-  }
-
-  valueDisplay(m: Metric, v: Value): TemplateResult {
-    if (m.type == "GAUGE") {
-      return html`${v.value}`;
-    } else if (m.type == "COUNTER") {
-      return html`${v.value}`;
-    } else if (m.type == "HISTOGRAM") {
-      return this.histoDisplay(v.buckets!);
-    } else if (m.type == "UNTYPED") {
-      return html`${v.value}`;
-    } else if (m.type == "SUMMARY") {
-      if (!v.count) {
-        return html`err: summary without count`;
-      }
-      return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`;
-    } else {
-      throw m.type;
-    }
-  }
-
-  private histoDisplay(b: { [value: string]: string }) {
-    const lines: TemplateResult[] = [];
-    let firstLevel;
-    let lastLevel;
-    let prev = 0;
-
-    let maxDelta = 0;
-    for (let level in b) {
-      if (firstLevel === undefined) firstLevel = level;
-      lastLevel = level;
-      let count = parseFloat(b[level]);
-      let delta = count - prev;
-      prev = count;
-      if (delta > maxDelta) maxDelta = delta;
-    }
-    prev = 0;
-    const maxBarH = 30;
-    for (let level in b) {
-      let count = parseFloat(b[level]);
-      let delta = count - prev;
-      prev = count;
-      let levelf = parseFloat(level);
-      const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH);
-      lines.push(
-        html`<div
-          title="bucket=${level} count=${count}"
-          style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block"
-        ></div>`
-      );
-    }
-    return html`${firstLevel} ${lines} ${lastLevel}`;
-  }
-
-  tightLabel(labs: { [key: string]: string }): string {
-    const d: { [key: string]: string } = {};
-    for (let k in labs) {
-      if (k == "app_name") continue;
-      if (k == "output") continue;
-      if (k == "status_code" && labs[k] == "200") continue;
-      d[k] = labs[k];
-    }
-    const ret = JSON.stringify(d);
-    return ret == "{}" ? "" : ret;
-  }
-  tightMetric(name: string): string {
-    return name.replace("starlette", "⭐").replace("_request", "_req").replace("_duration", "_dur").replace("_seconds", "_s");
-  }
-  render() {
-    const now = Date.now() / 1000;
-
-    const displayedStats = this.stats.filter(nonBoring);
-    return html`
-      <div>
-        <table>
-          ${displayedStats.map(
-            (row, rowNum) => html`
-              <tr>
-                <th>${this.tightMetric(row.name)}</th>
-                <td>
-                  <table>
-                    ${row.metrics.map(
-                      (v) => html`
-                        <tr>
-                          <td>${this.tightLabel(v.labels)}</td>
-                          <td>${this.valueDisplay(row, v)}</td>
-                        </tr>
-                      `
-                    )}
-                  </table>
-                </td>
-                ${rowNum == 0
-                  ? html`
-                      <td rowspan="${displayedStats.length}">
-                        <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process>
-                      </td>
-                    `
-                  : ""}
-              </tr>
-            `
-          )}
-        </table>
-      </div>
-    `;
-  }
-}
--- a/light9/web/metrics/StatsProcess.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-import { LitElement, html, css } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import debug from "debug";
-
-const log = debug("process");
-
-const remap = (x: number, lo: number, hi: number, outLo: number, outHi: number) => {
-  return outLo + (outHi - outLo) * Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
-};
-
-@customElement("stats-process")
-export class StatsProcess extends LitElement {
-  // inspired by https://codepen.io/qiruiyin/pen/qOopQx
-  @property() cpu = 0; // process_cpu_seconds_total
-  @property() mem = 0; // process_resident_memory_bytes
-
-  w = 64;
-  h = 64;
-  revs = 0;
-  prev = 0;
-  canvas?: HTMLCanvasElement;
-  ctx?: CanvasRenderingContext2D;
-  connectedCallback() {
-    super.connectedCallback();
-    this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
-    this.prev = Date.now() / 1000;
-
-    var animate = () => {
-      requestAnimationFrame(animate);
-      this.redraw();
-    };
-    animate();
-  }
-  initCanvas(canvas: HTMLCanvasElement) {
-    if (!canvas) {
-      return;
-    }
-    this.canvas = canvas;
-    this.ctx = this.canvas.getContext("2d")!;
-
-    this.canvas.width = this.w;
-    this.canvas.height = this.h;
-  }
-  redraw() {
-    if (!this.ctx) {
-      this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
-    }
-    if (!this.ctx) return;
-
-    this.canvas!.setAttribute("title", 
-    `cpu ${new Number(this.cpu).toPrecision(3)}% mem ${new Number(this.mem).toPrecision(3)}MB`);
-
-    const now = Date.now() / 1000;
-    const ctx = this.ctx;
-    ctx.beginPath();
-    // wrong type of fade- never goes to 0
-    ctx.fillStyle = "#00000003";
-    ctx.fillRect(0, 0, this.w, this.h);
-    const dt = now - this.prev;
-    this.prev = now;
-
-    const size = remap(this.mem.valueOf() / 1024 / 1024, /*in*/ 20, 80, /*out*/ 3, 30);
-    this.revs += dt * remap(this.cpu.valueOf(), /*in*/ 0, 100, /*out*/ 4, 120);
-    const rad = remap(size, /*in*/ 3, 30, /*out*/ 14, 5);
-
-    var x = this.w / 2 + rad * Math.cos(this.revs / 6.28),
-      y = this.h / 2 + rad * Math.sin(this.revs / 6.28);
-
-    ctx.save();
-    ctx.beginPath();
-    ctx.fillStyle = "hsl(194, 100%, 42%)";
-    ctx.arc(x, y, size, 0, 2 * Math.PI);
-    ctx.fill();
-    ctx.restore();
-  }
-
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        width: 64px;
-        height: 64px;
-      }
-    `,
-  ];
-
-  render() {
-    return html`<canvas></canvas>`;
-  }
-}
--- a/light9/web/metrics/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>metrics</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="./ServiceButtonRow.ts"></script>
-  </head>
-  <body>
-    <div style="display: grid">
-      <service-button-row name="ascoltami" metrics="1"></service-button-row>
-      <service-button-row name="fade"></service-button-row>
-      <service-button-row name="effects"></service-button-row>
-      <service-button-row name="effectSequencer" metrics="1"></service-button-row>
-      <service-button-row name="collector" metrics="1"></service-button-row>
-      <service-button-row name="rdfdb" metrics="1"></service-button-row>
-    </div>
-  </body>
-</html>
--- a/light9/web/mime.types	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-
-types {
-
-    image/svg+xml                         svg;
-    application/x-compressed              tgz tar.gz;
-    application/x-gzip                    gz;
-    audio/x-vorbis                        ogg;
-    video/ogg                             ogv;
-    video/mp4 mp4;
-    video/webm webm;
-
-
-    text/html                             html htm shtml;
-    text/css                              css;
-    text/xml                              xml rss;
-    image/gif                             gif;
-    image/jpeg                            jpeg jpg;
-    application/json                      json;
-    application/x-javascript              js;
-    application/atom+xml                  atom;
-
-    application/rdf+xml                   rdf;
-    text/rdf+n3                           n3;
-
-    text/mathml                           mml;
-    text/plain                            txt;
-    text/vnd.sun.j2me.app-descriptor      jad;
-    text/vnd.wap.wml                      wml;
-    text/x-component                      htc;
-
-    image/png                             png;
-    image/tiff                            tif tiff;
-    image/vnd.wap.wbmp                    wbmp;
-    image/x-icon                          ico;
-    image/x-jng                           jng;
-    image/x-ms-bmp                        bmp;
-
-    application/java-archive              jar war ear;
-    application/mac-binhex40              hqx;
-    application/msword                    doc;
-    application/pdf                       pdf;
-    application/postscript                ps eps ai;
-    application/rtf                       rtf;
-    application/vnd.ms-excel              xls;
-    application/vnd.ms-powerpoint         ppt;
-    application/vnd.wap.wmlc              wmlc;
-    application/xhtml+xml         xhtml;
-    application/x-cocoa                   cco;
-    application/x-java-archive-diff       jardiff;
-    application/x-java-jnlp-file          jnlp;
-    application/x-makeself                run;
-    application/x-perl                    pl pm;
-    application/x-pilot                   prc pdb;
-    application/x-rar-compressed          rar;
-    application/x-redhat-package-manager  rpm;
-    application/x-sea                     sea;
-    application/x-shockwave-flash         swf;
-    application/x-stuffit                 sit;
-    application/x-tcl                     tcl tk;
-    application/x-x509-ca-cert            der pem crt;
-    application/x-xpinstall               xpi;
-    application/zip                       zip;
-
-    application/octet-stream              bin exe dll;
-    application/octet-stream              deb;
-    application/octet-stream              dmg;
-    application/octet-stream              eot;
-    application/octet-stream              iso img;
-    application/octet-stream              msi msp msm;
-
-    audio/midi                            mid midi kar;
-    audio/mpeg                            mp3;
-    audio/x-realaudio                     ra;
-
-    video/3gpp                            3gpp 3gp;
-    video/mpeg                            mpeg mpg;
-    video/quicktime                       mov;
-    video/x-flv                           flv;
-    video/x-mng                           mng;
-    video/x-ms-asf                        asx asf;
-    video/x-ms-wmv                        wmv;
-    video/x-msvideo                       avi;
-}
Binary file light9/web/paint/bg1.jpg has changed
Binary file light9/web/paint/bg2.jpg has changed
Binary file light9/web/paint/bg3.jpg has changed
--- a/light9/web/paint/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>paint</title>
-    <meta charset="utf-8" />
-    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
-    <link rel="stylesheet"  href="/style.css">
-    <link rel="import" href="paint-elements.html">
-    <meta name="viewport" content="user-scalable=no, width=1000, initial-scale=.5" />
-    <style>
-     body {
-         position: relative;
-         height: 500px;
-     }
-    </style>
-  </head>
-  <body>
-    <light9-paint style="position: absolute; left: 0px; top: 0px; width: 1000px; height: 500px">
-    </light9-paint>
-
-
-  </body>
-</html>
--- a/light9/web/paint/paint-elements.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,232 +0,0 @@
-log = debug('paint')
-debug.enable('paint')
-
-class Painting
-  constructor: (@svg) ->
-    @strokes = []
-
-  setSize: (@size) ->
-
-  startStroke: (pos, color) ->
-    stroke = new Stroke(pos, color, @size)
-    stroke.appendElem(@svg)
-    @strokes.push(stroke)
-    return stroke
-
-  hover: (pos) ->
-    @clear()
-    s = @startStroke(pos, '#ffffff', @size)
-    r = .02
-    steps = 5
-    for ang in [0..steps]
-      ang = 6.28 * ang / steps
-      s.move([pos[0] + r * Math.sin(ang), pos[1] + 1.5 * r * Math.cos(ang)])
-
-  getDoc: ->
-    {strokes: @strokes}
-
-  clear: ->
-    s.removeElem() for s in @strokes
-    @strokes = []
-
-class Stroke
-  constructor: (pos, @color, @size) ->
-    @path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
-    @path.setAttributeNS(null, 'd', "M #{pos[0]*@size[0]} #{pos[1]*@size[1]}")
-    @pts = [pos]
-    @lastPos = pos
-
-  appendElem: (parent) ->
-    parent.appendChild(@path)
-
-  removeElem: ->
-    @path.remove()
-    
-  move: (pos) ->
-    if Math.hypot(pos[0] - @lastPos[0], pos[1] - @lastPos[1]) < .02
-      return
-    @path.attributes.d.value += " L #{pos[0]*@size[0]} #{pos[1]*@size[1]}"
-    @pts.push(pos)
-    @lastPos = pos
-
-  finish: () ->
-
-Polymer
-  is: "light9-paint-canvas"
-  behaviors: [ Polymer.IronResizableBehavior ]
-  listeners: 'iron-resize': 'onResize'
-  properties: {
-    bg: { type: String },
-    tool: { type: String, value: 'hover' },
-    painting: { type: Object } # output
-  }
-  ready: ->
-    @painting = new Painting(@$.paint)
-    @onResize()
-    @$.paint.addEventListener('mousedown', @onDown.bind(@))
-    @$.paint.addEventListener('mousemove', @onMove.bind(@))
-    @$.paint.addEventListener('mouseup', @onUp.bind(@))
-    @$.paint.addEventListener('touchstart', @onDown.bind(@))
-    @$.paint.addEventListener('touchmove', @onMove.bind(@))
-    @$.paint.addEventListener('touchend', @onUp.bind(@))
-
-    @hover = _.throttle((ev) =>
-          @painting.hover(@evPos(ev))
-          @scopeSubtree(@$.paint)
-          @fire('paintingChanged', @painting)
-        , 100)
-
-  evPos: (ev) ->
-    px = (if ev.touches?.length? then [Math.round(ev.touches[0].clientX),
-                                       Math.round(ev.touches[0].clientY)] else [ev.x, ev.y])
-    return [px[0] / @size[0], px[1] / @size[1]]
-
-  onClear: () ->
-    @painting.clear()
-    @fire('paintingChanged', @painting)
-    
-  onDown: (ev) ->
-    switch @tool
-      when "hover"
-        @onMove(ev)
-      when "paint"
-        # if it's on an existing one, do selection
-        @currentStroke = @painting.startStroke(@evPos(ev), '#aaaaaa')
-    @scopeSubtree(@$.paint)
-
-  onMove: (ev) ->
-    switch @tool
-      when "hover"
-        @hover(ev)
-
-      when "paint"
-        # ..or move selection
-        return unless @currentStroke
-        @currentStroke.move(@evPos(ev))
-
-  onUp: (ev) ->
-    return unless @currentStroke
-    @currentStroke.finish()
-    @currentStroke = null
-    
-    @notifyPath('painting.strokes.length') # not working
-    @fire('paintingChanged', @painting)
-
-  onResize: (ev) ->
-    @size = [@$.parent.offsetWidth, @$.parent.offsetHeight]
-    @$.paint.attributes.viewBox.value = "0 0 #{@size[0]} #{@size[1]}"
-    @painting.setSize(@size)
-
-
-Polymer
-  is: "light9-simulation"
-  properties: {
-    graph: { type: Object }
-    layers: { type: Object }
-    solution: { type: Object }
-  }
-  listeners: [
-    "onLayers(layers)"
-  ]
-  ready: ->
-    null
-  onLayers: (layers) ->
-    log('upd', layers)
-
-
-Polymer
-  is: "light9-device-settings",
-  properties: {
-    graph: { type: Object }
-    subj: {type: String, notify: true},
-    label: {type: String, notify: true},
-    attrs: {type: Array, notify: true},
-  },
-  observers: [
-    'onSubj(graph, subj)'
-  ]
-  ready: ->
-    @label = "aura2"
-    @attrs = [
-        {attr: 'rx', val: .03},
-        {attr: 'color', val: '#ffe897'},
-    ]
-  onSubj: (graph, @subj) ->
-    graph.runHandler(@loadAttrs.bind(@), "loadAttrs #{@subj}")
-  loadAttrs: ->
-    U = (x) => @graph.Uri(x)
-    @attrs = []
-    for s in @graph.objects(U(@subj), U(':setting'))
-      attr = @graph.uriValue(s, U(':deviceAttr'))
-      attrLabel = @graph.stringValue(attr, U('rdfs:label'))
-      @attrs.push({attr: attrLabel, val: @settingValue(s)})
-    @attrs = _.sortBy(@attrs, 'attr')
-
-  settingValue: (s) ->
-    U = (x) => @graph.Uri(x)
-    for pred in [U(':value'), U(':scaledValue')]
-      try
-        return @graph.stringValue(s, pred)
-      catch
-        null
-      try
-        return @graph.floatValue(s, pred)
-      catch
-        null
-    throw new Error("no value for #{s}")
-    
-Polymer
-  is: "light9-paint"
-  properties: {
-    painting: { type: Object }
-    client: { type: Object }
-    graph: { type: Object }
-  }
-
-  ready: () ->
-    # couldn't make it work to bind to painting's notifyPath events
-    @$.canvas.addEventListener('paintingChanged', @paintingChanged.bind(@))
-    @$.solve.addEventListener('response', @onSolve.bind(@))
-
-    @clientSendThrottled = _.throttle(@client.send.bind(@client), 60)
-    @bestMatchPending = false
-    
-  paintingChanged: (ev) ->
-    U = (x) => @graph.Uri(x)
-
-    @painting = ev.detail
-    @$.solve.body = JSON.stringify(@painting.getDoc())
-    #@$.solve.generateRequest()
-
-    @$.bestMatches.body = JSON.stringify({
-      painting: @painting.getDoc(),
-      devices: [
-        U('dev:aura1'), U('dev:aura2'), U('dev:aura3'), U('dev:aura4'), U('dev:aura5'),
-        U('dev:q1'), U('dev:q2'), U('dev:q3'),
-        ]})
-
-    send = =>
-      @$.bestMatches.generateRequest().completes.then (r) =>
-        @clientSendThrottled(r.response.settings)
-        if @bestMatchPending
-          @bestMatchPending = false
-          send()
-    
-    if @$.bestMatches.loading
-      @bestMatchPending = true
-    else
-      send()
-
-  onSolve: (response) ->
-    U = (x) => @graph.Uri(x)
-
-    sample = @$.solve.lastResponse.bestMatch.uri
-    settingsList = []
-    for s in @graph.objects(sample, U(':setting'))
-      try
-        v = @graph.floatValue(s, U(':value'))
-      catch
-        v = @graph.stringValue(s, U(':scaledValue'))
-      row = [@graph.uriValue(s, U(':device')), @graph.uriValue(s, U(':deviceAttr')), v]
-      settingsList.push(row)
-    @client.send(settingsList)
--- a/light9/web/paint/paint-elements.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,116 +0,0 @@
-<script src="/lib/underscore/underscore-min.js"></script>
-<link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-<link rel="import" href="/lib/paper-radio-group/paper-radio-group.html">
-<link rel="import" href="/lib/paper-radio-button/paper-radio-button.html">
-<link rel="import" href="paint-report-elements.html">
-<link rel="import" href="../rdfdb-synced-graph.html">
-<link rel="import" href="../light9-collector-client.html">
-
-
-<dom-module id="light9-paint-canvas">
-  <template>
-    <style>
-     :host {
-         display: block;
-     }
-     #parent {
-         position: relative;
-         height: 500px;
-     }
-     #parent > * {
-         position: absolute;
-         top: 0;
-         left: 0;
-         width: 100%;
-         height: 500px;
-     }
-     #toolbar {
-         background: #a7a7a7;
-     }
-     svg > path {
-         fill:none;
-         stroke:rgba(255, 255, 255, 0.66);
-         stroke-width:80;
-         filter:url(#blur);
-         stroke-linecap:butt;
-         stroke-linejoin:miter;
-         stroke-miterlimit:4;
-     }
-    </style>
-
-    <div id="toolbar">
-      <paper-radio-group selected="{{tool}}">
-        <paper-radio-button name="hover">hover spot</paper-radio-button>
-        <paper-radio-button name="paint">paint</paper-radio-button>
-        <paper-radio-button name="erase">erase</paper-radio-button>
-      </paper-radio-group>
-      <button on-click="onClear">clear</button>
-    </div>
-    
-    <div id="parent">
-      <img src="{{bg}}">
-      <svg id="paint" viewBox="0 0 500 221">
-        <defs id="defs12751">
-          <filter
-              style="color-interpolation-filters:sRGB"
-              id="blur"
-              x="-5.0" y="-5.0"
-              width="11.0" height="11.0"
-          >
-            <feGaussianBlur
-                stdDeviation="20"
-                k2="1.01"
-                result="result1"
-            ></feGaussianBlur>
-            <!--   <feMorphology
-                 in="result1"
-                 operator="dilate"
-                 radius="3.39"
-                 result="result3"
-                 ></feMorphology>
-                 <feMorphology
-                 in="result1"
-                 radius="3.37"
-                 result="result2"
-                 ></feMorphology>
-                 <feComposite
-                 in="result3"
-                 in2="result2"
-                 operator="arithmetic"
-                 k1="0"
-                 k2="1.00"
-                 k3="0.43"
-                 k4="0"
-                 ></feComposite> -->
-          </filter>
-        </defs>
-      </svg>
-    </div>
-  </template>
-  
-</dom-module>
-
-<dom-module id="light9-paint">
-  <template>
-    <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-
-    <light9-paint-canvas id="canvas" bg="bg3.jpg" painting="{{painting}}"></light9-paint-canvas>
-
-    <iron-ajax id="solve" method="POST" url="../paintServer/solve" last-response="{{solve}}"></iron-ajax>
-
-    <iron-ajax id="bestMatches" method="POST" url="../paintServer/bestMatches"></iron-ajax>
-    
-    <div>To collector: <light9-collector-client self="{{client}}"></light9-collector-client></div>
-
-    <light9-simulation graph="{{graph}}" solution="{{solve}}" layers="{{layers}}"></light9-simulation>
-  </template>
-</dom-module>
-
-<script src="/node_modules/n3/n3-browser.js"></script>
-<script src="/lib/shortcut/index.js"></script>
-<script src="/lib/underscore/underscore-min.js"></script>
-<script src="/lib/async/dist/async.js"></script>
-
-<script src="paint-elements.js"></script>
--- a/light9/web/paint/paint-report-elements.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,148 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-
-<dom-module id="light9-simulation">
-  <template>
-    <style>
-     #solutions { display: flex; margin: 20px; }
-     #single-light { margin-right: 70px; }
-     #multi-light {}
-     #breakdown { position: relative; }
-     #sources { display: flex; }
-     #solution { display: flex; margin-top: 80px; }
-     #connectors { position: absolute; width: 100%; height: 100%; z-index: -1; }
-     #connectors path { stroke: #615c54; stroke-width: 3px; }
-
-     [draggable=true]:hover {
-         box-shadow: 0 0 20px yellow;
-     }
-         
-     
-    </style>
-
-    <div id="solutions">
-      <div id="single-light">
-        <div>Single pic best match:</div>
-
-        <!-- drag this img to make an effect out of just it -->
-        <light9-capture-image name="lighhtnamehere" path="{{solution.bestMatch.path}}"></light9-capture-image>
-
-        <div>Error: {{solution.bestMatch.dist}}</div>
-        
-        <light9-device-settings graph="{{graph}}" subj="{{solution.bestMatch.uri}}"></light9-device-settings>
-      </div>
-
-      <!-- existing effect best match? -->
-      
-      <div id="multi-light">
-        Created from multiple lights:
-
-        <div id="breakdown">
-          <svg id="connectors">
-            <g>
-              <path d="M 112,241 L 150,280"></path>
-              <path d="M 332,241 L 150,280"></path>
-              <path d="M 532,241 L 150,280"></path>
-              <path d="M 732,241 L 150,280"></path>
-            </g>
-            
-          </svg>
-          <div id="sources">
-            <div class="effectLike" draggable="true">
-              <light9-capture-image name="aura1" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
-              <light9-device-settings></light9-device-settings>
-            </div>
-            <div>
-              <light9-capture-image name="aura2" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
-              <light9-device-settings></light9-device-settings>
-            </div>
-            <div>
-              <light9-capture-image name="aura3" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
-              <light9-device-settings></light9-device-settings>
-            </div>
-            <div>
-              <light9-capture-image name="aura4" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
-              <light9-device-settings></light9-device-settings>
-            </div>
-          </div>
-          
-          <div id="solution">
-            <div>
-              <div>combined</div>
-              <!-- drag this img to make an effect out of it -->
-              <div><img width="150" src="../show/dance2017/capture/moving1/cap258592/pic1.jpg"></div>
-              <div>error 9980</div>
-            </div>
-            <div>
-              <div>residual</div>
-              <div><img width="150" src="../show/dance2017/capture/moving1/cap258592/pic1.jpg"></div>
-            </div>
-          </div>
-        </div>
-
-        Save as effect named <input> <button>Save</button>
-      </div>
-
-  </template>
-</dom-module>
-
-<!-- merge more with light9-collector-device -->
-<dom-module id="light9-device-settings">
-  <template>
-    <style>
-     :host {
-         display: block;
-         break-inside: avoid-column;
-         border: 2px solid gray;
-         padding: 8px;
-     }
-     td.nonzero {
-         background: #310202;
-         color: #e25757;
-     }
-     td.full {
-         background: #2b0000;
-         color: red;
-         font-weight: bold;
-     }
-    </style>
-    <h3><a href="{{subj}}">{{label}}</a></h3>
-    <table class="borders">
-      <tr>
-        <th>device attr</th>
-        <th>value</th>
-      </tr>
-      <template is="dom-repeat" items="{{attrs}}">
-        <tr>
-          <td>{{item.attr}}</td>
-          <td class$="{{item.valClass}}">{{item.val}}</td>
-        </tr>
-      </template>
-
-  </template>
- 
-</dom-module>
-
-<dom-module id="light9-capture-image">
-  <template>
-    <style>
-     :host { display: block; }
-       img {
-         outline: 1px solid #232323;
-         margin: 5px;
-     }
-    </style>
-    <div>{{name}}</div>
-    <div><img width="100" src="../{{path}}"></div>
-  </template>
-  <script>
-   Polymer({
-       is: "light9-capture-image",
-       properties: {
-           name: { type: String },
-           path: { type: String },
-       }
-   });
-  </script>
-</dom-module>
--- a/light9/web/patch.test.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,79 +0,0 @@
-import { assert, describe, expect, it } from "vitest";
-
-import { Quad, NamedNode } from "n3";
-import { Patch, QuadPattern } from "./patch";
-import * as N3 from "n3";
-
-const node1 = new NamedNode("http://example.com/node1");
-const node2 = new NamedNode("http://example.com/node2");
-const node3 = new NamedNode("http://example.com/node3");
-
-const decimalDT = new NamedNode("http://www.w3.org/2001/XMLSchema#decimal");
-
-function QP(
-  subject: N3.Quad_Subject | null, //
-  predicate: N3.Quad_Predicate | null,
-  object: N3.Quad_Object | null,
-  graph: N3.Quad_Graph | null
-): QuadPattern {
-  return { subject, predicate, object, graph };
-}
-
-describe("Patch.matches", () => {
-  it("matches any quads against an open pattern", () => {
-    const quad1 = new Quad(node1, node2, node3);
-    const quad2 = new Quad(node1, node2, node3);
-    const quad3 = new Quad(node1, node2, node3);
-
-    const pattern = QP(null, null, null, null);
-
-    const p = new Patch([quad1, quad2], [quad3]);
-
-    assert.isTrue(p.matches(pattern));
-  });
-  it("doesn't match when the patch is empty", () => {
-    const p = new Patch([], []);
-    assert.isFalse(p.matches(QP(null, null, null, null)));
-  });
-  it("compares terms correctly", () => {
-    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node1, null, null, null)));
-    assert.isFalse(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node2, null, null, null)));
-  });
-  it("matches on just one set term", () => {
-    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node1, null, null, null)));
-    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(null, node2, null, null)));
-    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(null, null, node3, null)));
-  });
-});
-
-describe("Patch.empty", () => {
-  it("works with no quads", () => {
-    const p = new Patch([], []);
-    assert.isTrue(p.isEmpty());
-  });
-  it("works with unmatched quads", () => {
-    const p = new Patch([], [new Quad(node1, node2, node3)]);
-    assert.isFalse(p.isEmpty());
-  });
-  it("understands floats are equal", () => {
-    const p = new Patch(
-      [new Quad(node1, node2, N3.DataFactory.literal((0.12345).toPrecision(3), decimalDT))],
-      [new Quad(node1, node2, N3.DataFactory.literal((0.1234).toPrecision(3), decimalDT))]
-    );
-    assert.isTrue(p.isEmpty());
-  });
-  it("...and when they're not", () => {
-    const p = new Patch(
-      [new Quad(node1, node2, N3.DataFactory.literal(0.123, decimalDT))], //
-      [new Quad(node1, node2, N3.DataFactory.literal(0.124, decimalDT))]
-    );
-    assert.isFalse(p.isEmpty());
-  });
-  it("understands literals are equal", () => {
-    const p = new Patch(
-      [new Quad(node1, node2, node3)], //
-      [new Quad(node1, node2, new NamedNode("http://example.com/node" + "3"))]
-    );
-    assert.isTrue(p.isEmpty());
-  });
-});
--- a/light9/web/patch.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,217 +0,0 @@
-import * as async from "async";
-import debug from "debug";
-import * as N3 from "n3";
-import { NamedNode, Parser, Quad, Writer } from "n3";
-import * as Immutable from "immutable";
-export interface QuadPattern {
-  subject: N3.Quad_Subject | null;
-  predicate: N3.Quad_Predicate | null;
-  object: N3.Quad_Object | null; // literals allowed? needs review. probably 'yes'.
-  graph: N3.Quad_Graph | null;
-}
-
-const log = debug("patch");
-
-export class Patch {
-  // immutable
-  private dels: Immutable.Set<Quad>;
-  private adds: Immutable.Set<Quad>;
-  private _allPredsCache?: Immutable.Set<string>;
-  private _allSubjsCache?: Immutable.Set<string>;
-  constructor(dels: Iterable<Quad>, adds: Iterable<Quad>) {
-    this.dels = Immutable.Set(dels);
-    this.adds = Immutable.Set(adds);
-    this.validate();
-  }
-
-  private validate() {
-    // todo: finish porting this from coffeescript
-    this.adds.union(this.dels).forEach((q: Quad) => {
-      if (!q.equals) {
-        throw new Error("doesn't look like a proper Quad");
-      }
-      if (!q.subject.id || q.graph.id == null || q.predicate.id == null) {
-        throw new Error(`corrupt patch: ${JSON.stringify(q)}`);
-      }
-      if (
-        q.object.termType == "Literal" &&
-        (q.object.datatypeString == "http://www.w3.org/2001/XMLSchema#float" || q.object.datatypeString == "http://www.w3.org/2001/XMLSchema#double")
-      ) {
-        throw new Error(`${JSON.stringify(q)} is using non-decimal for numbers, which is going to break some comparisons`);
-      }
-    });
-  }
-
-  matches(pat: QuadPattern): boolean {
-    const allQuads = this.dels.concat(this.adds);
-    return allQuads.some((quad) => {
-      return (
-        (pat.subject === null || pat.subject.equals(quad.subject)) && //
-        (pat.predicate === null || pat.predicate.equals(quad.predicate)) && //
-        (pat.object === null || pat.object.equals(quad.object)) && //
-        (pat.graph === null || pat.graph.equals(quad.graph))
-      );
-    });
-  }
-
-  isEmpty() {
-    return Immutable.is(this.dels, this.adds);
-  }
-
-  applyToGraph(g: N3.Store) {
-    for (let quad of this.dels) {
-      g.removeQuad(quad);
-    }
-    for (let quad of this.adds) {
-      g.addQuad(quad);
-    }
-  }
-
-  update(other: Patch): Patch {
-    // this is approx, since it doesnt handle cancelling existing quads.
-    return new Patch(this.dels.union(other.dels), this.adds.union(other.adds));
-  }
-
-  summary(): string {
-    return "-" + this.dels.size + " +" + this.adds.size;
-  }
-
-  dump(): string {
-    if (this.dels.size + this.adds.size > 20) {
-      return this.summary();
-    }
-    const lines: string[] = [];
-    const s = (term: N3.Term): string => {
-      if (term.termType == "Literal") return term.value;
-      if (term.termType == "NamedNode")
-        return term.value
-          .replace("http://light9.bigasterisk.com/effect/", "effect:")
-          .replace("http://light9.bigasterisk.com/", ":")
-          .replace("http://www.w3.org/2000/01/rdf-schema#", "rdfs:")
-          .replace("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:");
-      if (term.termType == "BlankNode") return "_:" + term.value;
-      return term.id;
-    };
-    const delPrefix = "- ",
-      addPrefix = "\u200B+ "; // dels to sort before adds
-    this.dels.forEach((d) => lines.push(delPrefix + s(d.subject) + " " + s(d.predicate) + " " + s(d.object)));
-    this.adds.forEach((d) => lines.push(addPrefix + s(d.subject) + " " + s(d.predicate) + " " + s(d.object)));
-    lines.sort();
-    return lines.join("\n") + "\n" + (this.isEmpty() ? "(empty)" : "(nonempty)");
-  }
-
-  async toJsonPatch(): Promise<string> {
-    return new Promise((res, rej) => {
-      const out: SyncgraphPatchMessage = { patch: { adds: "", deletes: "" } };
-
-      const writeDels = (cb1: () => void) => {
-        const writer = new Writer({ format: "N-Quads" });
-        writer.addQuads(this.dels.toArray());
-        writer.end(function (err: any, result: string) {
-          out.patch.deletes = result;
-          cb1();
-        });
-      };
-
-      const writeAdds = (cb2: () => void) => {
-        const writer = new Writer({ format: "N-Quads" });
-        writer.addQuads(this.adds.toArray());
-        writer.end(function (err: any, result: string) {
-          out.patch.adds = result;
-          cb2();
-        });
-      };
-
-      async.parallel([writeDels, writeAdds], (err: any) => res(JSON.stringify(out)));
-    });
-  }
-
-  containsAnyPreds(preds: Iterable<NamedNode>): boolean {
-    if (this._allPredsCache === undefined) {
-      this._allPredsCache = Immutable.Set();
-      this._allPredsCache.withMutations((cache) => {
-        for (let qq of [this.adds, this.dels]) {
-          for (let q of Array.from(qq)) {
-            cache.add(q.predicate.value);
-          }
-        }
-      });
-    }
-
-    for (let p of preds) {
-      if (this._allPredsCache.has(p.value)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  allSubjs(): Immutable.Set<string> {
-    // returns subjs as Set of strings
-    if (this._allSubjsCache === undefined) {
-      this._allSubjsCache = Immutable.Set();
-      this._allSubjsCache.withMutations((cache) => {
-        for (let qq of [this.adds, this.dels]) {
-          for (let q of Array.from(qq)) {
-            cache.add(q.subject.value);
-          }
-        }
-      });
-    }
-
-    return this._allSubjsCache;
-  }
-
-  allPreds(): Immutable.Set<NamedNode> {
-    // todo: this could cache
-    const ret = Immutable.Set<NamedNode>();
-    ret.withMutations((r) => {
-      for (let qq of [this.adds, this.dels]) {
-        for (let q of Array.from(qq)) {
-          if (q.predicate.termType == "Variable") throw "unsupported";
-          r.add(q.predicate);
-        }
-      }
-    });
-    return ret;
-  }
-}
-
-// The schema of the json sent from graph server.
-export interface SyncgraphPatchMessage {
-  patch: { adds: string; deletes: string };
-}
-
-export function patchToDeleteEntireGraph(g: N3.Store) {
-  return new Patch(g.getQuads(null, null, null, null), []);
-}
-
-export function parseJsonPatch(input: SyncgraphPatchMessage, cb: (p: Patch) => void): void {
-  // note response cb doesn't have an error arg.
-  const dels: Quad[] = [];
-  const adds: Quad[] = [];
-
-  const parseAdds = (cb2: () => any) => {
-    const parser = new Parser();
-    return parser.parse(input.patch.adds, (error: any, quad: Quad, prefixes: any) => {
-      if (quad) {
-        return adds.push(quad);
-      } else {
-        return cb2();
-      }
-    });
-  };
-  const parseDels = (cb3: () => any) => {
-    const parser = new Parser();
-    return parser.parse(input.patch.deletes, (error: any, quad: any, prefixes: any) => {
-      if (quad) {
-        return dels.push(quad);
-      } else {
-        return cb3();
-      }
-    });
-  };
-
-  // todo: is it faster to run them in series? might be
-  async.parallel([parseAdds, parseDels], (err: any) => cb(new Patch(dels, adds)));
-}
--- a/light9/web/rdfdb-synced-graph_test.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>rdfdb-synced-graph test</title>
-    <meta charset="utf-8">
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <script src="/node_modules/mocha/mocha.js"></script>
-    <script src="/node_modules/chai/chai.js"></script>
-    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
-    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
-
-    <link rel="import" href="rdfdb-synced-graph.html">
-  </head>
-  <body>
-    <div id="mocha"><p><a href=".">Index</a></p></div>
-    <div id="messages"></div>
-    <div id="fixtures">
-      <dom-bind>
-        <template>
-          <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
-        </template>
-      </dom-bind>
-    </div>
-    
-    <script>
-     mocha.setup('bdd');
-     const assert = chai.assert;
-     
-     describe("rdfdb-synced-graph", () => {
-       let elem, U;
-       beforeEach(() => {
-         elem = document.querySelector("#graph");
-         window.g = elem;
-         elem.graph.clearGraph();
-         U = elem.graph.Uri.bind(elem.graph);
-       });
-       it("makes a node", () => {
-         assert.equal(elem.tagName, "RDFDB-SYNCED-GRAPH");
-       });
-       it("loads trig", (done) => {
-         elem.graph.loadTrig(`
-                       @prefix : <http://light9.bigasterisk.com/> .
-                       :a :b :c :d .
-                     `, () => {
-                       assert.equal(elem.graph.quads().length, 1);
-                       done();
-                     });
-       });
-       describe("floatValue read call", () => {
-         it("loads two values without confusing them in a cache", (done) => {
-           elem.graph.loadTrig(`
-               @prefix : <http://light9.bigasterisk.com/> .
-               :s :a 1 :g .
-               :s :b 2 :g .
-             `, () => {
-               assert.equal(elem.graph.floatValue(U(":s"), U(":a")), 1);
-               assert.equal(elem.graph.floatValue(U(":s"), U(":b")), 2);
-               assert.equal(elem.graph.floatValue(U(":s"), U(":a")), 1);
-               done();
-             });
-         });
-       });
-     }); 
-     mocha.run();
-    </script>
-  </body>
-</html>
--- a/light9/web/rdfdbclient.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-import debug from "debug";
-import { parseJsonPatch, Patch } from "./patch";
-import { RdfDbChannel } from "./RdfDbChannel";
-const log = debug("rdfdbclient");
-
-export class RdfDbClient {
-  private channel: RdfDbChannel;
-  _patchesToSend: Patch[];
-  // Send and receive patches from rdfdb. Primarily used in SyncedGraph.
-  //
-  // What this should do, and does not yet, is keep the graph
-  // 'coasting' over a reconnect, applying only the diffs from the old
-  // contents to the new ones once they're in. Then, remove all the
-  // clearGraph stuff in graph.coffee that doesn't even work right.
-  //
-  constructor(
-    patchSenderUrl: string,
-    private clearGraphOnNewConnection: () => void,
-    private applyPatch: (p: Patch) => void,
-    setStatus: (status: string) => void
-  ) {
-    this._patchesToSend = [];
-    this.channel = new RdfDbChannel(patchSenderUrl);
-    this.channel.statusDisplay.subscribe((st: string) => {
-      setStatus(st + `; ${this._patchesToSend.length} pending `);
-    });
-    this.channel.newConnection.subscribe(() => {
-      this.clearGraphOnNewConnection();
-    });
-    this.channel.serverMessage.subscribe((m) => {
-      parseJsonPatch(m.body, (p: Patch) => {
-        log('patch from server:', p.dump())
-        if (p.isEmpty()) {
-          return;
-        }
-        this.applyPatch(p);
-      });
-    });
-  }
-
-  sendPatch(patch: Patch) {
-    log("queue patch to server ", patch.summary());
-    this._patchesToSend.push(patch);
-    this._continueSending();
-  }
-
-  disconnect(why:string) {
-    this.channel.disconnect(why);
-  }
-
-  async _continueSending() {
-    // we could call this less often and coalesce patches together to optimize
-    // the dragging cases. See rdfdb 'compactPatches' and 'processInbox'.
-    while (this._patchesToSend.length) {
-      const patch = this._patchesToSend.splice(0, 1)[0];
-      const json = await patch.toJsonPatch();
-      const ret = this.channel.sendMessage(json);
-      if (!ret) {
-        setTimeout(this._continueSending.bind(this), 500);
-
-        // this.disconnect()
-        return;
-      }
-    }
-  }
-}
--- a/light9/web/resource-display_test.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,122 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>resource-display test</title>
-    <meta charset="utf-8">
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <script src="/node_modules/mocha/mocha.js"></script>
-    <script src="/node_modules/chai/chai.js"></script>
-
-    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
-    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
-
-    <link rel="import" href="rdfdb-synced-graph.html">
-    <link rel="import" href="resource-display.html">
-  </head>
-  <body>
-    <div id="mocha"><p><a href=".">Index</a></p></div>
-    <div id="messages"></div>
-    <div id="fixtures">
-      <dom-bind>
-        <template>
-          <p>
-            <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
-          </p>
-          <p>
-            resource: <resource-display
-                          id="elem"
-                          graph="{{graph}}"
-                          uri="http://example.com/a"></resource-display>
-          </p>
-        </template>
-      </dom-bind>
-    </div>
-    
-    <script>
-     mocha.setup('bdd')
-     const assert = chai.assert;
-     
-     describe("resource-display", () => {
-         let elem;
-         let graph;
-         beforeEach((done) => {
-             elem = document.querySelector("#elem");
-             window.elem = elem;
-             graph = document.querySelector("#graph");
-             graph.graph.clearGraph();
-             graph.graph.loadTrig(`
-      @prefix : <http://example.com/> .
-      @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
-      :a rdfs:label "label a" :ctx .
-      :b rdfs:label "label b" :ctx .
-      `, done);
-         });
-         const assertLabelTextEquals = (expected) => {
-             assert.equal(elem.shadowRoot.querySelector("#uri").innerText,
-                          expected);
-             
-         };
-         describe('link display', () => {
-         it("says no uri", () => {
-             elem.setAttribute('uri', '');
-             assertLabelTextEquals("<no uri>");
-         });
-         it("has no link when there's no uri", () => {
-             elem.setAttribute('uri', '');
-             assert.equal(elem.shadowRoot.querySelector("#uri").href,
-                          'javascript:;');
-         });
-         it("shows uri's label if graph has one", () => {
-             elem.setAttribute('uri', 'http://example.com/a');
-             assertLabelTextEquals("label a");
-         });
-         it("links to uri", () => {
-             elem.setAttribute('uri', 'http://example.com/a');
-             assert.equal(elem.shadowRoot.querySelector("#uri").href,
-                          'http://example.com/a');
-         });
-         it("falls back to uri tail if there's no label", () => {
-             elem.setAttribute('uri', 'http://example.com/nolabel');
-             assertLabelTextEquals("nolabel");
-         });
-         it("falls back to full uri if the tail would be empty", () => {
-             elem.setAttribute('uri', 'http://example.com/');
-             assertLabelTextEquals('http://example.com/');
-
-         });
-         it("changes the label if the graph updates uri's label", () => {
-             const g = graph.graph;
-             elem.setAttribute('uri', 'http://example.com/a');
-
-             g.patchObject(g.Uri('http://example.com/a'),
-                           g.Uri('rdfs:label'),
-                           g.Literal('new label'));
-             assertLabelTextEquals('new label');
-
-         });
-         it("changes the label if the uri changes", (done) => {
-             elem.setAttribute('uri', 'http://example.com/a');
-             setTimeout(() => {
-                 elem.setAttribute('uri', 'http://example.com/b');
-                 assertLabelTextEquals('label b');
-                 done();
-             }, 100);
-         });
-         });
-         describe('type icons', () => {
-             it("omits icon for unknown type");
-             it("uses icon uri from graph and shows the icon");
-         });
-         describe('rename ui', () => {
-             it("shows rename button if caller wants");
-             it("opens dialog when you click rename");
-             it("shows old label in dialog, ready to be replaced");
-             it("does nothing if you cancel");
-             it("patches the graph if you accept a new name");
-         });
-         
-     }); 
-     mocha.run();
-    </script>
-  </body>
-</html>
--- a/light9/web/show_specific.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-export const shortShow = "dance2023";
-export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
\ No newline at end of file
--- a/light9/web/style.css	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,240 +0,0 @@
-body {
-  background: black;
-  color: white;
-  --color-background: black;
-  --color-text: white;
-  font-family: sans-serif;
-}
-h1 {
-  margin: 0;
-}
-
-h2 {
-  margin: 0;
-  padding: 0;
-  font-size: 100%;
-}
-
-ul {
-  margin: 0;
-}
-
-a {
-  color: rgb(97, 97, 255);
-}
-
-input[type="text"] {
-  border: 1px inset rgb(177, 177, 177);
-  background: rgb(230, 230, 230);
-  padding: 3px;
-}
-
-#status {
-  position: fixed;
-  bottom: 0px;
-  right: 0px;
-  background: rgba(0, 0, 0, 0.47);
-  padding-left: 6px;
-}
-
-.songs {
-  column-width: 17em;
-}
-
-.songs button {
-  display: inline-block;
-  width: 100%;
-  min-height: 50px;
-  text-align: left;
-  background: black;
-  color: white;
-  margin: 2px;
-  font-size: 130% !important;
-  font-weight: bold;
-  text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000,
-    1px 1px 0 #000;
-}
-
-button a {
-  color: white;
-}
-
-.songs button:hover {
-  color: black;
-  background: #333;
-}
-
-.commands button {
-  background: black;
-  color: white;
-  padding: 20px;
-}
-
-.commands button.active {
-  background: #a90707;
-}
-
-.key {
-  color: #888;
-}
-
-div.keys {
-  margin-top: 10px;
-  padding: 5px;
-}
-
-.keyCap {
-  color: #ccc;
-  background: #525252;
-  display: inline-block;
-  border: 1px outset #b3b3b3;
-  padding: 2px 3px;
-  margin: 3px 0;
-  font-size: 16px;
-  box-shadow: 0.9px 0.9px 0px 2px #565656;
-  border-radius: 2px;
-}
-
-.currentSong button {
-  background: #a90707;
-}
-
-
-.stalled {
-  opacity: 0.5;
-}
-
-.num {
-  font-size: 27px;
-  color: rgb(233, 122, 122);
-  display: inline-block;
-  font-size: 200% !important;
-  font-weight: bold;
-  text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000,
-    1px 1px 0 #000;
-  float: left;
-}
-
-.dropTarget {
-  padding: 10px 5px;
-  border: 2px dashed gray;
-  font-style: italic;
-  color: rgb(78, 90, 107);
-}
-
-.dropTarget:hover {
-  background: #1f1f0d;
-}
-
-.twoColList {
-  -webkit-column-width: 24em;
-}
-
-.twoColList > li {
-  margin-bottom: 13px;
-}
-
-.song {
-  color: rgb(85, 221, 85);
-}
-
-.song:before {
-  content: "♫";
-  color: black;
-  background: rgb(85, 221, 85);
-  border-radius: 30%;
-}
-
-.effect:before {
-  content: "⛖";
-}
-
-.song:before,
-.effect:before {
-  margin-right: 3px;
-  text-decoration: none !important;
-  font-size: 140%;
-}
-
-/* ascoltami mini mode */
-@media (max-width: 600px) {
-  .songs {
-    column-width: 15em;
-  }
-  .songs button {
-    font-size: initial !important;
-    min-height: 35px !important;
-    width: 100%;
-    margin: initial;
-    border-width: 1px;
-    margin-bottom: 2px;
-  }
-  .num {
-    font-size: initial !important;
-    padding: initial !important;
-  }
-  .commands button {
-    padding: 5px;
-  }
-}
-
-/* subserver */
-.vari {
-  color: white;
-}
-
-.sub {
-  display: inline-block;
-  vertical-align: top;
-}
-
-.sub.local {
-  background: rgb(44, 44, 44);
-}
-
-.sub img {
-  width: 196px;
-  min-height: 40px;
-  margin: 0 6px;
-  background: -webkit-gradient(
-    linear,
-    right top,
-    left bottom,
-    color-stop(0, rgb(121, 120, 120)),
-    color-stop(1, rgb(54, 54, 54))
-  );
-}
-
-.chase {
-  background: rgb(75, 57, 72);
-}
-
-a button {
-  font-size: 60%;
-}
-
-a.big {
-  background-color: #384052;
-  padding: 6px;
-  text-shadow: rgba(0, 0, 0, 0.48) -1px -1px 0px;
-  color: rgb(172, 172, 255);
-  font-size: 160%;
-  margin: 0px;
-  display: inline-block;
-  border-radius: 5px;
-}
-
-table {
-  border-collapse: collapse;
-}
-
-table.borders td,
-table.borders th {
-  border: 1px solid #4a4a4a;
-  padding: 2px 8px;
-}
-
-hr {
-  width: 100%;
-  border-color: #1d3e1d;
-}
--- a/light9/web/timeline/Note.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,286 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-# Maintains a pixi object, some adjusters, and inlineattrs corresponding to a note
-# in the graph.
-class Note
-  constructor: (@parentElem, @container, @project, @graph, @selection, @uri, @setAdjuster, @song, @viewState, @brickLayout) ->
-    @adjusterIds = new Set() # id string
-    @updateSoon = _.debounce(@update.bind(@), 30)
-
-  initWatchers: ->
-    @graph.runHandler(@update.bind(@), "note update #{@uri.value}")
-    ko.computed @update.bind(@)
-
-  destroy: ->
-    log('destroy', @uri.value)
-    @isDetached = true
-    @clearAdjusters()
-    @parentElem.updateInlineAttrs(@uri, null)
-
-  clearAdjusters: ->
-    @adjusterIds.forEach (i) =>
-      @setAdjuster(i, null)
-    @adjusterIds.clear()
-
-  getCurvePoints: (subj, curveAttr) ->
-    U = (x) => @graph.Uri(x)
-    originTime = @graph.floatValue(subj, U(':originTime'))
-
-    for curve in @graph.objects(subj, U(':curve'))
-      # todo: maybe shoudl be :effectAttr?
-      if @graph.uriValue(curve, U(':attr')).equals(curveAttr)
-        return @project.getCurvePoints(curve, originTime)
-    throw new Error("curve #{@uri.value} has no attr #{curveAttr.value}")
-
-  midPoint: (i0, i1) ->
-    p0 = @worldPts[i0]
-    p1 = @worldPts[i1]
-    p0.x(.5).add(p1.x(.5))
-
-  _planDrawing: ->
-    U = (x) => @graph.Uri(x)
-    [pointUris, worldPts] = @getCurvePoints(@uri, U(':strength'))
-    effect = @graph.uriValue(@uri, U(':effectClass'))
-
-    yForV = @brickLayout.yForVFor(@)
-    dependOn = [@viewState.zoomSpec.t1(),
-                @viewState.zoomSpec.t2(),
-                @viewState.width()]
-    screenPts = (new PIXI.Point(@viewState.zoomInX(pt.e(1)),
-                                yForV(pt.e(2))) for pt in worldPts)
-    return {
-      yForV: yForV
-      worldPts: worldPts
-      screenPts: screenPts
-      effect: effect
-      hover: @uri.equals(@selection.hover())
-      selected: @selection.selected().filter((s) => s.equals(@uri)).length
-    }
-
-  onRowChange: ->
-    @clearAdjusters()
-    @updateSoon()
-
-  redraw: (params) ->
-    # no observable or graph deps in here
-    @container.removeChildren()
-    @graphics = new PIXI.Graphics({nativeLines: false})
-    @graphics.interactive = true
-    @container.addChild(@graphics)
-
-    if params.hover
-      @_traceBorder(params.screenPts, 12, 0x888888)
-    if params.selected
-      @_traceBorder(params.screenPts, 6, 0xff2900)
-
-    shape = new PIXI.Polygon(params.screenPts)
-    @graphics.beginFill(@_noteColor(params.effect), .313)
-    @graphics.drawShape(shape)
-    @graphics.endFill()
-
-    @_traceBorder(params.screenPts, 2, 0xffd900)
-
-    @_addMouseBindings()
-    
-                 
-  update: ->
-    if not @parentElem.isActiveNote(@uri)
-      # stale redraw call
-      return
-
-    if @worldPts
-      @brickLayout.setNoteSpan(@, @worldPts[0].e(1),
-                               @worldPts[@worldPts.length - 1].e(1))
-
-    params = @_planDrawing()
-    @worldPts = params.worldPts
-
-    @redraw(params)
-
-    curveWidthCalc = () => @project.curveWidth(@worldPts)
-    @_updateAdjusters(params.screenPts, @worldPts, curveWidthCalc,
-                      params.yForV, @viewState.zoomInX, @song)
-    @_updateInlineAttrs(params.screenPts, params.yForV)
-    @parentElem.noteDirty()
-
-  _traceBorder: (screenPts, thick, color) ->
-    @graphics.lineStyle(thick, color, 1)
-    @graphics.moveTo(screenPts[0].x, screenPts[0].y)
-    for p in screenPts.slice(1)
-      @graphics.lineTo(p.x, p.y)
-
-  _addMouseBindings: () ->
-    @graphics.on 'mousedown', (ev) =>
-      @_onMouseDown(ev)
-
-    @graphics.on 'mouseover', =>
-      if @selection.hover() and @selection.hover().equals(@uri)
-        # Hovering causes a redraw, which would cause another
-        # mouseover event.
-        return
-      @selection.hover(@uri)
-
-    # mouseout never fires since we rebuild the graphics on mouseover.
-    @graphics.on 'mousemove', (ev) =>
-      if @selection.hover() and @selection.hover().equals(@uri) and ev.target != @graphics
-        @selection.hover(null)
-
-  onUri: ->
-    @graph.runHandler(@update.bind(@), "note updates #{@uri}")
-
-  patchCouldAffectMe: (patch) ->
-    if patch and patch.addQuads # sometimes patch is a polymer-sent value. @update is used as a listener too
-      if patch.addQuads.length == patch.delQuads.length == 1
-        add = patch.addQuads[0]
-        del = patch.delQuads[0]
-        if (add.predicate.equals(del.predicate) and del.predicate.equals(@graph.Uri(':time')) and add.subject.equals(del.subject))
-          timeEditFor = add.subject
-          if @worldPts and timeEditFor not in @pointUris
-            return false
-    return true
-
-  xupdate: (patch) ->
-    # update our note DOM and SVG elements based on the graph
-    if not @patchCouldAffectMe(patch)
-      # as autodep still fires all handlers on all patches, we just
-      # need any single dep to cause another callback. (without this,
-      # we would no longer be registered at all)
-      @graph.subjects(@uri, @uri, @uri)
-      return
-    if @isDetached?
-      return
-
-    @_updateDisplay()
-
-  _updateAdjusters: (screenPts, worldPts, curveWidthCalc, yForV, zoomInX, ctx) ->
-    # todo: allow offset even on more narrow notes
-    if screenPts[screenPts.length - 1].x - screenPts[0].x < 100 or screenPts[0].x > @parentElem.offsetWidth or screenPts[screenPts.length - 1].x < 0
-      @clearAdjusters()
-    else
-      @_makeOffsetAdjuster(yForV, curveWidthCalc, ctx)
-      @_makeCurvePointAdjusters(yForV, worldPts, ctx)
-      @_makeFadeAdjusters(yForV, zoomInX, ctx, worldPts)
-
-  _updateInlineAttrs: (screenPts, yForV) ->
-    w = 280
-
-    leftX = Math.max(2, screenPts[Math.min(1, screenPts.length - 1)].x + 5)
-    rightX = screenPts[Math.min(2, screenPts.length - 1)].x - 5
-    if screenPts.length < 3
-      rightX = leftX + w
-
-    if rightX - leftX < w or rightX < w or leftX > @parentElem.offsetWidth
-      @parentElem.updateInlineAttrs(@uri, null)
-      return
-
-    config = {
-      uri: @uri,
-      left: leftX,
-      top: yForV(1) + 5,
-      width: w,
-      height: yForV(0) - yForV(1) - 15,
-      }
-
-    @parentElem.updateInlineAttrs(@uri, config)
-
-  _makeCurvePointAdjusters: (yForV, worldPts, ctx) ->
-    for pointNum in [0...worldPts.length]
-      @_makePointAdjuster(yForV, worldPts, pointNum, ctx)
-
-  _makePointAdjuster: (yForV, worldPts, pointNum, ctx) ->
-    U = (x) => @graph.Uri(x)
-
-    adjId = @uri.value + '/p' + pointNum
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      adj = new AdjustableFloatObject({
-        graph: @graph
-        subj: worldPts[pointNum].uri
-        pred: U(':time')
-        ctx: ctx
-        getTargetPosForValue: (value) =>
-          $V([@viewState.zoomInX(value), yForV(worldPts[pointNum].e(2))])
-        getValueForPos: (pos) =>
-          origin = @graph.floatValue(@uri, U(':originTime'))
-          (@viewState.zoomInX.invert(pos.e(1)) - origin)
-        getSuggestedTargetOffset: () => @_suggestedOffset(worldPts[pointNum]),
-      })
-      adj._getValue = (=>
-        # note: don't use originTime from the closure- we need the
-        # graph dependency
-        adj._currentValue + @graph.floatValue(@uri, U(':originTime'))
-        )
-      adj
-
-  _makeOffsetAdjuster: (yForV, curveWidthCalc, ctx) ->
-    U = (x) => @graph.Uri(x)
-
-    adjId = @uri.value + '/offset'
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      adj = new AdjustableFloatObject({
-        graph: @graph
-        subj: @uri
-        pred: U(':originTime')
-        ctx: ctx
-        getDisplayValue: (v, dv) => "o=#{dv}"
-        getTargetPosForValue: (value) =>
-          # display bug: should be working from pt[0].t, not from origin
-          $V([@viewState.zoomInX(value + curveWidthCalc() / 2), yForV(.5)])
-        getValueForPos: (pos) =>
-          @viewState.zoomInX.invert(pos.e(1)) - curveWidthCalc() / 2
-        getSuggestedTargetOffset: () => $V([-10, 0])
-      })
-      adj
-
-  _makeFadeAdjusters: (yForV, zoomInX, ctx, worldPts) ->
-    U = (x) => @graph.Uri(x)
-    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeIn', 0, 1, $V([-50, -10]))
-    n = worldPts.length
-    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeOut', n - 2, n - 1, $V([50, -10]))
-
-  _makeFadeAdjuster: (yForV, zoomInX, ctx, adjId, i0, i1, offset) ->
-    @adjusterIds.add(adjId)
-    @setAdjuster adjId, =>
-      new AdjustableFade(yForV, zoomInX, i0, i1, @, offset, ctx)
-
-  _suggestedOffset: (pt) ->
-    if pt.e(2) > .5
-      $V([0, 30])
-    else
-      $V([0, -30])
-
-  _onMouseDown: (ev) ->
-    sel = @selection.selected()
-    if ev.data.originalEvent.ctrlKey
-      if @uri in sel
-        sel = _.without(sel, @uri)
-      else
-        sel.push(@uri)
-    else
-      sel = [@uri]
-    @selection.selected(sel)
-
-  _noteColor: (effect) ->
-    effect = effect.value
-    if effect in ['http://light9.bigasterisk.com/effect/blacklight',
-                  'http://light9.bigasterisk.com/effect/strobewarm']
-      hue = 0
-      sat = 100
-    else
-      hash = 0
-      for i in [(effect.length-10)...effect.length]
-        hash += effect.charCodeAt(i)
-      hue = (hash * 8) % 360
-      sat = 40 + (hash % 20) # don't conceal colorscale too much
-
-    return parseInt(tinycolor.fromRatio({h: hue / 360, s: sat / 100, l: .58}).toHex(), 16)
-
-    #elem = @getOrCreateElem(uri+'/label', 'noteLabels', 'text', {style: "font-size:13px;line-height:125%;font-family:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"})
-    #elem.setAttribute('x', curvePts[0].e(1)+20)
-    #elem.setAttribute('y', curvePts[0].e(2)-10)
-    #elem.innerHTML = effectLabel
--- a/light9/web/timeline/Project.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,93 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-class Project
-  constructor: (@graph) ->
-
-  makeEffect: (uri) ->
-    U = (x) => @graph.Uri(x)
-    effect = U(uri.value + '/effect')
-    quad = (s, p, o) => @graph.Quad(s, p, o, effect)
-
-    quads = [
-      quad(effect, U('rdf:type'), U(':Effect')),
-      quad(effect, U(':copiedFrom'), uri),
-      quad(effect, U('rdfs:label'), @graph.Literal(uri.replace(/.*capture\//, ''))),
-      quad(effect, U(':publishAttr'), U(':strength')),
-      ]
-
-    fromSettings = @graph.objects(uri, U(':setting'))
-
-    toSettings = @graph.nextNumberedResources(effect + '_set', fromSettings.length)
-
-    for fs in fromSettings
-      ts = toSettings.pop()
-      # full copies of these since I may have to delete captures
-      quads.push(quad(effect, U(':setting'), ts))
-      quads.push(quad(ts, U(':device'), @graph.uriValue(fs, U(':device'))))
-      quads.push(quad(ts, U(':deviceAttr'), @graph.uriValue(fs, U(':deviceAttr'))))
-      try
-        quads.push(quad(ts, U(':value'), @graph.uriValue(fs, U(':value'))))
-      catch
-        quads.push(quad(ts, U(':scaledValue'), @graph.uriValue(fs, U(':scaledValue'))))
-
-    @graph.applyAndSendPatch({delQuads: [], addQuads: quads})
-    return effect
-
-  makeNewNote: (song, effect, dropTime, desiredWidthT) ->
-    U = (x) => @graph.Uri(x)
-    quad = (s, p, o) => @graph.Quad(s, p, o, song)
-
-    newNote = @graph.nextNumberedResource("#{song.value}/n")
-    newCurve = @graph.nextNumberedResource("#{newNote.value}c")
-    points = @graph.nextNumberedResources("#{newCurve.value}p", 4)
-
-    curveQuads = [
-        quad(song, U(':note'), newNote)
-        quad(newNote, U('rdf:type'), U(':Note'))
-        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
-        quad(newNote, U(':effectClass'), effect)
-        quad(newNote, U(':curve'), newCurve)
-        quad(newCurve, U('rdf:type'), U(':Curve'))
-        # todo: maybe shoudl be :effectAttr?
-        quad(newCurve, U(':attr'), U(':strength'))
-      ]
-
-    pointQuads = []
-    for i in [0...4]
-      pt = points[i]
-      pointQuads.push(quad(newCurve, U(':point'), pt))
-      pointQuads.push(quad(pt, U(':time'), @graph.LiteralRoundedFloat(i/3 * desiredWidthT)))
-      pointQuads.push(quad(pt, U(':value'), @graph.LiteralRoundedFloat(i == 1 or i == 2)))
-
-    patch = {
-      delQuads: []
-      addQuads: curveQuads.concat(pointQuads)
-    }
-    @graph.applyAndSendPatch(patch)
-
-  getCurvePoints: (curve, xOffset) ->
-    worldPts = []
-    uris = @graph.objects(curve, @graph.Uri(':point'))
-    for pt in uris
-      tm = @graph.floatValue(pt, @graph.Uri(':time'))
-      val = @graph.floatValue(pt, @graph.Uri(':value'))
-      v = $V([xOffset + tm, val])
-      v.uri = pt
-      worldPts.push(v)
-    worldPts.sort((a,b) -> a.e(1) - b.e(1))
-    return [uris, worldPts]
-
-  curveWidth: (worldPts) ->
-    tMin = @graph.floatValue(worldPts[0].uri, @graph.Uri(':time'))
-    tMax = @graph.floatValue(worldPts[3].uri, @graph.Uri(':time'))
-    tMax - tMin
-
-  deleteNote: (song, note, selection) ->
-    patch = {delQuads: [@graph.Quad(song, graph.Uri(':note'), note, song)], addQuads: []}
-    @graph.applyAndSendPatch(patch)
-    if note in selection.selected()
-      selection.selected(_.without(selection.selected(), note))
--- a/light9/web/timeline/TimeAxis.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-
-
-
-@customElement("light9-timeline-time-axis")
-class TimeAxis extends LitElement
-  @getter_properties:
-    viewState: { type: Object, notify: true, observer: "onViewState" }
-  onViewState: ->
-    ko.computed =>
-      dependOn = [@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()]
-      pxPerTick = 50
-      axis = d3.axisTop(@viewState.zoomInX).ticks(@viewState.width() / pxPerTick)
-      d3.select(@$.axis).call(axis)
\ No newline at end of file
--- a/light9/web/timeline/TimeZoomed.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,174 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-
-# plan: in here, turn all the notes into simple js objects with all
-# their timing data and whatever's needed for adjusters. From that, do
-# the brick layout. update only changing adjusters.
-@customElement('light9-timeline-time-zoomed')
-class TimeZoomed extends LitElement
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    project: { type: Object }
-    selection: { type: Object, notify: true }
-    song: { type: String, notify: true }
-    viewState: { type: Object, notify: true }
-    inlineAttrConfigs: { type: Array, value: [] } # only for inlineattrs that should be displayed
-    imageSamples: { type: Array, value: [] }
-  @getter_observers: [
-    '_onGraph(graph, setAdjuster, song, viewState, project)',
-    'onZoom(viewState)',
-    '_onViewState(viewState)',
-  ]
-  constructor: ->
-    super()
-    @numRows = 6
-    @noteByUriStr = new Map()
-    @stage = new PIXI.Container()
-    @stage.interactive=true
-
-    @renderer = PIXI.autoDetectRenderer({
-      backgroundColor: 0x606060,
-      antialias: true,
-      forceCanvas: true,
-    })
-    @bg = new PIXI.Container()
-    @stage.addChild(@bg)
-
-    @dirty = _.debounce(@_repaint.bind(@), 10)
-
-  ready: ->
-    super.ready()
-
-    @imageSamples = ['one']
-
-    @addEventListener('iron-resize', @_onResize.bind(@))
-    Polymer.RenderStatus.afterNextRender(this, @_onResize.bind(@))
-
-    @$.rows.appendChild(@renderer.view)
-
-    # This works for display, but pixi hit events didn't correctly
-    # move with the objects, so as a workaround, I extended the top of
-    # the canvas in _onResize.
-    #
-    #ko.computed =>
-    #  @stage.setTransform(0, -(@viewState.rowsY()), 1, 1, 0, 0, 0, 0, 0)
-
-  _onResize: ->
-    @$.rows.firstChild.style.position = 'relative'
-    @$.rows.firstChild.style.top = -@viewState.rowsY() + 'px'
-
-    @renderer.resize(@clientWidth, @clientHeight + @viewState.rowsY())
-
-    @dirty()
-
-  _onGraph: (graph, setAdjuster, song, viewState, project)->
-    return unless @song # polymer will call again
-    @graph.runHandler(@gatherNotes.bind(@), 'zoom notes')
-
-  _onViewState: (viewState) ->
-    @brickLayout = new BrickLayout(@viewState, @numRows)
-
-  noteDirty: -> @dirty()
-    
-  onZoom: ->
-    updateZoomFlattened = ->
-      @zoomFlattened = ko.toJS(@viewState.zoomSpec)
-    ko.computed(updateZoomFlattened.bind(@))
-
-  gatherNotes: ->
-    U = (x) => @graph.Uri(x)
-    return unless @song?
-    songNotes = @graph.objects(U(@song), U(':note'))
-
-    toRemove = new Set(@noteByUriStr.keys())
-    
-    for uri in @graph.sortedUris(songNotes)
-      had = toRemove.delete(uri.value)
-      if not had
-        @_addNote(uri)
-
-    toRemove.forEach @_delNote.bind(@)
-
-    @dirty()
-
-  isActiveNote: (note) -> @noteByUriStr.has(note.value)
-
-  _repaint: ->
-    @_drawGrid()  
-    @renderer.render(@stage)
-
-  _drawGrid: ->
-    # maybe someday this has snappable timing markers too
-    @bg.removeChildren()
-    gfx = new PIXI.Graphics()
-    @bg.addChild(gfx)
-
-    gfx.lineStyle(1, 0x222222, 1)
-    for row in [0...@numRows]
-      y = @brickLayout.rowBottom(row)
-      gfx.moveTo(0, y)
-      gfx.lineTo(@clientWidth, y)
-
-  _addNote: (uri) ->
-    U = (x) => @graph.Uri(x)
-    
-    con = new PIXI.Container()
-    con.interactive=true
-    @stage.addChild(con)
-    
-    note = new Note(@, con, @project, @graph, @selection, uri, @setAdjuster, U(@song), @viewState, @brickLayout)
-    # this must come before the first Note.draw
-    @noteByUriStr.set(uri.value, note)
-    @brickLayout.addNote(note, note.onRowChange.bind(note))
-    note.initWatchers()
-
-  _delNote: (uriStr) ->
-    n = @noteByUriStr.get(uriStr)
-    @brickLayout.delNote(n)
-    @stage.removeChild(n.container)
-    n.destroy()
-    @noteByUriStr.delete(uriStr)
-            
-  onDrop: (effect, pos) ->
-    U = (x) => @graph.Uri(x)
-
-    return unless effect and effect.match(/^http/)
-
-    # we could probably accept some initial overrides right on the
-    # effect uri, maybe as query params
-
-    if not @graph.contains(effect, U('rdf:type'), U(':Effect'))
-      if @graph.contains(effect, U('rdf:type'), U(':LightSample'))
-        effect = @project.makeEffect(effect)
-      else
-        log("drop #{effect} is not an effect")
-        return
-
-    dropTime = @viewState.zoomInX.invert(pos.e(1))
-
-    desiredWidthX = @offsetWidth * .3
-    desiredWidthT = @viewState.zoomInX.invert(desiredWidthX) - @viewState.zoomInX.invert(0)
-    desiredWidthT = Math.min(desiredWidthT, @viewState.zoomSpec.duration() - dropTime)
-    @project.makeNewNote(U(@song), U(effect), dropTime, desiredWidthT)
-
-  updateInlineAttrs: (note, config) ->
-    if not config?
-      index = 0
-      for c in @inlineAttrConfigs
-        if c.uri.equals(note)
-          @splice('inlineAttrConfigs', index)
-          return
-        index += 1
-    else
-      index = 0
-      for c in @inlineAttrConfigs
-        if c.uri.equals(note)
-          @splice('inlineAttrConfigs', index, 1, config)
-          return
-        index += 1
-      @push('inlineAttrConfigs', config)
-
--- a/light9/web/timeline/TimelineEditor.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,194 +0,0 @@
-log = debug('timeline')
-debug.enable('*')
-
-Drawing = window.Drawing
-ROW_COUNT = 7
-
-
-@customElement('light9-timeline-editor')
-class TimelineEditor extends LitElement
-  @getter_properties:
-    viewState: { type: Object }
-    debug: {type: String}
-    graph: {type: Object, notify: true}
-    project: {type: Object}
-    setAdjuster: {type: Function, notify: true}
-    playerSong: {type: String, notify: true}
-    followPlayerSong: {type: Boolean, notify: true, value: true}
-    song: {type: String, notify: true}
-    show: {type: String, notify: true}
-    songTime: {type: Number, notify: true}
-    songDuration: {type: Number, notify: true}
-    songPlaying: {type: Boolean, notify: true}
-    selection: {type: Object, notify: true}
-  @getter_observers: [
-    '_onSong(playerSong, followPlayerSong)',
-    '_onGraph(graph)',
-    '_onSongDuration(songDuration, viewState)',
-    '_onSongTime(song, playerSong, songTime, viewState)',
-    '_onSetAdjuster(setAdjuster)',
-  ]
-  constructor: ->
-    super()
-    @viewState = new ViewState()
-    window.viewState = @viewState
-
-  ready: ->
-    super.ready()
-    @addEventListener 'mousedown', (ev) => @$.adjustersCanvas.onDown(ev)
-    @addEventListener 'mousemove', (ev) => @$.adjustersCanvas.onMove(ev)
-    @addEventListener 'mouseup', (ev) => @$.adjustersCanvas.onUp(ev)
-
-    ko.options.deferUpdates = true
-
-    @selection = {hover: ko.observable(null), selected: ko.observable([])}
-
-    window.debug_zoomOrLayoutChangedCount = 0
-    window.debug_adjUpdateDisplay = 0
-
-    ko.computed(@zoomOrLayoutChanged.bind(@))
-
-    @trackMouse()
-    @bindKeys()
-    @bindWheelZoom(@)
-
-    setInterval(@updateDebugSummary.bind(@), 100)
-
-    @addEventListener('iron-resize', @_onIronResize.bind(@))
-    Polymer.RenderStatus.afterNextRender(this, @_onIronResize.bind(@))
-
-    Polymer.RenderStatus.afterNextRender this, =>
-      setupDrop(@$.zoomed.$.rows, @$.zoomed.$.rows, @, @$.zoomed.onDrop.bind(@$.zoomed))
-
-  _onIronResize: ->
-    @viewState.setWidth(@offsetWidth)
-    @viewState.coveredByDiagramTop(@$.coveredByDiagram.offsetTop)
-    @viewState.rowsY(@$.zoomed.$.rows.offsetTop) if @$.zoomed?.$?.rows?
-    @viewState.audioY(@$.audio.offsetTop)
-    @viewState.audioH(@$.audio.offsetHeight)
-    if @$.zoomed?.$?.time?
-      @viewState.zoomedTimeY(@$.zoomed.$.time.offsetTop)
-      @viewState.zoomedTimeH(@$.zoomed.$.time.offsetHeight)
-
-  _onSongTime: (song, playerSong, t) ->
-    if song != playerSong
-      @viewState.cursor.t(0)
-      return
-    @viewState.cursor.t(t)
-
-  _onSongDuration: (d) ->
-    d = 700 if d < 1 # bug is that asco isn't giving duration, but 0 makes the scale corrupt
-    @viewState.zoomSpec.duration(d)
-
-  _onSong: (s) ->
-    @song = @playerSong if @followPlayerSong
-
-  _onGraph: (graph) ->
-    @project = new Project(graph)
-    @show = showRoot
-
-  _onSetAdjuster: () ->
-    @makeZoomAdjs()
-
-  updateDebugSummary: ->
-    elemCount = (tag) -> document.getElementsByTagName(tag).length
-    @debug = "#{window.debug_zoomOrLayoutChangedCount} layout change,
-     #{elemCount('light9-timeline-note')} notes,
-     #{@selection.selected().length} selected
-     #{elemCount('light9-timeline-graph-row')} rows,
-     #{window.debug_adjsCount} adjuster items registered,
-     #{window.debug_adjUpdateDisplay} adjuster updateDisplay calls,
-    "
-
-  zoomOrLayoutChanged: ->
-    vs = @viewState
-    dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2(), vs.width()]
-
-    # shouldn't need this- deps should get it
-    @$.zoomed.gatherNotes() if @$.zoomed?.gatherNotes?
-
-    # todo: these run a lot of work purely for a time change
-    if @$.zoomed?.$?.audio?
-      #@dia.setTimeAxis(vs.width(), @$.zoomed.$.audio.offsetTop, vs.zoomInX)
-      @$.adjustersCanvas.updateAllCoords()
-
-  trackMouse: ->
-    # not just for show- we use the mouse pos sometimes
-    for evName in ['mousemove', 'touchmove']
-      @addEventListener evName, (ev) =>
-        ev.preventDefault()
-
-        # todo: consolidate with _editorCoordinates version
-        if ev.touches?.length
-          ev = ev.touches[0]
-
-        root = @$.cursorCanvas.getBoundingClientRect()
-        @viewState.mouse.pos($V([ev.pageX - root.left, ev.pageY - root.top]))
-
-        # should be controlled by a checkbox next to follow-player-song-choice
-        @sendMouseToVidref() unless window.location.hash.match(/novidref/)
-
-  sendMouseToVidref: ->
-    now = Date.now()
-    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
-      @$.vidrefTime.body = {t: @viewState.latestMouseTime(), source: 'timeline', song: @song}
-      @$.vidrefTime.generateRequest()
-      @$.vidrefLastSent = now
-
-  bindWheelZoom: (elem) ->
-    elem.addEventListener 'mousewheel', (ev) =>
-      @viewState.onMouseWheel(ev.deltaY)
-
-  bindKeys: ->
-    shortcut.add "Ctrl+P", (ev) =>
-      @$.music.seekPlayOrPause(@viewState.latestMouseTime())
-    shortcut.add "Ctrl+Escape", => @viewState.frameAll()
-    shortcut.add "Shift+Escape", => @viewState.frameToEnd()
-    shortcut.add "Escape", => @viewState.frameCursor()
-    shortcut.add "L", =>
-      @$.adjustersCanvas.updateAllCoords()
-    shortcut.add 'Delete', =>
-      for note in @selection.selected()
-        @project.deleteNote(@graph.Uri(@song), note, @selection)
-
-  makeZoomAdjs: ->
-    yMid = => @$.audio.offsetTop + @$.audio.offsetHeight / 2
-
-    valForPos = (pos) =>
-      x = pos.e(1)
-      t = @viewState.fullZoomX.invert(x)
-    @setAdjuster('zoom-left', => new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t1,
-      getTarget: () =>
-        $V([@viewState.fullZoomX(@viewState.zoomSpec.t1()), yMid()])
-      getSuggestedTargetOffset: () => $V([-50, 10])
-      getValueForPos: valForPos
-    }))
-
-    @setAdjuster('zoom-right', => new AdjustableFloatObservable({
-      observable: @viewState.zoomSpec.t2,
-      getTarget: () =>
-        $V([@viewState.fullZoomX(@viewState.zoomSpec.t2()), yMid()])
-      getSuggestedTargetOffset: () => $V([50, 10])
-      getValueForPos: valForPos
-    }))
-
-    panObs = ko.pureComputed({
-      read: () =>
-        (@viewState.zoomSpec.t1() + @viewState.zoomSpec.t2()) / 2
-      write: (value) =>
-        zs = @viewState.zoomSpec
-        span = zs.t2() - zs.t1()
-        zs.t1(value - span / 2)
-        zs.t2(value + span / 2)
-    })
-
-    @setAdjuster('zoom-pan', => new AdjustableFloatObservable({
-      observable: panObs
-      emptyBox: true
-      # fullzoom is not right- the sides shouldn't be able to go
-      # offscreen
-      getTarget: () => $V([@viewState.fullZoomX(panObs()), yMid()])
-      getSuggestedTargetOffset: () => $V([0, 0])
-      getValueForPos: valForPos
-    }))
--- a/light9/web/timeline/adjustable.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,269 +0,0 @@
-import * as d3 from "d3";
-import { debug } from "debug";
-import * as ko from "knockout";
-const log = debug("adjustable");
-
-interface Config {
-  //   getTarget -> vec2 of current target position
-  getTarget: () => Vector;
-  //   getSuggestedTargetOffset -> vec2 pixel offset from target
-  getSuggestedTargetOffset: () => Vector;
-  //   emptyBox -> true if you want no value display
-  emptyBox: boolean;
-}
-
-export class Adjustable {
-  config: any;
-  handle: any;
-  initialTarget: any;
-  targetDraggedTo: any;
-  root: any;
-  // Some value you can edit in the UI, probably by dragging
-  // stuff. Drawn by light9-adjusters-canvas. This object does the
-  // layout and positioning.
-  //
-  // The way dragging should work is that you start in the yellow *adj
-  // widget*, wherever it is, but your drag is moving the *target*. The
-  // adj will travel around too, but it may do extra moves to not bump
-  // into stuff or to get out from under your finger.
-
-  constructor(config: any) {
-    this.config = config;
-    this.ctor2();
-  }
-
-  ctor2() {
-    // updated later by layout algoritm
-    return (this.handle = $V([0, 0]));
-  }
-
-  getDisplayValue() {
-    if (this.config.emptyBox) {
-      return "";
-    }
-    const defaultFormat = d3.format(".4g")(this._getValue());
-    if (this.config.getDisplayValue != null) {
-      return this.config.getDisplayValue(this._getValue(), defaultFormat);
-    }
-    return defaultFormat;
-  }
-  _getValue(): any {
-    throw new Error("Method not implemented.");
-  }
-
-  getSuggestedHandle() {
-    return this.getTarget().add(this.config.getSuggestedTargetOffset());
-  }
-
-  getHandle() {
-    // vec2 of pixels
-    return this.handle;
-  }
-
-  getTarget() {
-    // vec2 of pixels
-    return this.config.getTarget();
-  }
-
-  subscribe(onChange: any) {
-    // change could be displayValue or center or target. This likely
-    // calls onChange right away if there's any data yet.
-    throw new Error("not implemented");
-  }
-
-  startDrag() {
-    return (this.initialTarget = this.getTarget());
-  }
-
-  continueDrag(pos: { add: (arg0: any) => any }) {
-    //# pos is vec2 of pixels relative to the drag start
-    return (this.targetDraggedTo = pos.add(this.initialTarget));
-  }
-
-  endDrag() {}
-  // override
-
-  _editorCoordinates() {
-    // vec2 of mouse relative to <l9-t-editor>
-    let rootElem: { getBoundingClientRect: () => any };
-    return this.targetDraggedTo;
-    // let ev = d3.event.sourceEvent;
-
-    // if (ev.target.tagName === "LIGHT9-TIMELINE-EDITOR") {
-    //   rootElem = ev.target;
-    // } else {
-    //   rootElem = ev.target.closest("light9-timeline-editor");
-    // }
-
-    // if (ev.touches != null ? ev.touches.length : undefined) {
-    //   ev = ev.touches[0];
-    // }
-
-    // // storing root on the object to remember it across calls in case
-    // // you drag outside the editor.
-    // if (rootElem) {
-    //   this.root = rootElem.getBoundingClientRect();
-    // }
-    // const offsetParentPos = $V([ev.pageX - this.root.left, ev.pageY - this.root.top]);
-
-    // return offsetParentPos;
-  }
-}
-
-class AdjustableFloatObservable extends Adjustable {
-  constructor(config: any) {
-    // config also has:
-    //   observable -> ko.observable we will read and write
-    //   getValueForPos(pos) -> what should we set to if the user
-    //                          moves target to this coord?
-    this.config = config;
-    super();
-    this.ctor2();
-  }
-
-  _getValue() {
-    return this.config.observable();
-  }
-
-  continueDrag(pos: any) {
-    // pos is vec2 of pixels relative to the drag start.
-    super.continueDrag(pos);
-    const epos = this._editorCoordinates();
-    const newValue = this.config.getValueForPos(epos);
-    return this.config.observable(newValue);
-  }
-
-  subscribe(onChange: () => any) {
-    log("AdjustableFloatObservable subscribe", this.config);
-    return ko.computed(() => {
-      this.config.observable();
-      return onChange();
-    });
-  }
-}
-
-class AdjustableFloatObject extends Adjustable {
-  _currentValue: any;
-  _onChange: any;
-  constructor(config: any) {
-    // config also has:
-    //   graph
-    //   subj
-    //   pred
-    //   ctx
-    //   getTargetPosForValue(value) -> getTarget result for value
-    //   getValueForPos
-    this.config = config;
-    super();
-    this.ctor2();
-    if (this.config.ctx == null) {
-      throw new Error("missing ctx");
-    }
-    // this seems to not fire enough.
-    this.config.graph.runHandler(this._syncValue.bind(this), `adj sync ${this.config.subj.value} ${this.config.pred.value}`);
-  }
-
-  _syncValue() {
-    this._currentValue = this.config.graph.floatValue(this.config.subj, this.config.pred);
-    if (this._onChange) {
-      return this._onChange();
-    }
-  }
-
-  _getValue() {
-    // this is a big speedup- callers use _getValue about 4x as much as
-    // the graph changes and graph.floatValue is slow
-    return this._currentValue;
-  }
-
-  getTarget() {
-    return this.config.getTargetPosForValue(this._getValue());
-  }
-
-  subscribe(onChange: any) {
-    // only works on one subscription at a time
-    if (this._onChange) {
-      throw new Error("multi subscribe not implemented");
-    }
-    return (this._onChange = onChange);
-  }
-
-  continueDrag(pos: any) {
-    // pos is vec2 of pixels relative to the drag start
-    super.continueDrag(pos);
-    const newValue = this.config.getValueForPos(this._editorCoordinates());
-
-    return this.config.graph.patchObject(this.config.subj, this.config.pred, this.config.graph.LiteralRoundedFloat(newValue), this.config.ctx);
-    //@_syncValue()
-  }
-}
-
-class AdjustableFade extends Adjustable {
-  yForV: any;
-  zoomInX: any;
-  i0: any;
-  i1: any;
-  note: any;
-  constructor(yForV: any, zoomInX: any, i0: any, i1: any, note: any, offset: any, ctx: any) {
-    this.yForV = yForV;
-    this.zoomInX = zoomInX;
-    this.i0 = i0;
-    this.i1 = i1;
-    this.note = note;
-    super();
-    this.config = {
-      getSuggestedTargetOffset() {
-        return offset;
-      },
-      getTarget: this.getTarget.bind(this),
-      ctx,
-    };
-    this.ctor2();
-  }
-
-  getTarget() {
-    const mid = this.note.midPoint(this.i0, this.i1);
-    return $V([this.zoomInX(mid.e(1)), this.yForV(mid.e(2))]);
-  }
-
-  _getValue() {
-    return this.note.midPoint(this.i0, this.i1).e(1);
-  }
-
-  continueDrag(pos: { e: (arg0: number) => any }) {
-    // pos is vec2 of pixels relative to the drag start
-    super.continueDrag(pos);
-    const { graph } = this.note;
-    const U = (x: string) => graph.Uri(x);
-
-    const goalCenterSec = this.zoomInX.invert(this.initialTarget.e(1) + pos.e(1));
-
-    const diamSec = this.note.worldPts[this.i1].e(1) - this.note.worldPts[this.i0].e(1);
-    const newSec0 = goalCenterSec - diamSec / 2;
-    const newSec1 = goalCenterSec + diamSec / 2;
-
-    const originSec = graph.floatValue(this.note.uri, U(":originTime"));
-
-    const p0 = this._makePatch(graph, this.i0, newSec0, originSec, this.config.ctx);
-    const p1 = this._makePatch(graph, this.i1, newSec1, originSec, this.config.ctx);
-
-    return graph.applyAndSendPatch(this._addPatches(p0, p1));
-  }
-
-  _makePatch(
-    graph: { getObjectPatch: (arg0: any, arg1: any, arg2: any, arg3: any) => any; Uri: (arg0: string) => any; LiteralRoundedFloat: (arg0: number) => any },
-    idx: string | number,
-    newSec: number,
-    originSec: number,
-    ctx: any
-  ) {
-    return graph.getObjectPatch(this.note.worldPts[idx].uri, graph.Uri(":time"), graph.LiteralRoundedFloat(newSec - originSec), ctx);
-  }
-
-  _addPatches(p0: { addQuads: { concat: (arg0: any) => any }; delQuads: { concat: (arg0: any) => any } }, p1: { addQuads: any; delQuads: any }) {
-    return {
-      addQuads: p0.addQuads.concat(p1.addQuads),
-      delQuads: p0.delQuads.concat(p1.delQuads),
-    };
-  }
-}
--- a/light9/web/timeline/adjusters.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,273 +0,0 @@
-import { debug } from "debug";
-import { LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
-import { throttle } from "underscore";
-import * as d3 from "d3";
-import { Adjustable } from "./adjustable";
-import * as Drawing from "../drawing";
-// https://www.npmjs.com/package/@types/sylvester Global values: $L, $M, $P, $V, Line, Matrix, Plane, Sylvester, Vector
-const log = debug("adjusters");
-
-const maxDist = 60;
-
-interface Drag {
-  start: Vector;
-  adj: Adjustable;
-  cur?: Vector;
-}
-type QTreeData = Vector & { adj: Adjustable };
-@customElement("light9-adjusters-canvas")
-class AdjustersCanvas extends LitElement {
-  static getter_properties: { setAdjuster: { type: any; notify: boolean } };
-  static getter_observers: {};
-  redraw: any;
-  adjs: { [id: string | number]: Adjustable };
-  hoveringNear: any;
-  ctx: any;
-  $: any;
-  setAdjuster: any;
-  offsetParent: any;
-  currentDrag?: Drag;
-  qt?: d3.Quadtree<QTreeData>;
-  canvasCenter: any;
-  static initClass() {
-    this.getter_properties = { setAdjuster: { type: Function, notify: true } };
-    this.getter_observers = ["updateAllCoords(adjs)"];
-  }
-  constructor() {
-    super();
-    this.redraw = throttle(this._throttledRedraw.bind(this), 30, { leading: false });
-    this.adjs = {};
-    this.hoveringNear = null;
-  }
-
-  ready() {
-    this.addEventListener("iron-resize", this.resizeUpdate.bind(this));
-    this.ctx = this.$.canvas.getContext("2d");
-
-    this.redraw();
-    this.setAdjuster = this._setAdjuster.bind(this);
-
-    // These don't fire; TimelineEditor calls the handlers for us.
-    this.addEventListener("mousedown", this.onDown.bind(this));
-    this.addEventListener("mousemove", this.onMove.bind(this));
-    return this.addEventListener("mouseup", this.onUp.bind(this));
-  }
-  addEventListener(arg0: string, arg1: any) {
-    throw new Error("Method not implemented.");
-  }
-
-  _mousePos(ev: MouseEvent) {
-    return $V([ev.clientX, ev.clientY - this.offsetParent.offsetTop]);
-  }
-
-  onDown(ev: MouseEvent) {
-    if (ev.buttons === 1) {
-      const start = this._mousePos(ev);
-      const adj = this._adjAtPoint(start);
-      if (adj) {
-        ev.stopPropagation();
-        this.currentDrag = { start, adj };
-        return adj.startDrag();
-      }
-    }
-  }
-
-  onMove(ev: MouseEvent) {
-    const pos = this._mousePos(ev);
-    if (this.currentDrag) {
-      this.hoveringNear = null;
-      this.currentDrag.cur = pos;
-      this.currentDrag.adj.continueDrag(this.currentDrag.cur.subtract(this.currentDrag.start));
-      this.redraw();
-    } else {
-      const near = this._adjAtPoint(pos);
-      if (this.hoveringNear !== near) {
-        this.hoveringNear = near;
-        this.redraw();
-      }
-    }
-  }
-
-  onUp(ev: any) {
-    if (!this.currentDrag) {
-      return;
-    }
-    this.currentDrag.adj.endDrag();
-    this.currentDrag = undefined;
-  }
-
-  _setAdjuster(adjId: string | number, makeAdjustable?: () => Adjustable) {
-    // callers register/unregister the Adjustables they want us to make
-    // adjuster elements for. Caller invents adjId.  makeAdjustable is
-    // a function returning the Adjustable or it is undefined to clear any
-    // adjusters with this id.
-    if (makeAdjustable == null) {
-      if (this.adjs[adjId]) {
-        delete this.adjs[adjId];
-      }
-    } else {
-      // this might be able to reuse an existing one a bit
-      const adj = makeAdjustable();
-      this.adjs[adjId] = adj;
-      adj.id = adjId;
-    }
-
-    this.redraw();
-
-    (window as any).debug_adjsCount = Object.keys(this.adjs).length;
-  }
-
-  updateAllCoords() {
-    this.redraw();
-  }
-
-  _adjAtPoint(pt: Vector): Adjustable|undefined {
-    const nearest = this.qt!.find(pt.e(1), pt.e(2));
-    if (nearest == null || nearest.distanceFrom(pt) > maxDist) {
-      return undefined;
-    }
-    return nearest != null ? nearest.adj : undefined;
-  }
-
-  resizeUpdate(ev: { target: { offsetWidth: any; offsetHeight: any } }) {
-    this.$.canvas.width = ev.target.offsetWidth;
-    this.$.canvas.height = ev.target.offsetHeight;
-    this.canvasCenter = $V([this.$.canvas.width / 2, this.$.canvas.height / 2]);
-    return this.redraw();
-  }
-
-  _throttledRedraw() {
-    if (this.ctx == null) {
-      return;
-    }
-    console.time("adjs redraw");
-    this._layoutCenters();
-
-    this.ctx.clearRect(0, 0, this.$.canvas.width, this.$.canvas.height);
-
-    for (let adjId in this.adjs) {
-      const adj = this.adjs[adjId];
-      const ctr = adj.getHandle();
-      const target = adj.getTarget();
-      if (this._isOffScreen(target)) {
-        continue;
-      }
-      this._drawConnector(ctr, target);
-
-      this._drawAdjuster(adj.getDisplayValue(), ctr.e(1) - 20, ctr.e(2) - 10, ctr.e(1) + 20, ctr.e(2) + 10, adj === this.hoveringNear);
-    }
-    return console.timeEnd("adjs redraw");
-  }
-
-  _layoutCenters() {
-    // push Adjustable centers around to avoid overlaps
-    // Todo: also don't overlap inlineattr boxes
-    // Todo: don't let their connector lines cross each other
-    const qt = d3.quadtree<QTreeData>(
-      [],
-      (d: QTreeData) => d.e(1),
-      (d: QTreeData) => d.e(2)
-    );
-    this.qt = qt;
-
-    qt.extent([
-      [0, 0],
-      [8000, 8000],
-    ]);
-
-    let _: string | number, adj: { handle: any; getSuggestedHandle: () => any };
-    for (_ in this.adjs) {
-      adj = this.adjs[_];
-      adj.handle = this._clampOnScreen(adj.getSuggestedHandle());
-    }
-
-    const numTries = 8;
-    for (let tryn = 0; tryn < numTries; tryn++) {
-      for (_ in this.adjs) {
-        adj = this.adjs[_];
-        let current = adj.handle;
-        qt.remove(current);
-        const nearest = qt.find(current.e(1), current.e(2), maxDist);
-        if (nearest) {
-          const dist = current.distanceFrom(nearest);
-          if (dist < maxDist) {
-            current = this._stepAway(current, nearest, 1 / numTries);
-            adj.handle = current;
-          }
-        }
-        current.adj = adj;
-        qt.add(current);
-      }
-    }
-    //if -50 < output.e(1) < 20 # mostly for zoom-left
-    //  output.setElements([
-    //    Math.max(20, output.e(1)),
-    //    output.e(2)])
-  }
-
-
-  _stepAway(
-    current: Vector,
-    nearest: Vector,
-    dx: number
-  ) {
-    const away = current.subtract(nearest).toUnitVector();
-    const toScreenCenter = this.canvasCenter.subtract(current).toUnitVector();
-    const goalSpacingPx = 20;
-    return this._clampOnScreen(current.add(away.x(goalSpacingPx * dx)));
-  }
-
-  _isOffScreen(pos: Vector):boolean {
-    return pos.e(1) < 0 || pos.e(1) > this.$.canvas.width || pos.e(2) < 0 || pos.e(2) > this.$.canvas.height;
-  }
-
-  _clampOnScreen(pos: Vector): Vector {
-    const marg = 30;
-    return $V([Math.max(marg, Math.min(this.$.canvas.width - marg, pos.e(1))), Math.max(marg, Math.min(this.$.canvas.height - marg, pos.e(2)))]);
-  }
-
-  _drawConnector(ctr: Vector, target: Vector) {
-    this.ctx.strokeStyle = "#aaa";
-    this.ctx.lineWidth = 2;
-    this.ctx.beginPath();
-    Drawing.line(this.ctx, ctr, target);
-    this.ctx.stroke();
-  }
-
-  _drawAdjuster(label: any, x1: number, y1: number, x2: number, y2: number, hover: boolean) {
-    const radius = 8;
-
-    this.ctx.shadowColor = "black";
-    this.ctx.shadowBlur = 15;
-    this.ctx.shadowOffsetX = 5;
-    this.ctx.shadowOffsetY = 9;
-
-    this.ctx.fillStyle = hover ? "#ffff88" : "rgba(255, 255, 0, 0.5)";
-    this.ctx.beginPath();
-    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
-    this.ctx.fill();
-
-    this.ctx.shadowColor = "rgba(0,0,0,0)";
-
-    this.ctx.strokeStyle = "yellow";
-    this.ctx.lineWidth = 2;
-    this.ctx.setLineDash([3, 3]);
-    this.ctx.beginPath();
-    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
-    this.ctx.stroke();
-    this.ctx.setLineDash([]);
-
-    this.ctx.font = "12px sans";
-    this.ctx.fillStyle = "#000";
-    this.ctx.fillText(label, x1 + 5, y2 - 5, x2 - x1 - 10);
-
-    // coords from a center that's passed in
-    // # special layout for the thaeter ones with middinh
-    // l/r arrows
-    // mouse arrow cursor upon hover, and accent the hovered adjuster
-    // connector
-  }
-}
-
-
--- a/light9/web/timeline/brick_layout.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,95 +0,0 @@
-import { debug } from "debug";
-import { sortBy } from "underscore";
-import { ViewState } from "viewstate";
-const log = debug("brick");
-
-interface Placement {
-  row?: number;
-  prev?: number;
-  t0: number;
-  t1: number;
-  onRowChange: () => void;
-}
-
-export class BrickLayout {
-  viewState: ViewState;
-  numRows: number;
-  noteRow: { [uri: string]: Placement };
-  constructor(viewState: ViewState, numRows: number) {
-    this.viewState = viewState;
-    this.numRows = numRows;
-    this.noteRow = {}; // uristr: row, t0, t1, onRowChange
-  }
-
-  addNote(n: { uri: { value: string } }, onRowChange: any) {
-    this.noteRow[n.uri.value] = { row: 0, t0: 0, t1: 0, onRowChange };
-  }
-
-  setNoteSpan(n: { uri: { value: string } }, t0: any, t1: any) {
-    this.noteRow[n.uri.value].t0 = t0;
-    this.noteRow[n.uri.value].t1 = t1;
-    this._recompute();
-  }
-
-  delNote(n: { uri: { value: string } }) {
-    delete this.noteRow[n.uri.value];
-    this._recompute();
-  }
-
-  _recompute() {
-    for (let u in this.noteRow) {
-      const row = this.noteRow[u];
-      row.prev = row.row;
-      row.row = undefined;
-    }
-    const overlap = (a: Placement, b: Placement) => a.t0 < b.t1 && a.t1 > b.t0;
-
-    const result = [];
-    for (let u in this.noteRow) {
-      const row = this.noteRow[u];
-      result.push({ dur: row.t1 - row.t0 + row.t0 * 0.0001, uri: u });
-    }
-    const notesByWidth = sortBy(result, "dur");
-    notesByWidth.reverse();
-
-    for (let n of Array.from(notesByWidth)) {
-      const blockedRows = new Set();
-      for (let u in this.noteRow) {
-        const other = this.noteRow[u];
-        if (other.row !== null) {
-          if (overlap(other, this.noteRow[n.uri])) {
-            blockedRows.add(other.row);
-          }
-        }
-      }
-
-      for (let r = 0; r < this.numRows; r++) {
-        if (!blockedRows.has(r)) {
-          this.noteRow[n.uri].row = r;
-          break;
-        }
-      }
-      if (this.noteRow[n.uri].row === null) {
-        log(`warning: couldn't place ${n.uri}`);
-        this.noteRow[n.uri].row = 0;
-      }
-      if (this.noteRow[n.uri].row !== this.noteRow[n.uri].prev) {
-        this.noteRow[n.uri].onRowChange();
-      }
-    }
-  }
-
-  rowBottom(row: number) {
-    return this.viewState.rowsY() + 20 + 150 * row + 140;
-  }
-
-  yForVFor(n: { uri: { value: string } }) {
-    const row = this.noteRow[n.uri.value].row;
-    if (row === undefined) {
-      throw new Error();
-    }
-    const rowBottom = this.rowBottom(row);
-    const rowTop = rowBottom - 140;
-    return (v: number) => rowBottom + (rowTop - rowBottom) * v;
-  }
-}
--- a/light9/web/timeline/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>timeline</title>
-    <meta charset="utf-8">
-    <script type="module" src="./timeline/timeline-elements.ts"></script>
-  </head>
-  <body>
-    <light9-timeline-editor style="position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;">
-    </light9-timeline-editor>
-  </body>
-</html>
--- a/light9/web/timeline/inline-attrs.coffee	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,88 +0,0 @@
-log = debug('attrs')
-debug.enable('*')
-
-coffeeElementSetup(class InlineAttrs extends Polymer.Element
-  @is: "light9-timeline-note-inline-attrs"
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    project: { type: Object, notify: true }
-    song: { type: String, notify: true }
-    config: { type: Object } # just for setup
-    uri: { type: Object, notify: true }  # the Note
-    effectStr: { type: String, notify: true }
-    colorScale: { type: String, notify: true }
-    noteLabel: { type: String, notify: true }
-    selection: { type: Object, notify: true }
-  @getter_observers: [
-    '_onConfig(config)'
-    'addHandler(graph, uri)'
-    'onColorScale(graph, uri, colorScale)'
-    ]
-
-  ready: ->
-    super.ready()
-    @$.effect.addEventListener 'edited', =>
-      @graph.patchObject(@uri, @graph.Uri(':effectClass'), @graph.Uri(@effectStr), @graph.Uri(@song))
-      
-  _onConfig: ->
-    @uri = @config.uri
-    for side in ['top', 'left', 'width', 'height']
-      @.style[side] = @config[side] + 'px'
-    
-  addHandler: ->
-    return unless @uri
-    @graph.runHandler(@update.bind(@), "update inline attrs #{@uri.value}")
-    
-  onColorScale: ->
-    return unless @uri? and @colorScale? and @colorScaleFromGraph?
-    U = (x) => @graph.Uri(x)
-    if @colorScale == @colorScaleFromGraph
-      return
-    @editAttr(@uri, U(':colorScale'), @graph.Literal(@colorScale))
-
-  editAttr: (note, attr, value) ->
-    U = (x) => @graph.Uri(x)
-    if not @song?
-      log("inline: can't edit inline attr yet, no song")
-      return
-
-    existingColorScaleSetting = null
-    for setting in @graph.objects(note, U(':setting'))
-      ea = @graph.uriValue(setting, U(':effectAttr'))
-      if ea.equals(attr)
-        existingColorScaleSetting = setting
-        
-    if existingColorScaleSetting
-      log('inline: update setting', existingColorScaleSetting.value)
-      @graph.patchObject(existingColorScaleSetting, U(':value'), value, U(@song))
-    else
-      log('inline: new setting')
-      setting = @graph.nextNumberedResource(note.value + 'set')
-      patch = {delQuads: [], addQuads: [
-        @graph.Quad(note, U(':setting'), setting, U(@song))
-        @graph.Quad(setting, U(':effectAttr'), attr, U(@song))
-        @graph.Quad(setting, U(':value'), value, U(@song))
-        ]}
-      @graph.applyAndSendPatch(patch)
-    
-  update: ->
-    console.time('attrs update')
-    U = (x) => @graph.Uri(x)
-    @effectStr = @graph.uriValue(@uri, U(':effectClass'))?.value
-    @noteLabel = @uri.value.replace(/.*\//, '')
-    existingColorScaleSetting = null
-    for setting in @graph.objects(@uri, U(':setting'))
-      ea = @graph.uriValue(setting, U(':effectAttr'))
-      value = @graph.stringValue(setting, U(':value'))
-      if ea.equals(U(':colorScale'))
-        @colorScaleFromGraph = value
-        @colorScale = value
-        existingColorScaleSetting = setting
-    if existingColorScaleSetting == null
-      @colorScaleFromGraph = '#ffffff'
-      @colorScale = '#ffffff'
-    console.timeEnd('attrs update')
-
-  onDel: ->
-    @project.deleteNote(@graph.Uri(@song), @uri, @selection)
-)
--- a/light9/web/timeline/inline-attrs.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer-element.html">
-<link rel="import" href="../light9-color-picker.html">
-<link rel="import" href="../edit-choice.html">
-
-<!-- sometimes we draw attrs within the shape of a note. -->
-<dom-module id="light9-timeline-note-inline-attrs">
-  <template>
-    <style>
-     :host {
-         position: absolute;
-
-         display: block;
-         overflow: hidden;
-         background: rgba(19, 19, 19, 0.65);
-         border-radius: 6px;
-         border: 1px solid #313131;
-         padding: 3px;
-         z-index: 2;
-         color: white;
-     }
-    </style>
-
-      <div>note [[noteLabel]] <button on-click="onDel">del</button></div>
-      <table>
-        <tr><th>effect:</th><td><edit-choice id="effect" graph="{{graph}}" uri="{{effectStr}}"></edit-choice></td></tr>
-        <tr><th>colorScale:</th><td>
-          <light9-color-picker color="{{colorScale}}"></light9-color-picker>
-        </td></tr>
-      </table>
-      <img src="/show/dance2019/anim/rainbow1.png">
-  </template>
-  <script src="inline-attrs.js"></script>
-</dom-module>
--- a/light9/web/timeline/timeline-elements.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,248 +0,0 @@
-console.log("hi tl")
-import { debug } from "debug";
-import { css, html, LitElement, TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export {Light9TimelineAudio} from "../light9-timeline-audio"
-debug.enable("*");
-/*
- <link rel="import" href="/lib/polymer/polymer.html">
-<link rel="import" href="/lib/polymer/lib/utils/render-status.html">
-<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-<link rel="import" href="light9-timeline-audio.html">
-<link rel="import" href="../rdfdb-synced-graph.html">
-<link rel="import" href="../light9-music.html">
-<link rel="import" href="../edit-choice.html">
-<link rel="import" href="inline-attrs.html">
-    <script src="/websocket.js"></script>
-<script type="module" src="/light9-vidref-replay.js"></script>
-
-<script type="module" src="/light9-vidref-replay-stack.js"></script>
-
-*/
-
-// Whole editor- include this on your page.
-// Most coordinates are relative to this element.
-@customElement("light9-timeline-editor")
-export class Light9TimelineEditor extends LitElement {
-  render() {
-    return html`
-    <style>
-     :host {
-         background: #444;
-         display: flex;
-         flex-direction: column;
-         position: relative;
-         border: 1px solid black;
-         overflow: hidden;
-     }
-     light9-timeline-audio {
-         width: 100%;
-         height: 30px;
-     }
-     light9-timeline-time-zoomed {
-         flex-grow: 1;
-     }
-     #coveredByDiagram {
-         position: relative;
-         display: flex;
-         flex-direction: column;
-         height: 100%;
-     }
-     #dia, #adjusters, #cursorCanvas, #adjustersCanvas {
-         position: absolute;
-         left: 0; top: 0; right: 0; bottom: 0;
-     }
-     #debug {
-         background: white;
-         font-family: monospace;
-         font-size: 125%;
-         height: 15px;
-     }
-     light9-vidref-replay-stack {
-             position: absolute;
-             bottom: 10px;
-             width: 50%;
-             background: gray;
-             box-shadow: 6px 10px 12px #0000006b;
-             display: inline-block;
-     }
-    </style>
-    <div>
-      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-      <light9-music id="music"
-                    song="{{playerSong}}"
-                    t="{{songTime}}"
-                    playing="{{songPlaying}}"
-                    duration="{{songDuration}}"></light9-music>
-      timeline editor: song <edit-choice graph="{{graph}}" uri="{{song}}"></edit-choice>
-      <label><input type="checkbox" checked="{{followPlayerSong::change}}" > follow player song choice</label>
-    </div>
-    <div id="debug">[[debug]]</div>
-    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
-    <div id="coveredByDiagram">
-      <light9-timeline-audio id="audio"
-                             graph="{{graph}}"
-                             show="{{show}}"
-                             song="{{song}}"></light9-timeline-audio>
-      <light9-timeline-time-zoomed id="zoomed"
-                                   graph="{{graph}}"
-                                   project="{{project}}"
-                                   selection="{{selection}}"
-                                   set-adjuster="{{setAdjuster}}"
-                                   song="{{song}}"
-                                   show="{{show}}"
-                                   view-state="{{viewState}}">
-      </light9-timeline-time-zoomed>
-      <light9-adjusters-canvas id="adjustersCanvas" set-adjuster="{{setAdjuster}}">
-      </light9-adjusters-canvas>
-      <light9-cursor-canvas id="cursorCanvas" view-state="{{viewState}}"></light9-cursor-canvas>
-      <light9-vidref-replay-stack size="small"></light9-vidref-replay-stack>
-    </div>
-`;
-  }
-}
-
-// the whole section that pans/zooms in time (most of the editor)
-@customElement("light9-timeline-time-zoomed")
-export class Light9TimelineTimeZoomed extends LitElement {
-  render() {
-    return html`
-    <style>
-     :host {
-         display: flex;
-         height: 100%;
-         flex-direction: column;
-     }
-     #top {
-     }
-     #rows {
-         height: 100%;
-         overflow: hidden;
-     }
-     #rows.dragging {
-         background: rgba(126, 52, 245, 0.0784);
-     }
-     light9-timeline-time-axis {
-     }
-     light9-timeline-audio {
-         width: 100%;
-         height: 100px;
-     }
-     light9-timeline-graph-row {
-         flex-grow: 1;
-     }
-    </style>
-    <div id="top">
-      <light9-timeline-time-axis id="time" view-state="{{viewState}}"></light9-timeline-time-axis>
-      <light9-timeline-audio id="audio"
-                             graph="{{graph}}"
-                             song="{{song}}"
-                             show="{{show}}"
-                             zoom="{{zoomFlattened}}">
-      </light9-timeline-audio>
-    </div>
-    <div id="rows"></div>
-    <template is="dom-repeat" items="{{imageSamples}}">
-      <img src="/show/dance2019/anim/rainbow1.png">
-    </template>
-    <template is="dom-repeat" items="{{inlineAttrConfigs}}">
-      <light9-timeline-note-inline-attrs graph="{{graph}}"
-                                         project="{{project}}"
-                                         selection="{{selection}}"
-                                         song="{{song}}"
-                                         config="{{item}}">
-      </light9-timeline-note-inline-attrs>
-    </template>
-`;
-  } 
-}
-
-@customElement("light9-cursor-canvas")
-export class Light9CursorCanvas extends LitElement {
-  render() {
-    return html`
-      <style>
-        #canvas,
-        :host {
-          pointer-events: none;
-        }
-      </style>
-      <canvas id="canvas"></canvas>
-    `;
-  }
-}
-
-@customElement("light9-adjusters-canvas")
-export class Light9AdjustersCanvas extends LitElement {
-  render() {
-    return html`
-      <style>
-        :host {
-          pointer-events: none;
-        }
-      </style>
-      <canvas id="canvas"></canvas>
-    `;
-  }
-}      
-
-// seconds labels
-@customElement("light9-timeline-time-axis")
-export class Light9TimelineTimeAxis extends LitElement {
-  render() {
-    return html`
-      <style>
-        :host {
-          display: block;
-        }
-        div {
-          width: 100%;
-          height: 31px;
-        }
-        svg {
-          width: 100%;
-          height: 30px;
-        }
-      </style>
-      <svg id="timeAxis" xmlns="http://www.w3.org/2000/svg">
-        <style>
-          text {
-            fill: white;
-            color: white;
-            font-size: 135%;
-            font-weight: bold;
-          }
-        </style>
-        <g id="axis" transform="translate(0,30)"></g>
-      </svg>
-    `;
-  }
-}
-
-// All the adjusters you can edit or select. Tells a light9-adjusters-canvas how to draw them. Probabaly doesn't need to be an element.
-//  This element manages their layout and suppresion.
-//  Owns the selection.
-//  Maybe includes selecting things that don't even have adjusters.
-//  Maybe manages the layout of other labels and text too, to avoid overlaps.
-@customElement("light9-timeline-adjusters")
-export class Light9TimelineAdjusters extends LitElement {
-  render() {
-    return html`
-      <style>
-        :host {
-          pointer-events: none; /* restored on the individual adjusters */
-        }
-      </style>
-    `;
-  }
-}
-
-/*
-<script src="/lib/async/dist/async.js"></script>
-<script src="/lib/shortcut/index.js"></script>
-<script src="/lib/underscore/underscore-min.js"></script>
-<script src="/node_modules/n3/n3-browser.js"></script> 
-<script src="/node_modules/pixi.js/dist/pixi.min.js"></script>
-<script src="/node_modules/tinycolor2/dist/tinycolor-min.js"></script>
-*/
--- a/light9/web/timeline/viewstate.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,128 +0,0 @@
-import * as ko from "knockout";
-import * as d3 from "d3";
-import debug from "debug";
-
-const log = debug("viewstate");
-export class ViewState {
-  zoomSpec: {
-    duration: ko.Observable<number>; // current song duration
-    t1: ko.Observable<number>;
-    t2: ko.Observable<number>;
-  };
-  cursor: { t: ko.Observable<number> };
-  mouse: { pos: ko.Observable<Vector> };
-  width: ko.Observable<number>;
-  coveredByDiagramTop: ko.Observable<number>;
-  audioY: ko.Observable<number>;
-  audioH: ko.Observable<number>;
-  zoomedTimeY: ko.Observable<number>;
-  zoomedTimeH: ko.Observable<number>;
-  rowsY: ko.Observable<number>;
-  fullZoomX: d3.ScaleLinear<number, number>;
-  zoomInX: d3.ScaleLinear<number, number>;
-  zoomAnimSec: number;
-  constructor() {
-    // caller updates all these observables
-    this.zoomSpec = {
-      duration: ko.observable(100), // current song duration
-      t1: ko.observable(0),
-      t2: ko.observable(100),
-    };
-    this.cursor = { t: ko.observable(20) }; // songTime
-    this.mouse = { pos: ko.observable($V([0, 0])) };
-    this.width = ko.observable(500);
-    this.coveredByDiagramTop = ko.observable(0); // page coords
-    // all these are relative to #coveredByDiagram:
-    this.audioY = ko.observable(0);
-    this.audioH = ko.observable(0);
-    this.zoomedTimeY = ko.observable(0);
-    this.zoomedTimeH = ko.observable(0);
-    this.rowsY = ko.observable(0);
-
-    this.fullZoomX = d3.scaleLinear();
-    this.zoomInX = d3.scaleLinear();
-
-    this.zoomAnimSec = 0.1;
-
-    ko.computed(this.maintainZoomLimitsAndScales.bind(this));
-  }
-
-  setWidth(w: any) {
-    this.width(w);
-    this.maintainZoomLimitsAndScales(); // before other handlers run
-  }
-
-  maintainZoomLimitsAndScales() {
-    // not for cursor updates
-
-    if (this.zoomSpec.t1() < 0) {
-      this.zoomSpec.t1(0);
-    }
-    if (this.zoomSpec.duration() && this.zoomSpec.t2() > this.zoomSpec.duration()) {
-      this.zoomSpec.t2(this.zoomSpec.duration());
-    }
-
-    const rightPad = 5; // don't let time adjuster fall off right edge
-    this.fullZoomX.domain([0, this.zoomSpec.duration()]);
-    this.fullZoomX.range([0, this.width() - rightPad]);
-
-    this.zoomInX.domain([this.zoomSpec.t1(), this.zoomSpec.t2()]);
-    this.zoomInX.range([0, this.width() - rightPad]);
-  }
-
-  latestMouseTime(): number {
-    return this.zoomInX.invert(this.mouse.pos().e(1));
-  }
-
-  onMouseWheel(deltaY: any) {
-    const zs = this.zoomSpec;
-
-    const center = this.latestMouseTime();
-    const left = center - zs.t1();
-    const right = zs.t2() - center;
-    const scale = Math.pow(1.005, deltaY);
-
-    zs.t1(center - left * scale);
-    zs.t2(center + right * scale);
-    log("view to", ko.toJSON(this));
-  }
-
-  frameCursor() {
-    const zs = this.zoomSpec;
-    const visSeconds = zs.t2() - zs.t1();
-    const margin = visSeconds * 0.4;
-    // buggy: really needs t1/t2 to limit their ranges
-    if (this.cursor.t() < zs.t1() || this.cursor.t() > zs.t2() - visSeconds * 0.6) {
-      const newCenter = this.cursor.t() + margin;
-      this.animatedZoom(newCenter - visSeconds / 2, newCenter + visSeconds / 2, this.zoomAnimSec);
-    }
-  }
-  frameToEnd() {
-    this.animatedZoom(this.cursor.t() - 2, this.zoomSpec.duration(), this.zoomAnimSec);
-  }
-  frameAll() {
-    this.animatedZoom(0, this.zoomSpec.duration(), this.zoomAnimSec);
-  }
-  animatedZoom(newT1: number, newT2: number, secs: number) {
-    const fps = 30;
-    const oldT1 = this.zoomSpec.t1();
-    const oldT2 = this.zoomSpec.t2();
-    let lastTime = 0;
-    for (let step = 0; step < secs * fps; step++) {
-      const frac = step / (secs * fps);
-      ((frac) => {
-        const gotoStep = () => {
-          this.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1);
-          return this.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2);
-        };
-        const delay = frac * secs * 1000;
-        setTimeout(gotoStep, delay);
-        lastTime = delay;
-      })(frac);
-    }
-    setTimeout(() => {
-      this.zoomSpec.t1(newT1);
-      return this.zoomSpec.t2(newT2);
-    }, lastTime + 10);
-  }
-}
--- a/light9/web/timeline/vite.config.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8216;
-export default defineConfig({
-  base: "/timeline/",
-  root: "./light9/web/timeline",
-  publicDir: "../web",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/web/timeline2/index.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-<!DOCTYPE HTML>
-<html>
-  <head>
-    <title>pixi.js test</title>
-    <style>
-     body {
-	 margin: 0;
-	 padding: 0;
-	 background-color: #000000;
-     }
-     
-     #help{
-	 position: absolute;
-	 z-index: 20;
-	 color: black;
-	 top: 20px;
-	 left: 120px;
-     }
-    </style>
-
-
-    
-    <script src="node_modules/pixi.js/dist/pixi.js"></script>
-  </head>
-  <body>
-    <script>
-     const log = debug('timeline');
-     var stage = new PIXI.Container();
-     
-     var renderer = PIXI.autoDetectRenderer(3000,2000, {
-         backgroundColor: 0x606060,
-     });
-     
-     document.body.appendChild(renderer.view);
-     requestAnimFrame =  window.requestAnimationFrame;
-     requestAnimFrame( animate );
-
-     if(1) {
-         var graphics = new PIXI.Graphics();
-
-         // set a fill and line style
-         graphics.beginFill(0xFF3300);
-         graphics.lineStyle(4, 0xffd900, 1);
-         graphics.blendMode = PIXI.BLEND_MODES.LUMINOSITY;
-         graphics.cursor = 'wait';
-
-         // draw a shape
-         graphics.moveTo(50,50);
-         graphics.lineTo(250, 50);
-         graphics.lineTo(100, 100);
-         graphics.lineTo(50, 50);
-         graphics.endFill();
-         graphics.interactive = true;
-         graphics.on('click',  (ev) => {
-             log('hit', ev);
-         });
-         
-         stage.addChild(graphics);
-     }
-     
-     objs =  [];
-     const mkdrag = (txt, pos) => {
-         var draggable = new PIXI.Container();
-         
-         var graphics = new PIXI.Graphics();
-         graphics.beginFill(0xeecc00, .6);
-         graphics.lineStyle(2, 0xffd900, 1);
-         graphics.drawRoundedRect(0,0,50,30,5);
-         graphics.endFill();
-
-         draggable.addChild(graphics);
-
-         var style = new PIXI.TextStyle({
-             fontFamily: 'Arial',
-             fontSize: 16,
-             fill: ['#000000'], 
-         });
-         var basicText = new PIXI.Text(txt, style);
-         basicText.x = 3;
-         basicText.y = 9;
-         basicText.scale = new PIXI.Point(.7,1);
-         draggable.addChild(basicText);
-
-         draggable.interactive = true;
-         draggable.on('click',  (ev) => {
-             console.log('d hit', ev);
-         });
-         
-         draggable.position = pos;
-         
-         //   console.log(     draggable.toGlobal(new PIXI.Point(3,  3)));
-         return draggable;
-     };
-
-     for (let x=0; x<3000; x+=30) {
-         for(let i=0; i < 400; i+= 20) {
-             let d = mkdrag('o='+i, new PIXI.Point(i+x, i*2))
-             stage.addChild(d);
-             objs.push(d);
-         }
-     }
-     
-
-     var style = new PIXI.TextStyle({
-         fontFamily: 'Arial',
-         fontSize: 36,
-         fill: ['#ffffff'],
-         stroke: '#4a1850',
-         strokeThickness: 2,
-         dropShadow: true,
-         dropShadowColor: '#000000',
-         dropShadowBlur: 1,
-         dropShadowAngle: Math.PI / 6,
-         dropShadowDistance: 6,
-         //    wordWrap: true,
-         //    wordWrapWidth: 440
-     });
-     var basicText = new PIXI.Text(`num objs = ${objs.length}`, style);
-     basicText.x = 30;
-     basicText.y = 90;
-
-     stage.addChild(basicText);
-
-     function animate() {
-         requestAnimFrame( animate );
-
-         for (let d of objs) {
-             d.rotation = Date.now()  / 2000;
-         }
-         
-         renderer.render(stage);
-     }
-     renderer.render(stage);
-
-    </script>
-
-  </body>
-</html>
--- a/light9/web/vite.config.ts	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-import { defineConfig } from "vite";
-
-export default defineConfig({
-  base: "/",
-  root: "./light9/web",
-  publicDir: "../../node_modules",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: 8300,
-    hmr: {
-      port: 8301,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/web/websocket.js	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-/*
-  url is now relative to the window location. Note that nginx may drop
-  the connection after 60sec of inactivity.
-*/
-
-class ReconnectingWebsocket {
-    
-    constructor(url, onMessage) {
-        this.onMessage = onMessage;
-        this.ws = null;
-        this.connectTimer = null
-        this.pong = 0;
-        
-        this.fullUrl = (
-            "ws://"
-                + window.location.host
-                + window.location.pathname
-                + (window.location.pathname.match(/\/$/) ? "" : "/")
-                + url);
-        this.connect();
-    }
-    setStatus(txt) {
-        const docStatus = document.querySelector('#status')
-        if (docStatus) {
-            docStatus.innerText = txt;
-        }
-    }
-    connect() {
-        this.reconnect = true;
-        this.ws = new WebSocket(this.fullUrl);
-        
-        this.ws.onopen = () => { this.setStatus("connected"); };
-        this.ws.onerror = (e) => { this.setStatus("error: "+e); };
-        this.ws.onclose = () => {
-            this.pong = 1 - this.pong;
-            this.setStatus("disconnected (retrying "+(this.pong ? "😼":"😺")+")");
-            this.ws = null;
-
-            this.connectTimer = setTimeout(() => {
-                this.connectTimer = null;
-                requestAnimationFrame(() => {
-                    if (this.reconnect) {
-                        this.connect();
-                    }
-                });
-            }, 2000);
-        };
-        this.ws.onmessage = (evt) => {
-            this.onMessage(JSON.parse(evt.data));
-        };
-    }
-    disconnect() {
-        this.reconnect = false;
-        this.ws.close();
-    }
-}
-
-
-
-function reconnectingWebSocket(url, onMessage) {
-    return new ReconnectingWebsocket(url, onMessage);
-}
--- a/light9/webcontrol.html	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
-<?xml version="1.0" encoding="iso-8859-1"?>
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
-"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml"
-      xmlns:n="http://nevow.com/ns/nevow/0.1">
-  <head>
-    <title>light9 remote</title>
-    <style type="text/css" media="all">
-      /* <![CDATA[ */
-
-body {
-    background:black none repeat scroll 0 0;
-    color:white;
-    width:320px;
-}
-div.section {
-    border:2px groove #060;
-    margin:5px;
-    padding:5px;
-}
-.doubleWide {
-    width:120px;
-    background:#610;
-}
-.section img {
-    width:36px;
-    vertical-align: middle;
-}
-form {
-    display:inline;
-}
-button {
-    height:64px;
-    width:64px;
-    background:#035;
-    color:white;
-    margin:5px;
-    vertical-align:top;
-    border:1px #035 outset;
-    -moz-border-radius:8px;
-}
-
-div.status {
-    color:#FFFF00;
-    font-family:monospace;
-    text-align:center;
-}
-
-div.status img {
-    width: 32px;
-    vertical-align: middle;
-    margin-right: 3px;
-}
-
-      /* ]]> */
-    </style>
-
-  </head>
-  <body>
-
-    <div class="status"><n:invisible n:render="status"/></div>
-
-    <div class="section music">
-      <div class="title"><img src="icon/music.png"/> Music control</div>
-      <n:invisible n:render="songButtons"/>
-
-      <form method="post" action="stopMusic">
-	<button type="submit" class="doubleWide">Stop music</button>
-      </form>
-    </div>
-
-    <div class="section light">
-      <div class="title"><img src="icon/tvshow.png"/> Light control</div>
-      <div>
-	<form style="display: inline" method="post" action="worklightsOn">
-	  <button type="submit">Works on</button>
-	</form>
-	<form style="display: inline" method="post" action="worklightsOff">
-	  <button type="submit">Works off</button>
-	</form>
-      </div>
-    </div>
-
-  </body>
-</html>
\ No newline at end of file
--- a/light9/zmqtransport.py	Thu Jun 08 15:05:59 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,44 +0,0 @@
-import json
-import logging
-from typing import Tuple
-
-from rdflib import Literal, URIRef
-from txzmq import ZmqEndpoint, ZmqFactory, ZmqPullConnection
-
-from light9.effect.settings import DeviceSettings
-from light9.metrics import metrics
-from light9.newtypes import ClientSessionType, ClientType, UnixTime
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-
-log = logging.getLogger('zmq')
-
-
-def parseJsonMessage(graph: SyncedGraph, msg) -> Tuple[ClientType, ClientSessionType, DeviceSettings, UnixTime]:
-    body = json.loads(msg)
-    settings = []
-    for device, attr, value in body['settings']:
-        if isinstance(value, str) and value.startswith('http'):
-            value = URIRef(value)
-        else:
-            value = Literal(value)
-        settings.append((URIRef(device), URIRef(attr), value))
-    return body['client'], body['clientSession'], DeviceSettings(graph, settings), body['sendTime']
-
-
-def startZmq(graph, port, collector):
-    zf = ZmqFactory()
-    addr = 'tcp://*:%s' % port
-    log.info('creating zmq endpoint at %r', addr)
-    e = ZmqEndpoint('bind', addr)
-
-    class Pull(ZmqPullConnection):
-        #highWaterMark = 3
-        def onPull(self, message):
-            with metrics('zmq_server_set_attr').time():
-                # todo: new compressed protocol where you send all URIs up
-                # front and then use small ints to refer to devices and
-                # attributes in subsequent requests.
-                client, clientSession, settings, sendTime = parseJsonMessage(graph, message[0])
-                collector.setAttrs(client, clientSession, settings, sendTime)
-
-    Pull(zf, e)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/Effects.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,179 @@
+import random as random_mod
+import math
+import logging, colorsys
+import light9.Submaster as Submaster
+from .chase import chase as chase_logic
+from . import showconfig
+from rdflib import RDF
+from light9 import Patch
+from light9.namespaces import L9
+log = logging.getLogger()
+
+registered = []
+
+
+def register(f):
+    registered.append(f)
+    return f
+
+
+@register
+class Strip:
+    """list of r,g,b tuples for sending to an LED strip"""
+    which = 'L'  # LR means both. W is the wide one
+    pixels = []
+
+    def __repr__(self):
+        return '<Strip which=%r px0=%r>' % (self.which, self.pixels[0])
+
+    @classmethod
+    def solid(cls, which='L', color=(1, 1, 1), hsv=None):
+        """hsv overrides color"""
+        if hsv is not None:
+            color = colorsys.hsv_to_rgb(hsv[0] % 1.0, hsv[1], hsv[2])
+        x = cls()
+        x.which = which
+        x.pixels = [tuple(color)] * 50
+        return x
+
+    def __mul__(self, f):
+        if not isinstance(f, (int, float)):
+            raise TypeError
+
+        s = Strip()
+        s.which = self.which
+        s.pixels = [(r * f, g * f, b * f) for r, g, b in self.pixels]
+        return s
+
+    __rmul__ = __mul__
+
+
+@register
+class Blacklight(float):
+    """a level for the blacklight PWM output"""
+
+    def __mul__(self, f):
+        return Blacklight(float(self) * f)
+
+    __rmul__ = __mul__
+
+
+@register
+def chase(t,
+          ontime=0.5,
+          offset=0.2,
+          onval=1.0,
+          offval=0.0,
+          names=None,
+          combiner=max,
+          random=False):
+    """names is list of URIs. returns a submaster that chases through
+    the inputs"""
+    if random:
+        r = random_mod.Random(random)
+        names = names[:]
+        r.shuffle(names)
+
+    chase_vals = chase_logic(t, ontime, offset, onval, offval, names, combiner)
+    lev = {}
+    for uri, value in list(chase_vals.items()):
+        try:
+            dmx = Patch.dmx_from_uri(uri)
+        except KeyError:
+            log.info(("chase includes %r, which doesn't resolve to a dmx chan" %
+                      uri))
+            continue
+        lev[dmx] = value
+
+    return Submaster.Submaster(name="chase", levels=lev)
+
+
+@register
+def hsv(h, s, v, light='all', centerScale=.5):
+    r, g, b = colorsys.hsv_to_rgb(h % 1.0, s, v)
+    lev = {}
+    if light in ['left', 'all']:
+        lev[73], lev[74], lev[75] = r, g, b
+    if light in ['right', 'all']:
+        lev[80], lev[81], lev[82] = r, g, b
+    if light in ['center', 'all']:
+        lev[88], lev[89], lev[
+            90] = r * centerScale, g * centerScale, b * centerScale
+    return Submaster.Submaster(name='hsv', levels=lev)
+
+
+@register
+def stack(t, names=None, fade=0):
+    """names is list of URIs. returns a submaster that stacks the the inputs
+
+    fade=0 makes steps, fade=1 means each one gets its full fraction
+    of the time to fade in. Fades never...
+    """
+    frac = 1.0 / len(names)
+
+    lev = {}
+    for i, uri in enumerate(names):
+        if t >= (i + 1) * frac:
+            try:
+                dmx = Patch.dmx_from_uri(uri)
+            except KeyError:
+                log.info(
+                    ("stack includes %r, which doesn't resolve to a dmx chan" %
+                     uri))
+                continue
+            lev[dmx] = 1
+        else:
+            break
+
+    return Submaster.Submaster(name="stack", levels=lev)
+
+
+@register
+def smoove(x):
+    return -2 * (x**3) + 3 * (x**2)
+
+
+def configExprGlobals():
+    graph = showconfig.getGraph()
+    ret = {}
+
+    for chaseUri in graph.subjects(RDF.type, L9['Chase']):
+        shortName = chaseUri.rsplit('/')[-1]
+        chans = graph.value(chaseUri, L9['channels'])
+        ret[shortName] = list(graph.items(chans))
+        print("%r is a chase" % shortName)
+
+    for f in registered:
+        ret[f.__name__] = f
+
+    ret['nsin'] = lambda x: (math.sin(x * (2 * math.pi)) + 1) / 2
+    ret['ncos'] = lambda x: (math.cos(x * (2 * math.pi)) + 1) / 2
+
+    def nsquare(t, on=.5):
+        return (t % 1.0) < on
+
+    ret['nsquare'] = nsquare
+
+    _smooth_random_items = [random_mod.random() for x in range(100)]
+
+    # suffix '2' to keep backcompat with the versions that magically knew time
+    def smooth_random2(t, speed=1):
+        """1 = new stuff each second, <1 is slower, fade-ier"""
+        x = (t * speed) % len(_smooth_random_items)
+        x1 = int(x)
+        x2 = (int(x) + 1) % len(_smooth_random_items)
+        y1 = _smooth_random_items[x1]
+        y2 = _smooth_random_items[x2]
+        return y1 + (y2 - y1) * ((x - x1))
+
+    def notch_random2(t, speed=1):
+        """1 = new stuff each second, <1 is slower, notch-ier"""
+        x = (t * speed) % len(_smooth_random_items)
+        x1 = int(x)
+        y1 = _smooth_random_items[x1]
+        return y1
+
+    ret['noise2'] = smooth_random2
+    ret['notch2'] = notch_random2
+
+    return ret
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/Fadable.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,165 @@
+# taken from SnackMix -- now that's reusable code
+import time
+
+
+class Fadable:
+    """Fading mixin: must mix in with a Tk widget (or something that has
+    'after' at least) This is currently used by VolumeBox and MixerTk.
+    It's probably too specialized to be used elsewhere, but could possibly
+    work with an Entry or a Meter, I guess.  (Actually, this is used by
+    KeyboardComposer and KeyboardRecorder now too.)
+
+    var is a Tk variable that should be used to set and get the levels.
+    If use_fades is true, it will use fades to move between levels.
+    If key_bindings is true, it will install these keybindings:
+
+    Press a number to fade to that amount (e.g. '5' = 50%).  Also,
+    '`' (grave) will fade to 0 and '0' will fade to 100%.
+
+    If mouse_bindings is true, the following mouse bindings will be
+    installed: Right clicking toggles muting.  The mouse wheel will
+    raise or lower the volume.  Shift-mouse wheeling will cause a more
+    precise volume adjustment.  Control-mouse wheeling will cause a
+    longer fade."""
+
+    def __init__(self,
+                 var,
+                 wheel_step=5,
+                 use_fades=1,
+                 key_bindings=1,
+                 mouse_bindings=1):
+        self.use_fades = use_fades  # whether increase and decrease should fade
+        self.wheel_step = wheel_step  # amount that increase and descrease should
+        # change volume (by default)
+
+        self.fade_start_level = 0
+        self.fade_end_level = 0
+        self.fade_start_time = 0
+        self.fade_length = 1
+        self.fade_step_time = 10
+        self.fade_var = var
+        self.fading = 0  # whether a fade is in progress
+
+        if key_bindings:
+            for k in range(1, 10):
+                self.bind("<Key-%d>" % k, lambda evt, k=k: self.fade(k / 10.0))
+            self.bind("<Key-0>", lambda evt: self.fade(1.0))
+            self.bind("<grave>", lambda evt: self.fade(0))
+
+            # up / down arrows
+            self.bind("<Key-Up>", lambda evt: self.increase())
+            self.bind("<Key-Down>", lambda evt: self.decrease())
+
+        if mouse_bindings:
+            # right mouse button toggles muting
+            self.bind('<3>', lambda evt: self.toggle_mute())
+            # not "NOT ANY MORE!" - homer (i.e. it works again)
+
+            # mouse wheel
+            self.bind('<4>', lambda evt: self.increase())
+            self.bind('<5>', lambda evt: self.decrease())
+
+            # modified mouse wheel
+            self.bind('<Shift-4>', lambda evt: self.increase(multiplier=0.2))
+            self.bind('<Shift-5>', lambda evt: self.decrease(multiplier=0.2))
+            self.bind('<Control-4>', lambda evt: self.increase(length=1))
+            self.bind('<Control-5>', lambda evt: self.decrease(length=1))
+
+        self.last_level = None  # used for muting
+
+    def set_var_rounded(self, value):
+        """use this instead of just self.fade_var.set(value) so we can
+        control the precision"""
+        # this was just to make the display not look so weird, but it
+        # could actually affect the speed of really slow fades. If
+        # that's a problem, do a real trace_write hook for the
+        # variable's display instead of using Label(textvariable=var)
+        # and format it there.
+        self.fade_var.set(round(value, 7))
+        if self.fade_var.get() != value:
+            self.fade_var.set(value)
+        if abs(self.fade_var.get() - value) > .1:
+            raise ValueError(
+                "doublevar won't set- trying %r but it stays at %r" %
+                (value, self.fade_var.get()))
+
+    def fade(self, value, length=0.5, step_time=10):
+        """Fade to value in length seconds with steps every step_time
+        milliseconds"""
+        if length == 0:  # 0 seconds fades happen right away and prevents
+            # and prevents us from entering the fade loop,
+            # which would cause a divide by zero
+            self.set_var_rounded(value)
+            self.fading = 0  # we stop all fades
+        else:  # the general case
+            self.fade_start_time = time.time()
+            self.fade_length = length
+
+            self.fade_start_level = self.fade_var.get()
+            self.fade_end_level = value
+
+            self.fade_step_time = step_time
+            if not self.fading:
+                self.fading = 1
+                self.do_fade()
+
+    def do_fade(self):
+        """Actually performs the fade for Fadable.fade.  Shouldn't be called
+        directly."""
+        now = time.time()
+        elapsed = now - self.fade_start_time
+        complete = elapsed / self.fade_length
+        complete = min(1.0, complete)
+        diff = self.fade_end_level - self.fade_start_level
+        newlevel = (complete * diff) + self.fade_start_level
+        self.set_var_rounded(newlevel)
+        if complete < 1:
+            self.after(self.fade_step_time, self.do_fade)
+        else:
+            self.fading = 0
+
+    def increase(self, multiplier=1, length=0.3):
+        """Increases the volume by multiplier * wheel_step.  If use_fades is
+        true, it do this as a fade over length time."""
+        amount = self.wheel_step * multiplier
+        if self.fading:
+            newlevel = self.fade_end_level + amount
+        else:
+            newlevel = self.fade_var.get() + amount
+        newlevel = min(100, newlevel)
+        self.set_volume(newlevel, length)
+
+    def decrease(self, multiplier=1, length=0.3):
+        """Descreases the volume by multiplier * wheel_step.  If use_fades
+        is true, it do this as a fade over length time."""
+        amount = self.wheel_step * multiplier
+        if self.fading:
+            newlevel = self.fade_end_level - amount
+        else:
+            newlevel = self.fade_var.get() - amount
+        newlevel = max(0., newlevel)
+        self.set_volume(newlevel, length)
+
+    def set_volume(self, newlevel, length=0.3):
+        """Sets the volume to newlevel, performing a fade of length if
+        use_fades is true."""
+        if self.use_fades:
+            self.fade(newlevel, length=length)
+        else:
+            self.set_var_rounded(newlevel)
+
+    def toggle_mute(self):
+        """Toggles whether the volume is being muted."""
+        if self.last_level is None:
+            self.last_level = self.fade_var.get()
+            if self.last_level == 0.:  # we don't want last_level to be zero,
+                # since it will make us toggle between 0
+                # and 0
+                newlevel = 1.
+            else:
+                newlevel = 0.
+        else:
+            newlevel = self.last_level
+            self.last_level = None
+
+        self.set_var_rounded(newlevel)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/FlyingFader.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,220 @@
+from tkinter import tix
+from time import time
+
+
+class Mass:
+
+    def __init__(self):
+        self.x = 0  # position
+        self.xgoal = 0  # position goal
+
+        self.v = 0  # velocity
+        self.maxspeed = .8  # maximum speed, in position/second
+        self.maxaccel = 3  # maximum acceleration, in position/second^2
+        self.eps = .03  # epsilon - numbers within this much are considered the same
+
+        self._lastupdate = time()
+        self._stopped = 1
+
+    def equal(self, a, b):
+        return abs(a - b) < self.eps
+
+    def stop(self):
+        self.v = 0
+        self.xgoal = self.x
+        self._stopped = 1
+
+    def update(self):
+        t0 = self._lastupdate
+        tnow = time()
+        self._lastupdate = tnow
+
+        dt = tnow - t0
+
+        self.x += self.v * dt
+        # hitting the ends stops the slider
+        if self.x > 1:
+            self.v = max(self.v, 0)
+            self.x = 1
+        if self.x < 0:
+            self.v = min(self.v, 0)
+            self.x = 0
+
+        if self.equal(self.x, self.xgoal):
+            self.x = self.xgoal  # clean up value
+            self.stop()
+            return
+
+        self._stopped = 0
+        dir = (-1.0, 1, 0)[self.xgoal > self.x]
+
+        if abs(self.xgoal - self.x) < abs(self.v * 5 * dt):
+            # apply the brakes on the last 5 steps
+            dir *= -.5
+
+        self.v += dir * self.maxaccel * dt  # velocity changes with acceleration in the right direction
+        self.v = min(max(self.v, -self.maxspeed),
+                     self.maxspeed)  # clamp velocity
+
+        #print "x=%+.03f v=%+.03f a=%+.03f %f" % (self.x,self.v,self.maxaccel,self.xgoal)
+
+    def goto(self, newx):
+        self.xgoal = newx
+
+    def ismoving(self):
+        return not self._stopped
+
+
+class FlyingFader(tix.Frame):
+
+    def __init__(self,
+                 master,
+                 variable,
+                 label,
+                 fadedur=1.5,
+                 font=('Arial', 8),
+                 labelwidth=12,
+                 **kw):
+        tix.Frame.__init__(self, master)
+        self.name = label
+        self.variable = variable
+
+        self.mass = Mass()
+
+        self.config({'bd': 1, 'relief': 'raised'})
+        scaleopts = {
+            'variable': variable,
+            'showvalue': 0,
+            'from': 1.0,
+            'to': 0,
+            'res': 0.001,
+            'width': 20,
+            'length': 200,
+            'orient': 'vert'
+        }
+        scaleopts.update(kw)
+        if scaleopts['orient'] == 'vert':
+            side2 = tix.BOTTOM
+        else:
+            side2 = tix.LEFT
+
+        self.scale = tix.Scale(self, **scaleopts)
+        self.vlabel = tix.Label(self, text="0.0", width=6, font=font)
+        self.label = tix.Label(self,
+                               text=label,
+                               font=font,
+                               anchor='w',
+                               width=labelwidth)  #wraplength=40, )
+
+        self.oldtrough = self.scale['troughcolor']
+
+        self.scale.pack(side=side2, expand=1, fill=tix.BOTH, anchor='c')
+        self.vlabel.pack(side=side2, expand=0, fill=tix.X)
+        self.label.pack(side=side2, expand=0, fill=tix.X)
+
+        for k in range(1, 10):
+            self.scale.bind(
+                "<Key-%d>" % k, lambda evt, k=k: self.newfade(k / 10.0, evt))
+
+        self.scale.bind("<Key-0>", lambda evt: self.newfade(1.0, evt))
+        self.scale.bind("<grave>", lambda evt: self.newfade(0, evt))
+
+        self.scale.bind("<1>", self.cancelfade)
+        self.scale.bind("<2>", self.cancelfade)
+        self.scale.bind("<3>", self.mousefade)
+
+        self.trace_ret = self.variable.trace('w', self.updatelabel)
+        self.bind("<Destroy>", self.ondestroy)
+
+    def ondestroy(self, *ev):
+        self.variable.trace_vdelete('w', self.trace_ret)
+
+    def cancelfade(self, evt):
+        self.fadegoal = self.variable.get()
+        self.fadevel = self.fadeacc = 0
+
+        self.scale['troughcolor'] = self.oldtrough
+
+    def mousefade(self, evt):
+        target = float(self.tk.call(self.scale, 'get', evt.x, evt.y))
+        self.newfade(target, evt)
+
+    def ismoving(self):
+        return self.fadevel != 0 or self.fadeacc != 0
+
+    def newfade(self, newlevel, evt=None, length=None):
+
+        # these are currently unused-- Mass needs to accept a speed input
+        mult = 1
+        if evt.state & 8 and evt.state & 4: mult = 0.25  # both
+        elif evt.state & 8: mult = 0.5  # alt
+        elif evt.state & 4: mult = 2  # control # noqa
+
+        self.mass.x = self.variable.get()
+        self.mass.goto(newlevel)
+
+        self.gofade()
+
+    def gofade(self):
+        self.mass.update()
+        self.variable.set(self.mass.x)
+
+        if not self.mass.ismoving():
+            self.scale['troughcolor'] = self.oldtrough
+            return
+
+        # blink the trough while the thing's moving
+        if time() % .4 > .2:
+            # self.scale.config(troughcolor=self.oldtrough)
+            self.scale.config(troughcolor='orange')
+        else:
+            # self.scale.config(troughcolor='white')
+            self.scale.config(troughcolor='yellow')
+
+#        colorfade(self.scale, percent)
+        self.after(30, self.gofade)
+
+    def updatelabel(self, *args):
+        if self.variable:
+            self.vlabel['text'] = "%.3f" % self.variable.get()
+
+
+#        if self.fadetimes[1] == 0: # no fade
+#            self.vlabel['fg'] = 'black'
+#        elif self.curfade[1] > self.curfade[0]:
+#            self.vlabel['fg'] = 'red'
+#        else:
+#            self.vlabel['fg'] = 'blue'
+
+    def get(self):
+        return self.scale.get()
+
+    def set(self, val):
+        self.scale.set(val)
+
+
+def colorfade(scale, lev):
+    low = (255, 255, 255)
+    high = (0, 0, 0)
+    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
+    col = "#%02X%02X%02X" % tuple(out)
+    scale.config(troughcolor=col)
+
+
+if __name__ == '__main__':
+    root = tix.Tk()
+    root.tk_focusFollowsMouse()
+
+    FlyingFader(root, variable=tix.DoubleVar(),
+                label="suck").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
+    FlyingFader(root, variable=tix.DoubleVar(),
+                label="moof").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
+    FlyingFader(root, variable=tix.DoubleVar(),
+                label="zarf").pack(side=tix.LEFT, expand=1, fill=tix.BOTH)
+    FlyingFader(root,
+                variable=tix.DoubleVar(),
+                label="long name goes here.  got it?").pack(side=tix.LEFT,
+                                                            expand=1,
+                                                            fill=tix.BOTH)
+
+    root.mainloop()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/Patch.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,76 @@
+from rdflib import RDF
+from light9.namespaces import L9
+from light9 import showconfig
+
+
+def resolve_name(channelname):
+    "Ensure that we're talking about the primary name of the light."
+    return get_channel_name(get_dmx_channel(channelname))
+
+
+def get_all_channels():
+    """returns primary names for all channels (sorted)"""
+    prinames = sorted(list(reverse_patch.values())[:])
+    return prinames
+
+
+def get_dmx_channel(name):
+    if str(name) in patch:
+        return patch[str(name)]
+
+    try:
+        i = int(name)
+        return i
+    except ValueError:
+        raise ValueError("Invalid channel name: %r" % name)
+
+
+def get_channel_name(dmxnum):
+    """if you pass a name, it will get normalized"""
+    try:
+        return reverse_patch[dmxnum]
+    except KeyError:
+        return str(dmxnum)
+
+
+def get_channel_uri(name):
+    return uri_map[name]
+
+
+def dmx_from_uri(uri):
+    return uri_patch[uri]
+
+
+def reload_data():
+    global patch, reverse_patch, uri_map, uri_patch
+    patch = {}
+    reverse_patch = {}
+    uri_map = {}
+    uri_patch = {}
+
+    graph = showconfig.getGraph()
+
+    for chan in graph.subjects(RDF.type, L9['Channel']):
+        for which, name in enumerate([graph.label(chan)] +
+                                     list(graph.objects(chan, L9['altName']))):
+            name = str(name)
+            uri_map[name] = chan
+
+            if name in patch:
+                raise ValueError("channel name %r used multiple times" % name)
+            for output in graph.objects(chan, L9['output']):
+                for addr in graph.objects(output, L9['dmxAddress']):
+                    addrInt = int(addr)
+                    patch[name] = addrInt
+                    uri_patch[chan] = addrInt
+
+                    if which == 0:
+                        reverse_patch[addrInt] = name
+                        reverse_patch[addr] = name
+                        norm_name = name
+                    else:
+                        reverse_patch[name] = norm_name
+
+
+# importing patch will load initial data
+reload_data()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/Submaster.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,391 @@
+import logging
+from rdflib import Graph, RDF
+from rdflib import RDFS, Literal, BNode
+from light9.namespaces import L9, XSD
+from light9.TLUtility import dict_scale, dict_max
+from light9 import showconfig
+from light9.Patch import resolve_name, get_dmx_channel, get_channel_uri
+from louie import dispatcher
+from rdfdb.patch import Patch
+log = logging.getLogger('submaster')
+
+
+class Submaster(object):
+    """mapping of channels to levels"""
+
+    def __init__(self, name, levels):
+        """this sub has a name just for debugging. It doesn't get persisted.
+        See PersistentSubmaster.
+
+        levels is a dict
+        """
+        self.name = name
+        self.levels = levels
+
+        self.temporary = True
+
+        if not self.temporary:
+            # obsolete
+            dispatcher.connect(log.error, 'reload all subs')
+
+        #log.debug("%s initial levels %s", self.name, self.levels)
+
+    def _editedLevels(self):
+        pass
+
+    def set_level(self, channelname, level, save=True):
+        self.levels[resolve_name(channelname)] = level
+        self._editedLevels()
+
+    def set_all_levels(self, leveldict):
+        self.levels.clear()
+        for k, v in list(leveldict.items()):
+            # this may call _editedLevels too many times
+            self.set_level(k, v, save=0)
+
+    def get_levels(self):
+        return self.levels
+
+    def no_nonzero(self):
+        return all(v == 0 for v in self.levels.values())
+
+    def __mul__(self, scalar):
+        return Submaster("%s*%s" % (self.name, scalar),
+                         levels=dict_scale(self.levels, scalar))
+
+    __rmul__ = __mul__
+
+    def max(self, *othersubs):
+        return sub_maxes(self, *othersubs)
+
+    def __add__(self, other):
+        return self.max(other)
+
+    def ident(self):
+        return (self.name, tuple(sorted(self.levels.items())))
+
+    def __repr__(self):
+        items = sorted(list(getattr(self, 'levels', {}).items()))
+        levels = ' '.join(["%s:%.2f" % item for item in items])
+        return "<'%s': [%s]>" % (getattr(self, 'name', 'no name yet'), levels)
+
+    def __cmp__(self, other):
+        # not sure how useful this is
+        if not isinstance(other, Submaster):
+            return -1
+        return cmp(self.ident(), other.ident())  # noqa
+
+    def __hash__(self):
+        return hash(self.ident())
+
+    def get_dmx_list(self):
+        leveldict = self.get_levels()  # gets levels of sub contents
+
+        levels = []
+        for k, v in list(leveldict.items()):
+            if v == 0:
+                continue
+            try:
+                dmxchan = get_dmx_channel(k) - 1
+            except ValueError:
+                log.error(
+                    "error trying to compute dmx levels for submaster %s" %
+                    self.name)
+                raise
+            if dmxchan >= len(levels):
+                levels.extend([0] * (dmxchan - len(levels) + 1))
+            levels[dmxchan] = max(v, levels[dmxchan])
+
+        return levels
+
+    def normalize_patch_names(self):
+        """Use only the primary patch names."""
+        # possibly busted -- don't use unless you know what you're doing
+        self.set_all_levels(self.levels.copy())
+
+    def get_normalized_copy(self):
+        """Get a copy of this sumbaster that only uses the primary patch
+        names.  The levels will be the same."""
+        newsub = Submaster("%s (normalized)" % self.name, {})
+        newsub.set_all_levels(self.levels)
+        return newsub
+
+    def crossfade(self, othersub, amount):
+        """Returns a new sub that is a crossfade between this sub and
+        another submaster.
+
+        NOTE: You should only crossfade between normalized submasters."""
+        otherlevels = othersub.get_levels()
+        keys_set = {}
+        for k in list(self.levels.keys()) + list(otherlevels.keys()):
+            keys_set[k] = 1
+        all_keys = list(keys_set.keys())
+
+        xfaded_sub = Submaster("xfade", {})
+        for k in all_keys:
+            xfaded_sub.set_level(
+                k,
+                linear_fade(self.levels.get(k, 0), otherlevels.get(k, 0),
+                            amount))
+
+        return xfaded_sub
+
+
+class PersistentSubmaster(Submaster):
+
+    def __init__(self, graph, uri):
+        if uri is None:
+            raise TypeError("uri must be URIRef")
+        self.graph, self.uri = graph, uri
+        self.graph.addHandler(self.setName)
+        self.graph.addHandler(self.setLevels)
+        Submaster.__init__(self, self.name, self.levels)
+        self.temporary = False
+
+    def ident(self):
+        return self.uri
+
+    def _editedLevels(self):
+        self.save()
+
+    def changeName(self, newName):
+        self.graph.patchObject(self.uri, self.uri, RDFS.label, Literal(newName))
+
+    def setName(self):
+        log.info("sub update name %s %s", self.uri, self.graph.label(self.uri))
+        self.name = self.graph.label(self.uri)
+
+    def setLevels(self):
+        log.debug("sub update levels")
+        oldLevels = getattr(self, 'levels', {}).copy()
+        self.setLevelsFromGraph()
+        if oldLevels != self.levels:
+            log.debug("sub %s changed" % self.name)
+            # dispatcher too? this would help subcomposer
+            dispatcher.send("sub levels changed", sub=self)
+
+    def setLevelsFromGraph(self):
+        if hasattr(self, 'levels'):
+            self.levels.clear()
+        else:
+            self.levels = {}
+        for lev in self.graph.objects(self.uri, L9['lightLevel']):
+            log.debug(" lightLevel %s %s", self.uri, lev)
+            chan = self.graph.value(lev, L9['channel'])
+
+            val = self.graph.value(lev, L9['level'])
+            if val is None:
+                # broken lightLevel link- may be from someone deleting channels
+                log.warn("sub %r has lightLevel %r with channel %r "
+                         "and level %r" % (self.uri, lev, chan, val))
+                continue
+            log.debug("   new val %r", val)
+            if val == 0:
+                continue
+            name = self.graph.label(chan)
+            if not name:
+                log.error("sub %r has channel %r with no name- "
+                          "leaving out that channel" % (self.name, chan))
+                continue
+            try:
+                self.levels[name] = float(val)
+            except Exception:
+                log.error("name %r val %r" % (name, val))
+                raise
+
+    def _saveContext(self):
+        """the context in which we should save all the lightLevel triples for
+        this sub"""
+        typeStmt = (self.uri, RDF.type, L9['Submaster'])
+        with self.graph.currentState(tripleFilter=typeStmt) as current:
+            try:
+                log.debug(
+                    "submaster's type statement is in %r so we save there" %
+                    list(current.contextsForStatement(typeStmt)))
+                ctx = current.contextsForStatement(typeStmt)[0]
+            except IndexError:
+                log.info("declaring %s to be a submaster" % self.uri)
+                ctx = self.uri
+                self.graph.patch(
+                    Patch(addQuads=[
+                        (self.uri, RDF.type, L9['Submaster'], ctx),
+                    ]))
+
+        return ctx
+
+    def editLevel(self, chan, newLevel):
+        self.graph.patchMapping(self._saveContext(),
+                                subject=self.uri,
+                                predicate=L9['lightLevel'],
+                                nodeClass=L9['ChannelSetting'],
+                                keyPred=L9['channel'],
+                                newKey=chan,
+                                valuePred=L9['level'],
+                                newValue=Literal(newLevel))
+
+    def clear(self):
+        """set all levels to 0"""
+        with self.graph.currentState() as g:
+            levs = list(g.objects(self.uri, L9['lightLevel']))
+        for lev in levs:
+            self.graph.removeMappingNode(self._saveContext(), lev)
+
+    def allQuads(self):
+        """all the quads for this sub"""
+        quads = []
+        with self.graph.currentState() as current:
+            quads.extend(current.quads((self.uri, None, None)))
+            for s, p, o, c in quads:
+                if p == L9['lightLevel']:
+                    quads.extend(current.quads((o, None, None)))
+        return quads
+
+    def save(self):
+        raise NotImplementedError("obsolete?")
+        if self.temporary:
+            log.info("not saving temporary sub named %s", self.name)
+            return
+
+        graph = Graph()
+        subUri = L9['sub/%s' % self.name]
+        graph.add((subUri, RDFS.label, Literal(self.name)))
+        for chan in list(self.levels.keys()):
+            try:
+                chanUri = get_channel_uri(chan)
+            except KeyError:
+                log.error("saving dmx channels with no :Channel node "
+                          "is not supported yet. Give channel %s a URI "
+                          "for it to be saved. Omitting this channel "
+                          "from the sub." % chan)
+                continue
+            lev = BNode()
+            graph.add((subUri, L9['lightLevel'], lev))
+            graph.add((lev, L9['channel'], chanUri))
+            graph.add((lev, L9['level'],
+                       Literal(self.levels[chan], datatype=XSD['decimal'])))
+
+        graph.serialize(showconfig.subFile(self.name), format="nt")
+
+
+def linear_fade(start, end, amount):
+    """Fades between two floats by an amount.  amount is a float between
+    0 and 1.  If amount is 0, it will return the start value.  If it is 1,
+    the end value will be returned."""
+    level = start + (amount * (end - start))
+    return level
+
+
+def sub_maxes(*subs):
+    nonzero_subs = [s for s in subs if not s.no_nonzero()]
+    name = "max(%s)" % ", ".join([repr(s) for s in nonzero_subs])
+    return Submaster(name,
+                     levels=dict_max(*[sub.levels for sub in nonzero_subs]))
+
+
+def combine_subdict(subdict, name=None, permanent=False):
+    """A subdict is { Submaster objects : levels }.  We combine all
+    submasters first by multiplying the submasters by their corresponding
+    levels and then max()ing them together.  Returns a new Submaster
+    object.  You can give it a better name than the computed one that it
+    will get or make it permanent if you'd like it to be saved to disk.
+    Serves 8."""
+    scaledsubs = [sub * level for sub, level in list(subdict.items())]
+    maxes = sub_maxes(*scaledsubs)
+    if name:
+        maxes.name = name
+    if permanent:
+        maxes.temporary = False
+
+    return maxes
+
+
+class Submasters(object):
+    "Collection o' Submaster objects"
+
+    def __init__(self, graph):
+        self.submasters = {}  # uri : Submaster
+        self.graph = graph
+
+        graph.addHandler(self.findSubs)
+
+    def findSubs(self):
+        current = set()
+
+        for s in self.graph.subjects(RDF.type, L9['Submaster']):
+            if self.graph.contains((s, RDF.type, L9['LocalSubmaster'])):
+                continue
+            log.debug("found sub %s", s)
+            if s not in self.submasters:
+                sub = self.submasters[s] = PersistentSubmaster(self.graph, s)
+                dispatcher.send("new submaster", sub=sub)
+            current.add(s)
+        for s in set(self.submasters.keys()) - current:
+            del self.submasters[s]
+            dispatcher.send("lost submaster", subUri=s)
+        log.info("findSubs finished, %s subs", len(self.submasters))
+
+    def get_all_subs(self):
+        "All Submaster objects"
+        v = sorted(list(self.submasters.items()))
+        v = [x[1] for x in v]
+        songs = []
+        notsongs = []
+        for s in v:
+            if s.name and s.name.startswith('song'):
+                songs.append(s)
+            else:
+                notsongs.append(s)
+        combined = notsongs + songs
+
+        return combined
+
+    def get_sub_by_uri(self, uri):
+        return self.submasters[uri]
+
+    def get_sub_by_name(self, name):
+        return get_sub_by_name(name, self)
+
+
+# a global instance of Submasters, created on demand
+_submasters = None
+
+
+def get_global_submasters(graph):
+    """
+    Get (and make on demand) the global instance of
+    Submasters. Cached, but the cache is not correctly using the graph
+    argument. The first graph you pass will stick in the cache.
+    """
+    global _submasters
+    if _submasters is None:
+        _submasters = Submasters(graph)
+    return _submasters
+
+
+def get_sub_by_name(name, submasters=None):
+    """name is a channel or sub nama, submasters is a Submasters object.
+    If you leave submasters empty, it will use the global instance of
+    Submasters."""
+    if not submasters:
+        submasters = get_global_submasters()
+
+    # get_all_sub_names went missing. needs rework
+    #if name in submasters.get_all_sub_names():
+    #    return submasters.get_sub_by_name(name)
+
+    try:
+        val = int(name)
+        s = Submaster("#%d" % val, levels={val: 1.0})
+        return s
+    except ValueError:
+        pass
+
+    try:
+        subnum = get_dmx_channel(name)
+        s = Submaster("'%s'" % name, levels={subnum: 1.0})
+        return s
+    except ValueError:
+        pass
+
+    # make an error sub
+    return Submaster('%s' % name, levels=ValueError)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/TLUtility.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,229 @@
+"""Collected utility functions, many are taken from Drew's utils.py in
+Cuisine CVS and Hiss's Utility.py."""
+
+import sys
+
+__author__ = "David McClosky <dmcc@bigasterisk.com>, " + \
+             "Drew Perttula <drewp@bigasterisk.com>"
+__cvsid__ = "$Id: TLUtility.py,v 1.1 2003/05/25 08:25:35 dmcc Exp $"
+__version__ = "$Revision: 1.1 $" [11:-2]
+
+
+def make_attributes_from_args(*argnames):
+    """
+    This function simulates the effect of running
+      self.foo=foo
+    for each of the given argument names ('foo' in the example just
+    now). Now you can write:
+        def __init__(self,foo,bar,baz):
+            copy_to_attributes('foo','bar','baz')
+            ...
+    instead of:
+        def __init__(self,foo,bar,baz):
+            self.foo=foo
+            self.bar=bar
+            self.baz=baz
+            ... 
+    """
+
+    callerlocals = sys._getframe(1).f_locals
+    callerself = callerlocals['self']
+    for a in argnames:
+        try:
+            setattr(callerself, a, callerlocals[a])
+        except KeyError:
+            raise KeyError("Function has no argument '%s'" % a)
+
+
+def enumerate(*collections):
+    """Generates an indexed series:  (0,coll[0]), (1,coll[1]) ...
+    
+    this is a multi-list version of the code from the PEP:
+    enumerate(a,b) gives (0,a[0],b[0]), (1,a[1],b[1]) ...
+    """
+    i = 0
+    iters = [iter(collection) for collection in collections]
+    while True:
+        yield [
+            i,
+        ] + [next(iterator) for iterator in iters]
+        i += 1
+
+
+def dumpobj(o):
+    """Prints all the object's non-callable attributes"""
+    print(repr(o))
+    for a in [x for x in dir(o) if not callable(getattr(o, x))]:
+        try:
+            print("  %20s: %s " % (a, getattr(o, a)))
+        except Exception:
+            pass
+    print("")
+
+
+def dict_filter_update(d, **newitems):
+    """Adds a set of new keys and values to dictionary 'd' if the values are
+    true:
+
+    >>> some_dict = {}
+    >>> dict_filter_update(some_dict, a=None, b=0, c=1, e={}, s='hello')
+    >>> some_dict
+    {'c': 1, 's': 'hello'}
+    """
+    for k, v in list(newitems.items()):
+        if v: d[k] = v
+
+
+def try_get_logger(channel):
+    """Tries to get a logger with the channel 'channel'.  Will return a
+    silent DummyClass if logging is not available."""
+    try:
+        import logging
+        log = logging.getLogger(channel)
+    except ImportError:
+        log = DummyClass()
+    return log
+
+
+class DummyClass:
+    """A class that can be instantiated but never used.  It is intended to
+    be replaced when information is available.
+    
+    Usage:
+    >>> d = DummyClass(1, 2, x="xyzzy")
+    >>> d.someattr
+    Traceback (most recent call last):
+      File "<stdin>", line 1, in ?
+      File "Utility.py", line 33, in __getattr__
+        raise AttributeError, "Attempted usage of a DummyClass: %s" % key
+    AttributeError: Attempted usage of a DummyClass: someattr
+    >>> d.somefunction()
+    Traceback (most recent call last):
+      File "<stdin>", line 1, in ?
+      File "Utility.py", line 33, in __getattr__
+        raise AttributeError, "Attempted usage of a DummyClass: %s" % key
+    AttributeError: Attempted usage of a DummyClass: somefunction"""
+
+    def __init__(self, use_warnings=1, raise_exceptions=0, **kw):
+        """Constructs a DummyClass"""
+        make_attributes_from_args('use_warnings', 'raise_exceptions')
+
+    def __getattr__(self, key):
+        """Raises an exception to warn the user that a Dummy is not being
+        replaced in time."""
+        if key == "__del__":
+            return
+        msg = "Attempted usage of '%s' on a DummyClass" % key
+        if self.use_warnings:
+            import warnings
+            warnings.warn(msg)
+        if self.raise_exceptions:
+            raise AttributeError(msg)
+        return lambda *args, **kw: self.bogus_function()
+
+    def bogus_function(self):
+        pass
+
+
+class ClassyDict(dict):
+    """A dict that accepts attribute-style access as well (for keys
+    that are legal names, obviously). I used to call this Struct, but
+    chose the more colorful name to avoid confusion with the struct
+    module."""
+
+    def __getattr__(self, a):
+        return self[a]
+
+    def __setattr__(self, a, v):
+        self[a] = v
+
+    def __delattr__(self, a):
+        del self[a]
+
+
+def trace(func):
+    """Good old fashioned Lisp-style tracing.  Example usage:
+    
+    >>> def f(a, b, c=3):
+    >>>     print a, b, c
+    >>>     return a + b
+    >>>
+    >>>
+    >>> f = trace(f)
+    >>> f(1, 2)
+    |>> f called args: [1, 2]
+    1 2 3
+    <<| f returned 3
+    3
+
+    TODO: print out default keywords (maybe)
+          indent for recursive call like the lisp version (possible use of 
+              generators?)"""
+    name = func.__name__
+
+    def tracer(*args, **kw):
+        s = '|>> %s called' % name
+        if args:
+            s += ' args: %r' % list(args)
+        if kw:
+            s += ' kw: %r' % kw
+        print(s)
+        ret = func(*args, **kw)
+        print('<<| %s returned %s' % (name, ret))
+        return ret
+
+    return tracer
+
+
+# these functions taken from old light8 code
+def dict_max(*dicts):
+    """
+    ({'a' : 5, 'b' : 9}, {'a' : 10, 'b' : 4})
+      returns ==> {'a' : 10, 'b' : 9}
+    """
+    newdict = {}
+    for d in dicts:
+        for k, v in list(d.items()):
+            newdict[k] = max(v, newdict.get(k, 0))
+    return newdict
+
+
+def dict_scale(d, scl):
+    """scales all values in dict and returns a new dict"""
+    return dict([(k, v * scl) for k, v in d.items()])
+
+
+def dict_subset(d, dkeys, default=0):
+    """Subset of dictionary d: only the keys in dkeys.  If you plan on omitting
+    keys, make sure you like the default."""
+    newd = {}  # dirty variables!
+    for k in dkeys:
+        newd[k] = d.get(k, default)
+    return newd
+
+
+# functions specific to Timeline
+# TBD
+def last_less_than(array, x):
+    """array must be sorted"""
+    best = None
+    for elt in array:
+        if elt <= x:
+            best = elt
+        elif best is not None:
+            return best
+    return best
+
+
+# TBD
+def first_greater_than(array, x):
+    """array must be sorted"""
+    array_rev = array[:]
+    array_rev.reverse()
+    best = None
+    for elt in array_rev:
+        if elt >= x:
+            best = elt
+        elif best is not None:
+            return best
+    return best
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/main.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,84 @@
+#!bin/python
+import logging
+import optparse
+import sys
+from typing import cast
+
+import gi
+from rdflib import URIRef
+from starlette.applications import Starlette
+from starlette.routing import Route
+from starlette_exporter import PrometheusMiddleware, handle_metrics
+from twisted.internet import reactor
+from twisted.internet.interfaces import IReactorCore
+
+from light9.run_local import log
+
+gi.require_version('Gst', '1.0')
+gi.require_version('Gtk', '3.0')
+
+from gi.repository import Gst  # type: ignore
+
+from light9 import networking, showconfig
+from light9.ascoltami import webapp
+from light9.ascoltami.player import Player
+from light9.ascoltami.playlist import NoSuchSong, Playlist
+
+reactor = cast(IReactorCore, reactor)
+
+
+class Ascoltami:
+
+    def __init__(self, graph, show):
+        self.graph = graph
+        self.player = Player(onEOS=self.onEOS, autoStopOffset=0)
+        self.show = show
+        self.playlist = Playlist.fromShow(graph, show)
+
+    def onEOS(self, song):
+        self.player.pause()
+        self.player.seek(0)
+
+        thisSongUri = webapp.songUri(self.graph, URIRef(song))
+
+        try:
+            nextSong = self.playlist.nextSong(thisSongUri)
+        except NoSuchSong:  # we're at the end of the playlist
+            return
+
+        self.player.setSong(webapp.songLocation(self.graph, nextSong), play=False)
+
+
+def main():
+    logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
+    Gst.init(None)
+
+    graph = showconfig.getGraph()
+    asco = Ascoltami(graph, showconfig.showUri())
+
+    app = Starlette(
+        debug=True,
+        routes=[
+            Route("/config", webapp.get_config),
+            Route("/time", webapp.get_time, methods=["GET"]),
+            Route("/time", webapp.post_time, methods=["POST"]),
+            Route("/time/stream", webapp.timeStream),
+            Route("/song", webapp.post_song, methods=["POST"]),
+            Route("/songs", webapp.get_songs),
+            Route("/seekPlayOrPause", webapp.post_seekPlayOrPause),
+            Route("/output", webapp.post_output, methods=["POST"]),
+            Route("/go", webapp.post_goButton, methods=["POST"]),
+        ],
+    )
+
+    app.add_middleware(PrometheusMiddleware)
+    app.add_route("/metrics", handle_metrics)
+
+    app.state.graph = graph
+    app.state.show = asco.show
+    app.state.player = asco.player
+
+    return app
+
+
+app = main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/main_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,7 @@
+
+from light9.run_local import log
+
+
+def test_import():
+    import light9.ascoltami.main
+    
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/musictime_client.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,98 @@
+import time, json, logging
+from typing import Dict, cast
+from twisted.internet.interfaces import IReactorTime
+
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks
+import treq
+
+from light9 import networking
+
+log = logging.getLogger()
+
+
+class MusicTime:
+    """
+    fetch times from ascoltami in a background thread; return times
+    upon request, adjusted to be more precise with the system clock
+    """
+
+    def __init__(self, period=.2, onChange=lambda position: None, pollCurvecalc='ignored'):
+        """period is the seconds between
+        http time requests.
+
+        We call onChange with the time in seconds and the total time
+
+        The choice of period doesn't need to be tied to framerate,
+        it's more the size of the error you can tolerate (since we
+        make up times between the samples, and we'll just run off the
+        end of a song)
+        """
+        self.positionFetchTime = 0
+        self.period = period
+        self.hoverPeriod = .05
+        self.onChange = onChange
+
+        self.position: Dict[str, float] = {}
+        # driven by our pollCurvecalcTime and also by Gui.incomingTime
+        self.lastHoverTime = None  # None means "no recent value"
+        self.pollMusicTime()
+
+    def getLatest(self, frameTime=None) -> Dict:
+        """
+        dict with 't' and 'song', etc.
+
+        frameTime is the timestamp from the camera, which will be used
+        instead of now.
+
+        Note that this may be called in a gst camera capture thread. Very often.
+        """
+        if not hasattr(self, 'position'):
+            return {'t': 0, 'song': None}
+        pos = self.position.copy()
+        now = frameTime or time.time()
+        if pos.get('playing'):
+            pos['t'] = pos['t'] + (now - self.positionFetchTime)
+        else:
+            if self.lastHoverTime is not None:
+                pos['hoverTime'] = self.lastHoverTime
+        return pos
+
+    def pollMusicTime(self):
+
+        @inlineCallbacks
+        def cb(response):
+
+            if response.code != 200:
+                raise ValueError("%s %s", response.code, (yield response.content()))
+
+            position = yield response.json()
+
+            # this is meant to be the time when the server gave me its
+            # report, and I don't know if that's closer to the
+            # beginning of my request or the end of it (or some
+            # fraction of the way through)
+            self.positionFetchTime = time.time()
+
+            self.position = position
+            self.onChange(position)
+
+            cast(IReactorTime, reactor).callLater(self.period, self.pollMusicTime) # type: ignore
+
+        def eb(err):
+            log.warn("talking to ascoltami: %s", err.getErrorMessage())
+            cast(IReactorTime, reactor).callLater(2, self.pollMusicTime) # type: ignore
+
+        d = treq.get(networking.musicPlayer.path("time").toPython())
+        d.addCallback(cb)
+        d.addErrback(eb)  # note this includes errors in cb()
+
+    def sendTime(self, t):
+        """request that the player go to this time"""
+        treq.post(
+            networking.musicPlayer.path('time'),
+            data=json.dumps({
+                "t": time
+            }).encode('utf8'),
+            headers={b"content-type": [b"application/json"]},
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/player.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,195 @@
+#!/usr/bin/python
+"""
+alternate to the mpd music player, for ascoltami
+"""
+
+import time, logging, traceback
+from gi.repository import Gst # type: ignore
+from twisted.internet import task
+from light9.metrics import metrics
+log = logging.getLogger()
+
+
+
+class Player:
+
+    def __init__(self, autoStopOffset=4, onEOS=None):
+        """autoStopOffset is the number of seconds before the end of
+        song before automatically stopping (which is really pausing).
+        onEOS is an optional function to be called when we reach the
+        end of a stream (for example, can be used to advance the song).
+        It is called with one argument which is the URI of the song that
+        just finished."""
+        self.autoStopOffset = autoStopOffset
+        self.playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
+
+        self.playStartTime = 0
+        self.lastWatchTime = 0
+        self.autoStopTime = 0
+        self.lastSetSongUri = None
+        self.onEOS = onEOS
+
+        task.LoopingCall(self.watchTime).start(.050)
+
+        #bus = self.pipeline.get_bus()
+        # not working- see notes in pollForMessages
+        #self.watchForMessages(bus)
+
+    def watchTime(self):
+        try:
+            self.pollForMessages()
+
+            t = self.currentTime()
+            log.debug("watch %s < %s < %s", self.lastWatchTime,
+                      self.autoStopTime, t)
+            if self.lastWatchTime < self.autoStopTime < t:
+                log.info("autostop")
+                self.pause()
+
+            self.lastWatchTime = t
+        except Exception:
+            traceback.print_exc()
+
+    def watchForMessages(self, bus):
+        """this would be nicer than pollForMessages but it's not working for
+        me. It's like add_signal_watch isn't running."""
+        bus.add_signal_watch()
+
+        def onEos(*args):
+            print("onEos", args)
+            if self.onEOS is not None:
+                self.onEOS(self.getSong())
+
+        bus.connect('message::eos', onEos)
+
+        def onStreamStatus(bus, message):
+            print("streamstatus", bus, message)
+            (statusType, _elem) = message.parse_stream_status()
+            if statusType == Gst.StreamStatusType.ENTER:
+                self.setupAutostop()
+
+        bus.connect('message::stream-status', onStreamStatus)
+
+    def pollForMessages(self):
+        """bus.add_signal_watch seems to be having no effect, but this works"""
+        bus = self.pipeline.get_bus()
+        mt = Gst.MessageType
+        msg = bus.poll(
+            mt.EOS | mt.STREAM_STATUS | mt.ERROR,  # | mt.ANY,
+            0)
+        if msg is not None:
+            log.debug("bus message: %r %r", msg.src, msg.type)
+            # i'm trying to catch here a case where the pulseaudio
+            # output has an error, since that's otherwise kind of
+            # mysterious to diagnose. I don't think this is exactly
+            # working.
+            if msg.type == mt.ERROR:
+                log.error(repr(msg.parse_error()))
+            if msg.type == mt.EOS:
+                if self.onEOS is not None:
+                    self.onEOS(self.getSong())
+            if msg.type == mt.STREAM_STATUS:
+                (statusType, _elem) = msg.parse_stream_status()
+                if statusType == Gst.StreamStatusType.ENTER:
+                    self.setupAutostop()
+
+    def seek(self, t):
+        isSeekable = self.playbin.seek_simple(
+            Gst.Format.TIME,
+            Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE | Gst.SeekFlags.SKIP,
+            t * Gst.SECOND)
+        if not isSeekable:
+            raise ValueError('seek_simple failed')
+        self.playStartTime = time.time()
+
+    def setSong(self, songLoc, play=True):
+        """
+        uri like file:///my/proj/light9/show/dance2010/music/07.wav
+        """
+        log.info("set song to %r" % songLoc)
+        self.pipeline.set_state(Gst.State.READY)
+        self.preload(songLoc)
+        self.pipeline.set_property("uri", songLoc)
+        self.lastSetSongUri = songLoc
+        # todo: don't have any error report yet if the uri can't be read
+        if play:
+            self.pipeline.set_state(Gst.State.PLAYING)
+            self.playStartTime = time.time()
+
+    def getSong(self):
+        """Returns the URI of the current song."""
+        # even the 'uri' that I just set isn't readable yet
+        return self.playbin.get_property("uri") or self.lastSetSongUri
+
+    def preload(self, songPath):
+        """
+        to avoid disk seek stutters, which happened sometimes (in 2007) with the
+        non-gst version of this program, we read the whole file to get
+        more OS caching.
+
+        i don't care that it's blocking.
+        """
+        log.info("preloading %s", songPath)
+        assert songPath.startswith("file://"), songPath
+        try:
+            open(songPath[len("file://"):], 'rb').read()
+        except IOError as e:
+            log.error("couldn't preload %s, %r", songPath, e)
+            raise
+
+    @metrics('current_time').time()
+    def currentTime(self):
+        success, cur = self.playbin.query_position(Gst.Format.TIME)
+        if not success:
+            return 0
+        return cur / Gst.SECOND
+
+    def duration(self):
+        success, dur = self.playbin.query_duration(Gst.Format.TIME)
+        if not success:
+            return 0
+        return dur / Gst.SECOND
+
+    def states(self):
+        """json-friendly object describing the interesting states of
+        the player nodes"""
+        success, state, pending = self.playbin.get_state(timeout=0)
+        return {
+            "current": {
+                "name": state.value_nick
+            },
+            "pending": {
+                "name": state.value_nick
+            }
+        }
+
+    def pause(self):
+        self.pipeline.set_state(Gst.State.PAUSED)
+
+    def isAutostopped(self):
+        """
+        are we stopped at the autostop time?
+        """
+        if self.autoStopOffset < .01:
+            return False
+        pos = self.currentTime()
+        autoStop = self.duration() - self.autoStopOffset
+        return not self.isPlaying() and abs(
+            pos - autoStop) < 1  # i've seen .4 difference here
+
+    def resume(self):
+        self.pipeline.set_state(Gst.State.PLAYING)
+
+    def setupAutostop(self):
+        dur = self.duration()
+        if dur == 0:
+            raise ValueError("duration=0, can't set autostop")
+        self.autoStopTime = (dur - self.autoStopOffset)
+        log.info("autostop will be at %s", self.autoStopTime)
+        # pipeline.seek can take a stop time, but using that wasn't
+        # working out well. I'd get pauses at other times that were
+        # hard to remove.
+
+    def isPlaying(self):
+        _, state, _ = self.pipeline.get_state(timeout=0)
+        return state == Gst.State.PLAYING
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/playlist.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,55 @@
+from light9.showconfig import songOnDisk
+from light9.namespaces import L9
+
+
+class NoSuchSong(ValueError):
+    """Raised when a song is requested that doesn't exist (e.g. one
+    after the last song in the playlist)."""
+
+
+class Playlist:
+
+    def __init__(self, graph, playlistUri):
+        self.graph = graph
+        self.playlistUri = playlistUri
+        self.songs = list(graph.items(playlistUri))
+
+    def nextSong(self, currentSong):
+        """Returns the next song in the playlist or raises NoSuchSong if 
+        we are at the end of the playlist."""
+        try:
+            currentIndex = self.songs.index(currentSong)
+        except IndexError:
+            raise ValueError("%r is not in the current playlist (%r)." %
+                             (currentSong, self.playlistUri))
+
+        try:
+            nextSong = self.songs[currentIndex + 1]
+        except IndexError:
+            raise NoSuchSong("%r is the last item in the playlist." %
+                             currentSong)
+
+        return nextSong
+
+    def allSongs(self):
+        """Returns a list of all song URIs in order."""
+        return self.songs
+
+    def allSongPaths(self):
+        """Returns a list of the filesystem paths to all songs in order."""
+        paths = []
+        for song in self.songs:
+            paths.append(songOnDisk(song))
+        return paths
+
+    def songPath(self, uri):
+        """filesystem path to a song"""
+        raise NotImplementedError("see showconfig.songOnDisk")
+        # maybe that function should be moved to this method
+
+    @classmethod
+    def fromShow(cls, graph, show):
+        playlistUri = graph.value(show, L9['playList'])
+        if not playlistUri:
+            raise ValueError("%r has no l9:playList" % show)
+        return cls(graph, playlistUri)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/webapp.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,188 @@
+import asyncio
+import json
+import logging
+import socket
+import subprocess
+import time
+from typing import cast
+
+from rdflib import RDFS, Graph, URIRef
+from light9.ascoltami.player import Player
+from sse_starlette.sse import EventSourceResponse
+from starlette.requests import Request
+from starlette.responses import JSONResponse, PlainTextResponse
+
+from light9.namespaces import L9
+from light9.showconfig import getSongsFromShow, showUri, songOnDisk
+
+log = logging.getLogger()
+_songUris = {}  # locationUri : song
+
+
+def songLocation(graph, songUri):
+    loc = URIRef("file://%s" % songOnDisk(songUri))
+    _songUris[loc] = songUri
+    return loc
+
+
+def songUri(graph, locationUri):
+    return _songUris[locationUri]
+
+
+async def get_config(request: Request) -> JSONResponse:
+    return JSONResponse(
+        dict(
+            host=socket.gethostname(),
+            show=str(showUri()),
+            times={
+                # these are just for the web display. True values are on Player.__init__
+                'intro': 4,
+                'post': 0
+            }))
+
+
+def playerSongUri(graph, player):
+    """or None"""
+
+    playingLocation = player.getSong()
+    if playingLocation:
+        return songUri(graph, URIRef(playingLocation))
+    else:
+        return None
+
+
+def currentState(graph, player):
+    if player.isAutostopped():
+        nextAction = 'finish'
+    elif player.isPlaying():
+        nextAction = 'disabled'
+    else:
+        nextAction = 'play'
+
+    return {
+        "song": playerSongUri(graph, player),
+        "started": player.playStartTime,
+        "duration": player.duration(),
+        "playing": player.isPlaying(),
+        "t": player.currentTime(),
+        "state": player.states(),
+        "next": nextAction,
+    }
+
+
+async def get_time(request: Request) -> JSONResponse:
+    player = cast(Player, request.app.state.player)
+    graph = cast(Graph, request.app.state.graph)
+    return JSONResponse(currentState(graph, player))
+
+
+async def post_time(request: Request) -> PlainTextResponse:
+    """
+    post a json object with {pause: true} or {resume: true} if you
+    want those actions. Use {t: <seconds>} to seek, optionally
+    with a pause/resume command too.
+    """
+    params = await request.json()
+    player = cast(Player, request.app.state.player)
+    if params.get('pause', False):
+        player.pause()
+    if params.get('resume', False):
+        player.resume()
+    if 't' in params:
+        player.seek(params['t'])
+    return PlainTextResponse("ok")
+
+
+async def timeStream(request: Request):
+    graph = cast(Graph, request.app.state.graph)
+    player = cast(Player, request.app.state.player)
+    async def event_generator():
+        last_sent = None
+        last_sent_time = 0.0
+
+        while True:
+            now = time.time()
+            msg = currentState(graph, player)
+            if msg != last_sent or now > last_sent_time + 2:
+                event_data = json.dumps(msg)
+                yield event_data
+                last_sent = msg
+                last_sent_time = now
+
+            await asyncio.sleep(0.1)
+
+    return EventSourceResponse(event_generator())
+
+
+async def get_songs(request: Request) -> JSONResponse:
+    graph = cast(Graph, request.app.state.graph)
+
+    songs = getSongsFromShow(graph, request.app.state.show)
+
+    songs_data = [
+        {  #
+            "uri": s,
+            "path": graph.value(s, L9['songFilename']),
+            "label": graph.value(s, RDFS.label)
+        } for s in songs
+    ]
+
+    return JSONResponse({"songs": songs_data})
+
+
+async def post_song(request: Request) -> PlainTextResponse:
+    """post a uri of song to switch to (and start playing)"""
+    graph = cast(Graph, request.app.state.graph)
+    player = cast(Player, request.app.state.player)
+
+    song_uri = URIRef((await request.body()).decode('utf8'))
+    player.setSong(songLocation(graph, song_uri))
+
+    return PlainTextResponse("ok")
+
+
+async def post_seekPlayOrPause(request: Request) -> PlainTextResponse:
+    """curveCalc's ctrl-p or a vidref scrub"""
+    player = cast(Player, request.app.state.player)
+
+    data = await request.json()
+    if 'scrub' in data:
+        player.pause()
+        player.seek(data['scrub'])
+        return PlainTextResponse("ok")
+    if 'action' in data:
+        if data['action'] == 'play':
+            player.resume()
+        elif data['action'] == 'pause':
+            player.pause()
+        else:
+            raise NotImplementedError
+        return PlainTextResponse("ok")
+    if player.isPlaying():
+        player.pause()
+    else:
+        player.seek(data['t'])
+        player.resume()
+
+    return PlainTextResponse("ok")
+
+
+async def post_output(request: Request) -> PlainTextResponse:
+    d = await request.json()
+    subprocess.check_call(["bin/movesinks", str(d['sink'])])
+    return PlainTextResponse("ok")
+
+
+async def post_goButton(request: Request) -> PlainTextResponse:
+    """
+    if music is playing, this silently does nothing.
+    """
+    player = cast(Player, request.app.state.player)
+
+    if player.isAutostopped():
+        player.resume()
+    elif player.isPlaying():
+        pass
+    else:
+        player.resume()
+    return PlainTextResponse("ok")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/ascoltami/webapp_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,2 @@
+# todo
+# test that GET /songs doesn't break, etc
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/chase.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,44 @@
+def chase(t,
+          ontime=0.5,
+          offset=0.2,
+          onval=1.0,
+          offval=0.0,
+          names=None,
+          combiner=max):
+    names = names or []
+    # maybe this is better:
+    # period = ontime + ((offset + ontime) * (len(names) - 1))
+    period = (offset + ontime) * len(names)
+    outputs = {}
+    for index, name in enumerate(names):
+        # normalize our time
+        local_offset = (offset + ontime) * index
+        local_t = t - local_offset
+        local_t %= period
+
+        # see if we're still in the on part
+        if local_t <= ontime:
+            value = onval
+        else:
+            value = offval
+
+        # it could be in there twice (in a bounce like (1, 2, 3, 2)
+        if name in outputs:
+            outputs[name] = combiner(value, outputs[name])
+        else:
+            outputs[name] = value
+    return outputs
+
+
+if __name__ == "__main__":
+    # a little testing
+    for x in range(80):
+        x /= 20.0
+        output = chase(x,
+                       onval='x',
+                       offval=' ',
+                       ontime=0.1,
+                       offset=0.2,
+                       names=('a', 'b', 'c', 'd'))
+        output = sorted(list(output.items()))
+        print("%.2f\t%s" % (x, ' '.join([str(x) for x in output])))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/clientsession.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+"""
+some clients will support the concept of a named session that keeps
+multiple instances of that client separate
+"""
+from rdflib import URIRef
+from urllib.parse import quote
+from light9 import showconfig
+
+
+def add_option(parser):
+    parser.add_option(
+        '-s',
+        '--session',
+        help="name of session used for levels and window position",
+        default='default')
+
+
+def getUri(appName, opts):
+    return URIRef("%s/sessions/%s/%s" %
+                  (showconfig.showUri(), appName, quote(opts.session, safe='')))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/coffee.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,23 @@
+from cycloneerr import PrettyErrorHandler
+import cyclone.web
+import subprocess
+
+
+class StaticCoffee(PrettyErrorHandler, cyclone.web.RequestHandler):
+    """
+    e.g.
+
+            (r'/effect\.js', StaticCoffee, {
+                'src': 'light9/effecteval/effect.coffee'
+            }),
+    """ # noqa
+
+    def initialize(self, src):
+        super(StaticCoffee, self).initialize()
+        self.src = src
+
+    def get(self):
+        self.set_header('Content-Type', 'application/javascript')
+        self.write(
+            subprocess.check_output(
+                ['/usr/bin/coffee', '--compile', '--print', self.src]))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/collector.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,221 @@
+import logging
+import time
+from typing import Dict, List, Set, Tuple, cast
+from light9.typedgraph import typedValue
+
+from prometheus_client import Summary
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import URIRef
+
+from light9.collector.device import resolve, toOutputAttrs
+from light9.collector.output import Output as OutputInstance
+from light9.collector.weblisteners import WebListeners
+from light9.effect.settings import DeviceSettings
+from light9.namespaces import L9, RDF
+from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceClass, DeviceSetting, DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr,
+                             OutputRange, OutputUri, OutputValue, UnixTime, VTUnion, uriTail)
+
+log = logging.getLogger('collector')
+
+STAT_SETATTR = Summary('set_attr', 'setAttr calls')
+
+def makeDmxMessageIndex(base: DmxIndex, offset: DmxIndex) -> DmxMessageIndex:
+    return DmxMessageIndex(base + offset - 1)
+
+
+def _outputMap(graph: SyncedGraph, outputs: Set[OutputUri]) -> Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]:
+    """From rdf config graph, compute a map of
+       (device, outputattr) : (output, index)
+    that explains which output index to set for any device update.
+    """
+    ret = cast(Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]], {})
+
+    for dc in graph.subjects(RDF.type, L9['DeviceClass']):
+        log.info('  mapping devices of class %s', dc)
+        for dev in graph.subjects(RDF.type, dc):
+            dev = cast(DeviceUri, dev)
+            log.info('    💡 mapping device %s', dev)
+            universe = typedValue(OutputUri, graph, dev, L9['dmxUniverse'])
+            if universe not in outputs:
+                raise ValueError(f'{dev=} is configured to be in {universe=}, but we have no Output for that universe')
+            try:
+                dmxBase = typedValue(DmxIndex, graph, dev, L9['dmxBase'])
+            except ValueError:
+                raise ValueError('no :dmxBase for %s' % dev)
+
+            for row in sorted(graph.objects(dc, L9['attr']), key=str):
+                outputAttr = typedValue(OutputAttr, graph, row, L9['outputAttr'])
+                offset = typedValue(DmxIndex, graph, row, L9['dmxOffset'])
+                index = makeDmxMessageIndex(dmxBase, offset)
+                ret[(dev, outputAttr)] = (universe, index)
+                log.info(f'      {uriTail(outputAttr):15} maps to {uriTail(universe)} index {index}')
+    return ret
+
+
+class Collector:
+    """receives setAttrs calls; combines settings; renders them into what outputs like; calls Output.update"""
+
+    def __init__(self, graph: SyncedGraph, outputs: List[OutputInstance], listeners: WebListeners, clientTimeoutSec: float = 10):
+        self.graph = graph
+        self.outputs = outputs
+        self.listeners = listeners
+        self.clientTimeoutSec = clientTimeoutSec
+
+        self._initTime = time.time()
+        self._outputByUri: Dict[OutputUri, OutputInstance] = {}
+        self._deviceType: Dict[DeviceUri, DeviceClass] = {}
+        self.remapOut: Dict[Tuple[DeviceUri, OutputAttr], OutputRange] = {}
+
+        self.graph.addHandler(self._compile)
+
+        # rename to activeSessons ?
+        self.lastRequest: Dict[Tuple[ClientType, ClientSessionType], Tuple[UnixTime, Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]]] = {}
+
+        # (dev, devAttr): value to use instead of 0
+        self.stickyAttrs: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
+
+    def _compile(self):
+        log.info('Collector._compile:')
+        self._outputByUri = self._compileOutputByUri()
+        self._outputMap = _outputMap(self.graph, set(self._outputByUri.keys()))
+
+        self._deviceType.clear()
+        self.remapOut.clear()
+        for dc in self.graph.subjects(RDF.type, L9['DeviceClass']):
+            dc = cast(DeviceClass, dc)
+            for dev in self.graph.subjects(RDF.type, dc):
+                dev = cast(DeviceUri, dev)
+                self._deviceType[dev] = dc
+                self._compileRemapForDevice(dev)
+
+    def _compileOutputByUri(self) -> Dict[OutputUri, OutputInstance]:
+        ret = {}
+        for output in self.outputs:
+            ret[OutputUri(output.uri)] = output
+        return ret
+
+    def _compileRemapForDevice(self, dev: DeviceUri):
+        for remap in self.graph.objects(dev, L9['outputAttrRange']):
+            attr = typedValue(OutputAttr, self.graph, remap, L9['outputAttr'])
+            start = typedValue(float, self.graph, remap, L9['start'])
+            end = typedValue(float, self.graph, remap, L9['end'])
+            self.remapOut[(dev, attr)] = OutputRange((start, end))
+
+    @STAT_SETATTR.time()
+    def setAttrs(self, client: ClientType, clientSession: ClientSessionType, settings: DeviceSettings, sendTime: UnixTime):
+        """
+        Given DeviceSettings, we resolve conflicting values,
+        process them into output attrs, and call Output.update
+        to send the new outputs.
+
+        client is a string naming the type of client.
+        (client, clientSession) is a unique client instance.
+        clientSession is deprecated.
+
+        Each client session's last settings will be forgotten
+        after clientTimeoutSec.
+        """
+        # todo: cleanup session code if we really don't want to be able to run multiple sessions of one client
+        clientSession = ClientSessionType("no_longer_used")
+
+        now = UnixTime(time.time())
+        self._warnOnLateRequests(client, now, sendTime)
+
+        self._forgetStaleClients(now)
+
+        self.lastRequest[(client, clientSession)] = (now, self._resolvedSettingsDict(settings))
+
+        deviceAttrs = self._merge(iter(self.lastRequest.values()))
+
+        outputAttrsByDevice = self._convertToOutputAttrsPerDevice(deviceAttrs)
+        pendingOut = self._flattenDmxOutput(outputAttrsByDevice)
+
+        t2 = time.time()
+
+        self._updateOutputs(pendingOut)
+
+        t3 = time.time()
+        if t2 - now > .030 or t3 - t2 > .030:
+            log.warning("slow setAttrs: prepare %.1fms -> updateOutputs %.1fms" % ((t2 - now) * 1000, (t3 - t2) * 1000))
+
+    def _warnOnLateRequests(self, client, now, sendTime):
+        requestLag = now - sendTime
+        if requestLag > .1 and now > self._initTime + 10 and getattr(self, '_lastWarnTime', 0) < now - 3:
+            self._lastWarnTime = now
+            log.warning('collector.setAttrs from %s is running %.1fms after the request was made', client, requestLag * 1000)
+
+    def _forgetStaleClients(self, now):
+        staleClientSessions = []
+        for clientSession, (reqTime, _) in self.lastRequest.items():
+            if reqTime < now - self.clientTimeoutSec:
+                staleClientSessions.append(clientSession)
+        for clientSession in staleClientSessions:
+            log.info('forgetting stale client %r', clientSession)
+            del self.lastRequest[clientSession]
+
+    # todo: move to settings.py
+    def _resolvedSettingsDict(self, settingsList: DeviceSettings) -> Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]:
+        out: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
+        for devUri, devAttr, val in settingsList.asList():
+            if (devUri, devAttr) in out:
+                existingVal = out[(devUri, devAttr)]
+                out[(devUri, devAttr)] = resolve(self._deviceType[devUri], devAttr, [existingVal, val])
+            else:
+                out[(devUri, devAttr)] = val
+        return out
+
+    def _merge(self, lastRequests):
+        deviceAttrs: Dict[DeviceUri, Dict[DeviceAttr, VTUnion]] = {}  # device: {deviceAttr: value}
+        for _, lastSettings in lastRequests:
+            for (device, deviceAttr), value in lastSettings.items():
+                if (device, deviceAttr) in self.remapOut:
+                    start, end = self.remapOut[(device, deviceAttr)]
+                    value = start + float(value) * (end - start)
+
+                attrs = deviceAttrs.setdefault(device, {})
+                if deviceAttr in attrs:
+                    value = resolve(device, deviceAttr, [attrs[deviceAttr], value])
+                attrs[deviceAttr] = value
+                # list should come from the graph. these are attrs
+                # that should default to holding the last position,
+                # not going to 0.
+                if deviceAttr in [L9['rx'], L9['ry'], L9['zoom'], L9['focus']]:
+                    self.stickyAttrs[(device, deviceAttr)] = cast(float, value)
+
+        # e.g. don't let an unspecified rotation go to 0
+        for (d, da), v in self.stickyAttrs.items():
+            daDict = deviceAttrs.setdefault(d, {})
+            if da not in daDict:
+                daDict[da] = v
+
+        return deviceAttrs
+
+    def _convertToOutputAttrsPerDevice(self, deviceAttrs):
+        ret: Dict[DeviceUri, Dict[OutputAttr, OutputValue]] = {}
+        for d, devType in self._deviceType.items():
+            try:
+                ret[d] = toOutputAttrs(devType, deviceAttrs.get(d, {}))
+                self.listeners.outputAttrsSet(d, ret[d], self._outputMap)
+            except Exception as e:
+                log.error('failing toOutputAttrs on %s: %r', d, e)
+        return ret
+
+    def _flattenDmxOutput(self, outputAttrs: Dict[DeviceUri, Dict[OutputAttr, OutputValue]]) -> Dict[OutputUri, bytearray]:
+        pendingOut = cast(Dict[OutputUri, bytearray], {})
+        for outUri in self._outputByUri.keys():
+            pendingOut[outUri] = bytearray(512)
+
+        for device, attrs in outputAttrs.items():
+            for outputAttr, value in attrs.items():
+                outputUri, _index = self._outputMap[(device, outputAttr)]
+                index = DmxMessageIndex(_index)
+                outArray = pendingOut[outputUri]
+                if outArray[index] != 0:
+                    log.warning(f'conflict: {outputUri} output array was already nonzero at 0-based index {index}')
+                    raise ValueError(f"someone already wrote to index {index}")
+                outArray[index] = value
+        return pendingOut
+
+    def _updateOutputs(self, pendingOut: Dict[OutputUri, bytearray]):
+        for uri, buf in pendingOut.items():
+            self._outputByUri[uri].update(bytes(buf))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/collector_client.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,12 @@
+from light9 import networking
+from light9.effect.settings import DeviceSettings
+from light9.metrics import metrics
+from twisted.internet import defer
+from txzmq import ZmqEndpoint, ZmqFactory, ZmqPushConnection
+import json, time, logging
+import treq
+
+log = logging.getLogger('coll_client')
+
+
+        # d = treq.put(networking.collector.path('attrs'), data=msg, timeout=1)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/collector_client_asyncio.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,42 @@
+import asyncio
+import json
+import logging
+import time
+from light9 import networking
+from light9.effect.settings import DeviceSettings
+import zmq.asyncio
+from prometheus_client import Summary
+
+log = logging.getLogger('coll_client')
+
+ZMQ_SEND = Summary('zmq_send', 'calls')
+
+
+def toCollectorJson(client, session, settings: DeviceSettings) -> str:
+    assert isinstance(settings, DeviceSettings)
+    return json.dumps({
+        'settings': settings.asList(),
+        'client': client,
+        'clientSession': session,
+        'sendTime': time.time(),
+    })
+
+
+class _Sender:
+
+    def __init__(self):
+        self.context = zmq.asyncio.Context()
+        self.socket = self.context.socket(zmq.PUB)
+        self.socket.connect('tcp://127.0.0.1:9203')  #todo: tie to :collectorZmq in graph
+        # old version used: 'tcp://%s:%s' % (service.host, service.port)
+
+    @ZMQ_SEND.time()
+    async def send(self, client: str, session: str, settings: DeviceSettings):
+        msg = toCollectorJson(client, session, settings).encode('utf8')
+        # log.info(f'zmq send {len(msg)}')
+        await self.socket.send_multipart([b'setAttr', msg])
+
+
+_sender = _Sender()
+
+sendToCollector = _sender.send
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/collector_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,204 @@
+import datetime
+import time
+import unittest
+
+from freezegun import freeze_time
+from light9.effect.settings import DeviceSettings
+from rdflib import Namespace
+
+from light9.collector.collector import Collector
+from light9.collector.output import Output
+from light9.collector.weblisteners import WebListeners
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import DEV, L9
+from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceUri, HexColor, UnixTime)
+
+UDMX = Namespace('http://light9.bigasterisk.com/output/udmx/')
+DMX0 = Namespace('http://light9.bigasterisk.com/output/dmx0/')
+
+PREFIX = '''
+   @prefix : <http://light9.bigasterisk.com/> .
+        @prefix dev: <http://light9.bigasterisk.com/device/> .
+        @prefix udmx: <http://light9.bigasterisk.com/output/udmx/> .
+        @prefix dmx0: <http://light9.bigasterisk.com/output/dmx0/> .
+'''
+
+THEATER = '''
+        :brightness         a :DeviceAttr; :dataType :scalar .
+
+        :SimpleDimmer a :DeviceClass;
+          :deviceAttr :brightness;
+          :attr
+            [ :outputAttr :level; :dmxOffset 0 ] .
+
+        :ChauvetColorStrip a :DeviceClass;
+          :deviceAttr :color;
+          :attr
+            [ :outputAttr :mode;  :dmxOffset 0 ],
+            [ :outputAttr :red;   :dmxOffset 1 ],
+            [ :outputAttr :green; :dmxOffset 2 ],
+            [ :outputAttr :blue;  :dmxOffset 3 ] .
+
+'''
+
+t0 = UnixTime(0)
+client1 = ClientType('client1')
+client2 = ClientType('client2')
+session1 = ClientSessionType('sess1')
+session2 = ClientSessionType('sess2')
+colorStrip = DeviceUri(DEV['colorStrip'])
+inst1 = DeviceUri(DEV['inst1'])
+brightness = DeviceAttr(L9['brightness'])
+color = DeviceAttr(L9['color'])
+
+
+class MockOutput(Output):
+
+    def __init__(self, uri, connections):
+        self.connections = connections
+        self.updates = []
+        self.uri = uri
+        self.numChannels = 4
+
+    def update(self, values):
+        self.updates.append(list(values[:self.numChannels]))
+
+
+class MockWebListeners(WebListeners):
+
+    def __init__(self):
+        "do not init"
+
+    def outputAttrsSet(self, *a, **kw):
+        pass
+
+
+class TestCollector(unittest.TestCase):
+
+    def setUp(self):
+        self.graph = MockSyncedGraph(PREFIX + THEATER + '''
+
+        dev:colorStrip a :Device, :ChauvetColorStrip;
+          :dmxUniverse udmx:; :dmxBase 1;
+          :red dev:colorStripRed;
+          :green dev:colorStripGreen;
+          :blue dev:colorStripBlue;
+          :mode dev:colorStripMode .
+
+        dev:inst1 a :Device, :SimpleDimmer;
+          :dmxUniverse dmx0:; :dmxBase 1;
+          :level dev:inst1Brightness .
+        ''')
+
+        self.dmx0 = MockOutput(DMX0, [(0, DMX0['c1'])])
+        self.udmx = MockOutput(UDMX, [(0, UDMX['c1']), (1, UDMX['c2']), (2, UDMX['c3']), (3, UDMX['c4'])])
+
+    def testRoutesColorOutput(self):
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
+
+        self.assertEqual([
+            [215, 0, 255, 0],
+        ], self.udmx.updates)
+        self.assertEqual([
+            [0, 0, 0, 0],
+        ], self.dmx0.updates)
+
+    def testOutputMaxOfTwoClients(self):
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#ff0000'))]), t0)
+        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#333333'))]), t0)
+
+        self.assertEqual([[215, 255, 0, 0], [215, 255, 51, 51]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
+
+    def testClientOnSameOutputIsRememberedOverCalls(self):
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#080000'))]), t0)
+        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#060000'))]), t0)
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#050000'))]), t0)
+
+        self.assertEqual([[215, 8, 0, 0], [215, 8, 0, 0], [215, 6, 0, 0]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
+
+    def testClientsOnDifferentOutputs(self):
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#aa0000'))]), t0)
+        c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
+
+        # ok that udmx is flushed twice- it can screen out its own duplicates
+        self.assertEqual([[215, 170, 0, 0], [215, 170, 0, 0]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
+
+    def testNewSessionReplacesPreviousOutput(self):
+        # ..as opposed to getting max'd with it
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .8)]), t0)
+        c.setAttrs(client1, session2, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
+
+        self.assertEqual([[204, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
+
+    def testNewSessionDropsPreviousSettingsOfOtherAttrs(self):
+        c = Collector(MockSyncedGraph(PREFIX + THEATER + '''
+
+        dev:colorStrip a :Device, :ChauvetColorStrip;
+          :dmxUniverse udmx:; :dmxBase 1;
+          :red dev:colorStripRed;
+          :green dev:colorStripGreen;
+          :blue dev:colorStripBlue;
+          :mode dev:colorStripMode .
+
+        dev:inst1 a :Device, :SimpleDimmer;
+          :dmxUniverse dmx0:; :dmxBase 0;
+          :level dev:inst1Brightness .
+        '''),
+                      outputs=[self.dmx0, self.udmx],
+                      listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#ff0000'))]), t0)
+        c.setAttrs(client1, session2, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
+
+        self.assertEqual([[215, 255, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
+
+    def testClientIsForgottenAfterAWhile(self):
+        with freeze_time(datetime.datetime.now()) as ft:
+            c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+            c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), UnixTime(time.time()))
+            ft.tick(delta=datetime.timedelta(seconds=1))
+            # this max's with client1's value so we still see .5
+            c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .2)]), UnixTime(time.time()))
+            ft.tick(delta=datetime.timedelta(seconds=9.1))
+            # now client1 is forgotten, so our value appears
+            c.setAttrs(client2, session1, DeviceSettings(self.graph, [(inst1, brightness, .4)]), UnixTime(time.time()))
+            self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0], [102, 0, 0, 0]], self.dmx0.updates)
+
+    def testClientUpdatesAreNotMerged(self):
+        # second call to setAttrs forgets the first
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+        t0 = UnixTime(time.time())
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, .5)]), t0)
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(inst1, brightness, 1)]), t0)
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [(colorStrip, color, HexColor('#00ff00'))]), t0)
+
+        self.assertEqual([[215, 0, 0, 0], [215, 0, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
+        self.assertEqual([[127, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
+
+    def testRepeatedAttributesInOneRequestGetResolved(self):
+        c = Collector(self.graph, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [
+            (inst1, brightness, .5),
+            (inst1, brightness, .3),
+        ]), t0)
+        self.assertEqual([[127, 0, 0, 0]], self.dmx0.updates)
+
+        c.setAttrs(client1, session1, DeviceSettings(self.graph, [
+            (inst1, brightness, .3),
+            (inst1, brightness, .5),
+        ]), t0)
+        self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/device.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,260 @@
+import logging
+from typing import Dict, List, Any, TypeVar, cast
+from light9.namespaces import L9
+from rdflib import Literal, URIRef
+from webcolors import hex_to_rgb, rgb_to_hex
+from colormath.color_objects import sRGBColor, CMYColor
+import colormath.color_conversions
+from light9.newtypes import VT, DeviceClass, HexColor, OutputAttr, OutputValue, DeviceUri, DeviceAttr, VTUnion
+
+log = logging.getLogger('device')
+
+
+class Device:
+    pass
+
+
+class ChauvetColorStrip(Device):
+    """
+     device attrs:
+       color
+    """
+
+
+class Mini15(Device):
+    """
+    plan:
+
+      device attrs
+        rx, ry
+        color
+        gobo
+        goboShake
+        imageAim (configured with a file of calibration data)
+    """
+
+
+def clamp255(x):
+    return min(255, max(0, x))
+
+
+def _8bit(f):
+    if not isinstance(f, (int, float)):
+        raise TypeError(repr(f))
+    return clamp255(int(f * 255))
+
+
+def _maxColor(values: List[HexColor]) -> HexColor:
+    rgbs = [hex_to_rgb(v) for v in values]
+    maxes = [max(component) for component in zip(*rgbs)]
+    return cast(HexColor, rgb_to_hex(tuple(maxes)))
+
+
+def resolve(
+        deviceType: DeviceClass,
+        deviceAttr: DeviceAttr,
+        values: List[VTUnion]) -> VTUnion:  # todo: return should be VT
+    """
+    return one value to use for this attr, given a set of them that
+    have come in simultaneously. len(values) >= 1.
+
+    bug: some callers are passing a device instance for 1st arg
+    """
+    if len(values) == 1:
+        return values[0]
+    if deviceAttr == DeviceAttr(L9['color']):
+        return _maxColor(cast(List[HexColor], values))
+    # incomplete. how-to-resolve should be on the DeviceAttr defs in the graph.
+    if deviceAttr in map(DeviceAttr, [L9['rx'], L9['ry'], L9['zoom'], L9['focus'], L9['iris']]):
+        floatVals = []
+        for v in values:
+            if isinstance(v, Literal):
+                floatVals.append(float(v.toPython()))
+            elif isinstance(v, (int, float)):
+                floatVals.append(float(v))
+            else:
+                raise TypeError(repr(v))
+
+        # averaging with zeros? not so good
+        return sum(floatVals) / len(floatVals)
+    return max(values)
+
+
+def toOutputAttrs(
+        deviceType: DeviceClass,
+        deviceAttrSettings: Dict[DeviceAttr, VTUnion  # TODO
+                                ]) -> Dict[OutputAttr, OutputValue]:
+    return dict((OutputAttr(u), OutputValue(v)) for u, v in untype_toOutputAttrs(deviceType, deviceAttrSettings).items())
+
+
+def untype_toOutputAttrs(deviceType, deviceAttrSettings) -> Dict[URIRef, int]:
+    """
+    Given device attr settings like {L9['color']: Literal('#ff0000')},
+    return a similar dict where the keys are output attrs (like
+    L9['red']) and the values are suitable for Collector.setAttr
+
+    :outputAttrRange happens before we get here.
+    """
+
+    def floatAttr(attr, default=0):
+        out = deviceAttrSettings.get(attr)
+        if out is None:
+            return default
+        return float(out.toPython()) if isinstance(out, Literal) else out
+
+    def rgbAttr(attr):
+        color = deviceAttrSettings.get(attr, '#000000')
+        r, g, b = hex_to_rgb(color)
+        return r, g, b
+
+    def cmyAttr(attr):
+        rgb = sRGBColor.new_from_rgb_hex(deviceAttrSettings.get(attr, '#000000'))
+        out = colormath.color_conversions.convert_color(rgb, CMYColor)
+        return (_8bit(out.cmy_c), _8bit(out.cmy_m), _8bit(out.cmy_y))
+
+    def fine16Attr(attr, scale=1.0):
+        x = floatAttr(attr) * scale
+        hi = _8bit(x)
+        lo = _8bit((x * 255) % 1.0)
+        return hi, lo
+
+    def choiceAttr(attr):
+        # todo
+        if deviceAttrSettings.get(attr) == L9['g1']:
+            return 3
+        if deviceAttrSettings.get(attr) == L9['g2']:
+            return 10
+        return 0
+
+    if deviceType == L9['ChauvetColorStrip']:
+        r, g, b = rgbAttr(L9['color'])
+        return {L9['mode']: 215, L9['red']: r, L9['green']: g, L9['blue']: b}
+    elif deviceType == L9['Bar612601d']:
+        r, g, b = rgbAttr(L9['color'])
+        return {L9['red']: r, L9['green']: g, L9['blue']: b}
+    elif deviceType == L9['LedPar90']:
+        r, g, b = rgbAttr(L9['color'])
+        return {L9['master']: 255, L9['red']: r, L9['green']: g, L9['blue']: b, L9['white']: 0}
+    elif deviceType == L9['LedPar54']:
+        r, g, b = rgbAttr(L9['color'])
+        return {L9['master']: 255, L9['red']: r, L9['green']: g, L9['blue']: b, L9['white']: 0, L9['strobe']: 0}
+    elif deviceType == L9['SimpleDimmer']:
+        return {L9['level']: _8bit(floatAttr(L9['brightness']))}
+    elif deviceType == L9['MegaFlash']:
+        return {
+            L9['brightness']: _8bit(floatAttr(L9['brightness'])),
+            L9['strobeSpeed']: _8bit(floatAttr(L9['strobeSpeed'])),
+        }
+    elif deviceType == L9['Mini15']:
+        out = {
+            L9['rotationSpeed']: 0,  # seems to have no effect
+            L9['dimmer']: 255,
+            L9['colorChange']: 0,
+            L9['colorSpeed']: 0,
+            L9['goboShake']: _8bit(floatAttr(L9['goboShake'])),
+        }
+
+        out[L9['goboChoose']] = {
+            L9['open']: 0,
+            L9['mini15Gobo1']: 10,
+            L9['mini15Gobo2']: 20,
+            L9['mini15Gobo3']: 30,
+        }[deviceAttrSettings.get(L9['mini15GoboChoice'], L9['open'])]
+
+        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
+        out[L9['xRotation']], out[L9['xFine']] = fine16Attr(L9['rx'], 1 / 540)
+        out[L9['yRotation']], out[L9['yFine']] = fine16Attr(L9['ry'], 1 / 240)
+        # didn't find docs on this, but from tests it looks like 64 fine steps takes you to the next coarse step
+
+        return out
+    elif deviceType == L9['ChauvetHex12']:
+        out = {}
+        out[L9['red']], out[L9['green']], out[L9['blue']] = r, g, b = rgbAttr(L9['color'])
+        out[L9['amber']] = 0
+        out[L9['white']] = min(r, g, b)
+        out[L9['uv']] = _8bit(floatAttr(L9['uv']))
+        return out
+    elif deviceType == L9['Source4LedSeries2']:
+        out = {}
+        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
+        out[L9['strobe']] = 0
+        out[L9['fixed255']] = 255
+        for num in range(7):
+            out[L9['fixed128_%s' % num]] = 128
+        return out
+    elif deviceType == L9['MacAura']:
+        out = {
+            L9['shutter']: 22,
+            L9['dimmer']: 255,
+            L9['zoom']: _8bit(floatAttr(L9['zoom'])),
+            L9['fixtureControl']: 0,
+            L9['colorWheel']: 0,
+            L9['colorTemperature']: 128,
+            L9['fx1Select']: 0,
+            L9['fx1Adjust']: 0,
+            L9['fx2Select']: 0,
+            L9['fx2Adjust']: 0,
+            L9['fxSync']: 0,
+            L9['auraShutter']: 22,
+            L9['auraDimmer']: 0,
+            L9['auraColorWheel']: 0,
+            L9['auraRed']: 0,
+            L9['auraGreen']: 0,
+            L9['auraBlue']: 0,
+        }
+        out[L9['pan']], out[L9['panFine']] = fine16Attr(L9['rx'])
+        out[L9['tilt']], out[L9['tiltFine']] = fine16Attr(L9['ry'])
+        out[L9['red']], out[L9['green']], out[L9['blue']] = rgbAttr(L9['color'])
+        out[L9['white']] = 0
+
+        return out
+    elif deviceType == L9['MacQuantum']:
+        out = {
+            L9['dimmerFadeLo']: 0,
+            L9['fixtureControl']: 0,
+            L9['fx1Select']: 0,
+            L9['fx1Adjust']: 0,
+            L9['fx2Select']: 0,
+            L9['fx2Adjust']: 0,
+            L9['fxSync']: 0,
+        }
+
+        # note these values are set to 'fade', so they update slowly. Haven't found where to turn that off.
+        out[L9['cyan']], out[L9['magenta']], out[L9['yellow']] = cmyAttr(L9['color'])
+
+        out[L9['focusHi']], out[L9['focusLo']] = fine16Attr(L9['focus'])
+        out[L9['panHi']], out[L9['panLo']] = fine16Attr(L9['rx'])
+        out[L9['tiltHi']], out[L9['tiltLo']] = fine16Attr(L9['ry'])
+        out[L9['zoomHi']], out[L9['zoomLo']] = fine16Attr(L9['zoom'])
+        out[L9['dimmerFadeHi']] = 0 if deviceAttrSettings.get(L9['color'], '#000000') == '#000000' else 255
+
+        out[L9['goboChoice']] = {
+            L9['open']: 0,
+            L9['spider']: 36,
+            L9['windmill']: 41,
+            L9['limbo']: 46,
+            L9['brush']: 51,
+            L9['whirlpool']: 56,
+            L9['stars']: 61,
+        }[deviceAttrSettings.get(L9['quantumGoboChoice'], L9['open'])]
+
+        # my goboSpeed deviceAttr goes 0=stopped to 1=fastest (using one direction only)
+        x = .5 + .5 * floatAttr(L9['goboSpeed'])
+        out[L9['goboSpeedHi']] = _8bit(x)
+        out[L9['goboSpeedLo']] = _8bit((x * 255) % 1.0)
+
+        strobe = floatAttr(L9['strobe'])
+        if strobe < .1:
+            out[L9['shutter']] = 30
+        else:
+            out[L9['shutter']] = 50 + int(150 * (strobe - .1) / .9)
+
+        out.update({
+            L9['colorWheel']: 0,
+            L9['goboStaticRotate']: 0,
+            L9['prismRotation']: _8bit(floatAttr(L9['prism'])),
+            L9['iris']: _8bit(floatAttr(L9['iris']) * (200 / 255)),
+        })
+        return out
+    else:
+        raise NotImplementedError('device %r' % deviceType)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/device_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,59 @@
+import unittest
+from light9.newtypes import DeviceAttr, DeviceClass, HexColor, OutputAttr
+from rdflib import Literal
+from light9.namespaces import L9
+
+from light9.collector.device import toOutputAttrs, resolve
+
+
+class TestUnknownDevice(unittest.TestCase):
+
+    def testFails(self):
+        self.assertRaises(NotImplementedError, toOutputAttrs, L9['bogus'], {})
+
+
+class TestColorStrip(unittest.TestCase):
+
+    def testConvertDeviceToOutputAttrs(self):
+        out = toOutputAttrs(DeviceClass(L9['ChauvetColorStrip']), {DeviceAttr(L9['color']): HexColor('#ff0000')})
+        self.assertEqual({L9['mode']: 215, L9['red']: 255, L9['green']: 0, L9['blue']: 0}, out)
+
+
+class TestDimmer(unittest.TestCase):
+
+    def testConvert(self):
+        self.assertEqual({L9['level']: 127}, toOutputAttrs(DeviceClass(L9['SimpleDimmer']), {DeviceAttr(L9['brightness']): .5}))
+
+
+class TestMini15(unittest.TestCase):
+
+    def testConvertColor(self):
+        out = toOutputAttrs(DeviceClass(L9['Mini15']), {DeviceAttr(L9['color']): HexColor('#010203')})
+        self.assertEqual(255, out[OutputAttr(L9['dimmer'])])
+        self.assertEqual(1, out[OutputAttr(L9['red'])])
+        self.assertEqual(2, out[OutputAttr(L9['green'])])
+        self.assertEqual(3, out[OutputAttr(L9['blue'])])
+
+    def testConvertRotation(self):
+        out = toOutputAttrs(DeviceClass(L9['Mini15']), {DeviceAttr(L9['rx']): 90, DeviceAttr(L9['ry']): 45})
+        self.assertEqual(42, out[OutputAttr(L9['xRotation'])])
+        self.assertEqual(127, out[OutputAttr(L9['xFine'])])
+        self.assertEqual(47, out[OutputAttr(L9['yRotation'])])
+        self.assertEqual(207, out[OutputAttr(L9['yFine'])])
+        self.assertEqual(0, out[OutputAttr(L9['rotationSpeed'])])
+
+
+DC = DeviceClass(L9['someDev'])
+
+
+class TestResolve(unittest.TestCase):
+
+    def testMaxes1Color(self):
+        # do not delete - this one catches a bug in the rgb_to_hex(...) lines
+        self.assertEqual(HexColor('#ff0300'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#ff0300')]))
+
+    def testMaxes2Colors(self):
+        self.assertEqual(HexColor('#ff0400'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#ff0300'), HexColor('#000400')]))
+
+    def testMaxes3Colors(self):
+        self.assertEqual(HexColor('#112233'), resolve(DC, DeviceAttr(L9['color']), [HexColor('#110000'), HexColor('#002200'), HexColor('#000033')]))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/dmx_controller_output.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,73 @@
+#######################################################
+# DMX Controller
+# See <TBD>
+# Copyright (C) Jonathan Brogdon <jlbrogdon@gmail.com>
+# This program is published under a GPLv2 license
+#
+# This code implements a DMX controller with UI provided
+# by LCDproc
+#
+#######################################################
+from pyftdi import ftdi
+
+#FTDI device info
+vendor = 0x0403
+product = 0x6001
+
+
+#####################
+# DMX USB controller
+#####################
+class OpenDmxUsb():
+
+    def __init__(self):
+        self.baud_rate = 250000
+        self.data_bits = 8
+        self.stop_bits = 2
+        self.parity = 'N'
+        self.flow_ctrl = ''
+        self.rts_state = False
+        self._init_dmx()
+
+    #Initialize the controller
+    def _init_dmx(self):
+        self.ftdi = ftdi.Ftdi()
+        self.ftdi.open(vendor, product, 0)
+        self.ftdi.set_baudrate(self.baud_rate)
+        self.ftdi.set_line_property(self.data_bits,
+                                    self.stop_bits,
+                                    self.parity,
+                                    break_=False)
+        self.ftdi.set_flowctrl(self.flow_ctrl)
+        self.ftdi.purge_rx_buffer()
+        self.ftdi.purge_tx_buffer()
+        self.ftdi.set_rts(self.rts_state)
+
+    #Send DMX data
+    def send_dmx(self, channelVals):
+        assert self.ftdi.write_data(channelVals) == 513
+        # Need to generate two bits for break
+        self.ftdi.set_line_property(self.data_bits,
+                                    self.stop_bits,
+                                    self.parity,
+                                    break_=True)
+        self.ftdi.set_line_property(self.data_bits,
+                                    self.stop_bits,
+                                    self.parity,
+                                    break_=True)
+        self.ftdi.set_line_property(self.data_bits,
+                                    self.stop_bits,
+                                    self.parity,
+                                    break_=False)
+
+
+if __name__ == "__main__":
+    dmxUsb = OpenDmxUsb()
+
+    channelVals = bytearray([0] * 513)
+    channelVals[0] = 0  # dummy channel 0
+    while (True):
+        for x in range(1, 468 + 1):
+            channelVals[x] = 255
+
+        dmxUsb.send_dmx(channelVals)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/output.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,310 @@
+import asyncio
+import logging
+import socket
+import struct
+import time
+from typing import cast
+from light9.newtypes import uriTail
+
+import usb.core
+from rdflib import URIRef
+from twisted.internet import reactor, task
+from twisted.internet.interfaces import IReactorCore
+
+from light9.metrics import metrics
+
+log = logging.getLogger('output')
+logAllDmx = logging.getLogger('output.allDmx')
+
+
+class Output:
+    """
+    send a binary buffer of values to some output device. Call update
+    as often as you want- the result will be sent as soon as possible,
+    and with repeats as needed to outlast hardware timeouts.
+
+    This base class doesn't ever call _write. Subclasses below have
+    strategies for that.
+    """
+    uri: URIRef
+
+    def __init__(self, uri: URIRef):
+        self.uri = uri
+
+        self._currentBuffer = b''
+
+        if log.isEnabledFor(logging.DEBUG):
+            self._lastLoggedMsg = ''
+            task.LoopingCall(self._periodicLog).start(1)
+
+    def reconnect(self):
+        pass
+
+    def shortId(self) -> str:
+        """short string to distinguish outputs"""
+        return uriTail(self.uri)
+
+    def update(self, buf: bytes) -> None:
+        """caller asks for the output to be this buffer"""
+        self._currentBuffer = buf
+
+    def _periodicLog(self):
+        msg = '%s: %s' % (self.shortId(), ' '.join(map(str, self._currentBuffer)))
+        if msg != self._lastLoggedMsg:
+            log.debug(msg)
+            self._lastLoggedMsg = msg
+
+    def _write(self, buf: bytes) -> None:
+        """
+        write buffer to output hardware (may be throttled if updates are
+        too fast, or repeated if they are too slow)
+        """
+        pass
+
+    def crash(self):
+        log.error('unrecoverable- exiting')
+        cast(IReactorCore, reactor).crash()
+
+
+class DummyOutput(Output):
+
+    def __init__(self, uri, **kw):
+        super().__init__(uri)
+
+    def update(self, buf: bytes):
+        log.info(f'dummy update {list(map(int,buf[:80]))}')
+
+
+class BackgroundLoopOutput(Output):
+    """Call _write forever at 20hz in background threads"""
+
+    rate: float
+
+    def __init__(self, uri, rate=22):
+        super().__init__(uri)
+        self.rate = rate
+        self._currentBuffer = b''
+
+        self._task = asyncio.create_task(self._loop())
+
+    async def _loop(self):
+        while True:
+            t1 = time.time()
+            self._loop_one()
+            remain = max(0, 1 / self.rate - (time.time() - t1))
+            await asyncio.sleep(remain)
+
+    def _loop_one(self):
+        start = time.time()
+        sendingBuffer = self._currentBuffer
+        #tenacity retry
+        self._write(sendingBuffer)
+
+
+class FtdiDmx(BackgroundLoopOutput):
+
+    def __init__(self, uri, lastDmxChannel, rate=22):
+        super().__init__(uri)
+        self.lastDmxChannel = lastDmxChannel
+        from .dmx_controller_output import OpenDmxUsb
+        self.dmx = OpenDmxUsb()
+
+    def _write(self, buf):
+        with metrics('write', output=self.shortId()).time():
+            if not buf:
+                logAllDmx.debug('%s: empty buf- no output', self.shortId())
+                return
+
+            # ok to truncate the last channels if they just went
+            # to 0? No it is not. DMX receivers don't add implicit
+            # zeros there.
+            buf = bytes([0]) + buf[:self.lastDmxChannel]
+
+            if logAllDmx.isEnabledFor(logging.DEBUG):
+                # for testing fps, smooth fades, etc
+                logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
+
+            self.dmx.send_dmx(buf)
+
+
+class ArtnetDmx(BackgroundLoopOutput):
+    # adapted from https://github.com/spacemanspiff2007/PyArtNet/blob/master/pyartnet/artnet_node.py (gpl3)
+    def __init__(self, uri, host, port, rate):
+        """sends UDP messages to the given host/port"""
+        super().__init__(uri, rate)
+        packet = bytearray()
+        packet.extend(map(ord, "Art-Net"))
+        packet.append(0x00)  # Null terminate Art-Net
+        packet.extend([0x00, 0x50])  # Opcode ArtDMX 0x5000 (Little endian)
+        packet.extend([0x00, 0x0e])  # Protocol version 14
+        self.base_packet = packet
+        self.sequence_counter = 255
+        self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+    def _write(self, buf):
+        with metrics('write', output=self.shortId()).time():
+            if not buf:
+                logAllDmx.debug('%s: empty buf- no output', self.shortId())
+                return
+
+            if logAllDmx.isEnabledFor(logging.DEBUG):
+                # for testing fps, smooth fades, etc
+                logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
+
+            if self.sequence_counter:
+                self.sequence_counter += 1
+                if self.sequence_counter > 255:
+                    self.sequence_counter = 1
+            packet = self.base_packet[:]
+            packet.append(self.sequence_counter)  # Sequence,
+            packet.append(0x00)  # Physical
+            universe_nr = 0
+            packet.append(universe_nr & 0xFF)  # Universe LowByte
+            packet.append(universe_nr >> 8 & 0xFF)  # Universe HighByte
+
+            packet.extend(struct.pack('>h', len(buf)))  # Pack the number of channels Big endian
+            packet.extend(buf)
+
+            self._socket.sendto(packet, ('127.0.0.1', 6454))
+
+
+class Udmx(BackgroundLoopOutput):
+    """alternate lib:
+
+    from PyDMXControl.controllers import uDMXController
+    u = uDMXController(autostart=False)
+    u._connect()
+    u._transmit([255, 0, 0, ...
+    """
+
+    def __init__(self, uri: URIRef, bus: int, address: int, lastDmxChannel: int, rate: float):
+        self.bus = bus
+        self.address = address
+        self.lastDmxChannel = lastDmxChannel
+        self.dev = None
+        super().__init__(uri, rate=rate)
+
+        self.reconnect()
+
+    def shortId(self) -> str:
+        return super().shortId() + f'_bus={self.bus}'
+
+    def reconnect(self):
+        metrics('connected', output=self.shortId()).set(0)
+        from pyudmx import pyudmx
+        self.dev = pyudmx.uDMXDevice()
+        if not self.dev.open(bus=self.bus, address=self.address):
+            raise ValueError("dmx open failed")
+        log.info(f'opened {self.dev}')
+        metrics('connected', output=self.shortId()).set(1)
+        metrics('reconnections', output=self.shortId()).inc()
+
+    #def update(self, buf:bytes):
+    #    self._write(buf)
+
+    #def _loop(self):
+    #    pass
+    def _write(self, buf):
+        if not self.dev:
+            log.info('%s: trying to connect', self.shortId())
+            raise ValueError()
+
+        with metrics('write', output=self.shortId()).time():
+            try:
+                if not buf:
+                    logAllDmx.debug('%s: empty buf- no output', self.shortId())
+                    return
+
+                # ok to truncate the last channels if they just went
+                # to 0? No it is not. DMX receivers don't add implicit
+                # zeros there.
+                buf = buf[:self.lastDmxChannel]
+
+                if logAllDmx.isEnabledFor(logging.DEBUG):
+                    # for testing fps, smooth fades, etc
+                    logAllDmx.debug('%s: %s...' % (self.shortId(), ' '.join(map(str, buf[:32]))))
+                t1 = time.time()
+                sent = self.dev.send_multi_value(1, bytearray(buf))
+                if sent != len(buf):
+                    raise ValueError("incomplete send")
+            except ValueError:
+                self.reconnect()
+                raise
+            except usb.core.USBError as e:
+                # not in main thread
+                if e.errno == 75:
+                    metrics('write_overflow', output=self.shortId()).inc()
+                    return
+
+                if e.errno == 5:  # i/o err
+                    metrics('write_io_error', output=self.shortId()).inc()
+                    return
+
+                if e.errno == 32:  # pipe err
+                    metrics('write_pipe_error', output=self.shortId()).inc()
+                    return
+
+                msg = 'usb: sending %s bytes to %r; error %r' % (len(buf), self.uri, e)
+                log.warn(msg)
+
+                if e.errno == 13:  # permissions
+                    return self.crash()
+
+                if e.errno == 19:  # no such dev; usb hw restarted
+                    self.reconnect()
+                    return
+
+                raise
+            dt = time.time() - t1
+            if dt > 1/self.rate*1.5:
+                log.warning(f'usb stall- took {(dt*1000):.2f}ms')
+
+
+'''
+# the code used in 2018 and before
+class UdmxOld(BackgroundLoopOutput):
+
+    def __init__(self, uri, bus):
+        from light9.io.udmx import Udmx
+        self._dev = Udmx(bus)
+
+        super().__init__(uri)
+
+    def _write(self, buf: bytes):
+        try:
+            if not buf:
+                return
+            self.dev.SendDMX(buf)
+
+        except usb.core.USBError as e:
+            # not in main thread
+            if e.errno != 75:
+                msg = 'usb: sending %s bytes to %r; error %r' % (
+                    len(buf), self.uri, e)
+                log.warn(msg)
+            raise
+
+
+# out of date
+class EnttecDmx(BackgroundLoopOutput):
+    stats = scales.collection('/output/enttecDmx', scales.PmfStat('write', recalcPeriod=1),
+                              scales.PmfStat('update', recalcPeriod=1))
+
+    def __init__(self, uri, devicePath='/dev/dmx0', numChannels=80):
+        sys.path.append("dmx_usb_module")
+        from dmx import Dmx
+        self.dev = Dmx(devicePath)
+        super().__init__(uri)
+
+
+    @stats.update.time()
+    def update(self, values):
+
+        # I was outputting on 76 and it was turning on the light at
+        # dmx75. So I added the 0 byte. No notes explaining the footer byte.
+        self.currentBuffer = '\x00' + ''.join(map(chr, values)) + "\x00"
+
+    @stats.write.time()
+    def _write(self, buf):
+        self.dev.write(buf)
+'''
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/output_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,7 @@
+import unittest
+from light9.namespaces import L9
+# from light9.collector.output import DmxOutput
+
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/service.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,161 @@
+#!bin/python
+"""
+Collector receives device attrs from multiple senders, combines
+them, and sends output attrs to hardware. The combining part has
+custom code for some attributes.
+
+Input can be over http or zmq.
+"""
+import asyncio
+import functools
+import logging
+import subprocess
+import traceback
+from typing import List
+
+from light9 import networking
+from light9.collector.collector import Collector
+from light9.collector.output import ArtnetDmx, DummyOutput, Output, Udmx  # noqa
+from light9.collector.weblisteners import UiListener, WebListeners
+from light9.namespaces import L9
+from light9.run_local import log
+from light9.zmqtransport import parseJsonMessage
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from starlette.applications import Starlette
+from starlette.endpoints import WebSocketEndpoint
+from starlette.requests import ClientDisconnect
+from starlette.responses import Response
+from starlette.routing import Route, WebSocketRoute
+from starlette.types import Receive, Scope, Send
+from starlette.websockets import WebSocket
+from starlette_exporter import PrometheusMiddleware, handle_metrics
+
+import zmq
+import zmq.asyncio
+
+
+# this is the rate sent to usb
+RATE = 20
+
+
+class Updates(WebSocketEndpoint, UiListener):
+
+    def __init__(self, listeners, scope: Scope, receive: Receive, send: Send) -> None:
+        super().__init__(scope, receive, send)
+        self.listeners = listeners
+
+    async def on_connect(self, websocket: WebSocket):
+        await websocket.accept()
+        log.info('socket connect %s', self.scope['client'])
+        self.websocket = websocket
+        self.listeners.addClient(self)
+
+    async def sendMessage(self, msgText):
+        await self.websocket.send_text(msgText)
+
+    # async def on_receive(self, websocket, data):
+    #     json.loads(data)
+
+    async def on_disconnect(self, websocket: WebSocket, close_code: int):
+        self.listeners.delClient(self)
+
+    pass
+
+
+async def PutAttrs(collector: Collector, request):
+    try:
+        body = await request.body()
+    except ClientDisconnect:
+        log.warning("PUT /attrs request disconnected- ignoring")
+        return Response('', status_code=400)
+    client, clientSession, settings, sendTime = parseJsonMessage(collector.graph, body)
+    collector.setAttrs(client, clientSession, settings, sendTime)
+    return Response('', status_code=202)
+
+
+async def zmqListener(collector):
+    try:
+        ctx = zmq.asyncio.Context()
+        sock = ctx.socket(zmq.SUB)
+        sock.bind('tcp://127.0.0.1:9203')
+        sock.subscribe(b'setAttr')
+        while True:
+            [topic, msg] = await sock.recv_multipart()
+            if topic != b'setAttr':
+                raise ValueError(topic)
+            # log.info(f'zmq recv {len(msg)}')
+            client, clientSession, settings, sendTime = parseJsonMessage(collector.graph, msg)
+            collector.setAttrs(client, clientSession, settings, sendTime)
+    except:
+        traceback.print_exc()
+        raise
+
+def findDevice():
+    for line in subprocess.check_output("lsusb").decode('utf8').splitlines():
+        if '16c0:05dc' in line:
+            words = line.split(':')[0].split()
+            dev = f'/dev/bus/usb/{words[1]}/{words[3]}'
+            log.info(f'device will be {dev}')
+            return dev ,int(words[3])
+    raise ValueError("no matching uDMX found")
+
+def main():
+    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
+    logging.getLogger('syncedgraph').setLevel(logging.INFO)
+    logging.getLogger('output.allDmx').setLevel(logging.WARNING)
+    logging.getLogger().setLevel(logging.DEBUG)
+    logging.getLogger('collector').setLevel(logging.DEBUG)
+
+    graph = SyncedGraph(networking.rdfdb.url, "collector")
+
+    #devPath, usbAddress = findDevice()
+            # if user doesn't have r/w, fail now
+    try:
+        # todo: drive outputs with config files
+        outputs: List[Output] = [
+            # ArtnetDmx(L9['output/dmxA/'],
+            #           host='127.0.0.1',
+            #           port=6445,
+            #           rate=rate),
+            #sudo chmod a+rw /dev/bus/usb/003/021
+#            Udmx(L9['output/dmxA/'], bus=1, address=usbAddress, lastDmxChannel=200, rate=RATE),
+        ]
+    except Exception:
+        log.error("setting up outputs:")
+        traceback.print_exc()
+        raise
+    listeners = WebListeners()
+    c = Collector(graph, outputs, listeners)
+    zl = asyncio.create_task(zmqListener(c))
+    app = Starlette(
+        debug=True,
+        routes=[
+            # Route('/recentRequests', lambda req: get_recentRequests(req, db)),
+            WebSocketRoute('/updates', endpoint=functools.partial(Updates, listeners)),
+            Route('/attrs', functools.partial(PutAttrs, c), methods=['PUT']),
+        ],
+    )
+    app.add_middleware(PrometheusMiddleware)
+    app.add_route("/metrics", handle_metrics)
+
+    # loadtest = os.environ.get('LOADTEST', False)  # call myself with some synthetic load then exit
+    # if loadtest:
+    #     # in a subprocess since we don't want this client to be
+    #     # cooperating with the main event loop and only sending
+    #     # requests when there's free time
+    #     def afterWarmup():
+    #         log.info('running collector_loadtest')
+    #         d = utils.getProcessValue('bin/python', ['bin/collector_loadtest.py'])
+
+    #         def done(*a):
+    #             log.info('loadtest done')
+    #             reactor.stop()
+
+    #         d.addCallback(done)
+
+    #     reactor.callLater(2, afterWarmup)
+
+    return app
+
+
+app = main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/collector/weblisteners.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,90 @@
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Awaitable, Dict, List, Protocol, Tuple
+
+from light9.collector.output import Output as OutputInstance
+from light9.newtypes import (DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr, OutputUri, OutputValue)
+
+log = logging.getLogger('weblisteners')
+
+
+def shortenOutput(out: OutputUri) -> str:
+    return str(out).rstrip('/').rsplit('/', 1)[-1]
+
+
+class UiListener(Protocol):
+
+    async def sendMessage(self, msg):
+        ...
+
+
+class WebListeners:
+
+    def __init__(self) -> None:
+        self.clients: List[Tuple[UiListener, Dict[DeviceUri, Dict[OutputAttr, OutputValue]]]] = []
+        self.pendingMessageForDev: Dict[DeviceUri, Tuple[Dict[OutputAttr, OutputValue], Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri,
+                                                                                                                                 DmxMessageIndex]]]] = {}
+        self.lastFlush = 0
+        asyncio.create_task(self.flusher())
+
+    def addClient(self, client: UiListener):
+        self.clients.append((client, {}))  # seen = {dev: attrs}
+        log.info('added client %s %s', len(self.clients), client)
+        # todo: it would be nice to immediately fill in the client on the
+        # latest settings, but I lost them so I can't.
+
+    def delClient(self, client: UiListener):
+        self.clients = [(c, t) for c, t in self.clients if c != client]
+        log.info('delClient %s, %s left', client, len(self.clients))
+
+    def outputAttrsSet(self, dev: DeviceUri, attrs: Dict[OutputAttr, Any], outputMap: Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]):
+        """called often- don't be slow"""
+        self.pendingMessageForDev[dev] = (attrs, outputMap)
+        # maybe put on a stack for flusher or something
+
+    async def flusher(self):
+        await asyncio.sleep(3)  # help startup faster?
+        while True:
+            await self._flush()
+            await asyncio.sleep(.05)
+
+    async def _flush(self):
+        now = time.time()
+        if now < self.lastFlush + .05 or not self.clients:
+            return
+        self.lastFlush = now
+
+        while self.pendingMessageForDev:
+            dev, (attrs, outputMap) = self.pendingMessageForDev.popitem()
+
+            msg = None  # lazy, since makeMsg is slow
+
+            sendAwaits: List[Awaitable[None]] = []
+
+            # this omits repeats, but can still send many
+            # messages/sec. Not sure if piling up messages for the browser
+            # could lead to slowdowns in the real dmx output.
+            for client, seen in self.clients:
+                if seen.get(dev) == attrs:
+                    continue
+                if msg is None:
+                    msg = self.makeMsg(dev, attrs, outputMap)
+
+                seen[dev] = attrs
+                sendAwaits.append(client.sendMessage(msg))
+            await asyncio.gather(*sendAwaits)
+
+    def makeMsg(self, dev: DeviceUri, attrs: Dict[OutputAttr, Any], outputMap: Dict[Tuple[DeviceUri, OutputAttr], Tuple[OutputUri, DmxMessageIndex]]):
+        attrRows = []
+        for attr, val in attrs.items():
+            outputUri, bufIndex = outputMap[(dev, attr)]
+            dmxIndex = DmxIndex(bufIndex + 1)
+            attrRows.append({'attr': attr.rsplit('/')[-1], 'val': val, 'chan': (shortenOutput(outputUri), dmxIndex)})
+        attrRows.sort(key=lambda r: r['chan'])
+        for row in attrRows:
+            row['chan'] = '%s %s' % (row['chan'][0], row['chan'][1])
+
+        msg = json.dumps({'outputAttrsSet': {'dev': dev, 'attrs': attrRows}}, sort_keys=True)
+        return msg
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/cursor1.xbm	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,11 @@
+#define cursor1_width 20
+#define cursor1_height 20
+#define cursor1_x_hot 5
+#define cursor1_y_hot 5
+static char cursor1_bits[] = {
+  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x07, 0x00, 
+  0x00, 0x1d, 0x00, 0x00, 0x27, 0x00, 0x00, 0x23, 0x00, 0x80, 0x21, 0x00, 
+  0x80, 0x21, 0x00, 0x80, 0x23, 0x00, 0x80, 0x3e, 0x00, 0x80, 0x1f, 0x00, 
+  0x80, 0x71, 0x00, 0x80, 0x47, 0x00, 0x80, 0x7c, 0x00, 0xc0, 0x00, 0x00, 
+  0x40, 0x00, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0x00, 0x10, 0x00, 0x00, 
+  };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/client.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,25 @@
+"""
+client code for talking to curvecalc
+"""
+import cyclone.httpclient
+from light9 import networking
+import urllib.request, urllib.parse, urllib.error
+
+
+def sendLiveInputPoint(curve, value):
+    f = cyclone.httpclient.fetch(networking.curveCalc.path('liveInputPoint'),
+                                 method='POST',
+                                 timeout=1,
+                                 postdata=urllib.parse.urlencode({
+                                     'curve':
+                                     curve,
+                                     'value':
+                                     str(value),
+                                 }))
+
+    @f.addCallback
+    def cb(result):
+        if result.code // 100 != 2:
+            raise ValueError("curveCalc said %s: %s", result.code, result.body)
+
+    return f
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/cursors.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,22 @@
+import logging
+log = logging.getLogger("cursors")
+
+# accept ascii images, read file images, add hotspots, read xbm as
+# cursor with @filename form
+
+_pushed = {}  # widget : [old, .., newest]
+
+
+def push(widget, new_cursor):
+    global _pushed
+    _pushed.setdefault(widget, []).append(widget.cget("cursor"))
+
+
+def pop(widget):
+    global _pushed
+    try:
+        c = _pushed[widget].pop(-1)
+    except IndexError:
+        log.debug("cursor pop from empty stack")
+        return
+    widget.config(cursor=c)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/curve.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,384 @@
+import logging, ast, os
+from bisect import bisect_left, bisect
+import louie as dispatcher
+from twisted.internet import reactor
+from rdflib import Literal
+from light9 import showconfig
+from light9.namespaces import L9, RDF, RDFS
+from rdfdb.patch import Patch
+
+log = logging.getLogger()
+# todo: move to config, consolidate with ascoltami, musicPad, etc
+introPad = 4
+postPad = 4
+
+
+class Curve(object):
+    """curve does not know its name. see Curveset"""
+
+    def __init__(self, uri, pointsStorage='graph'):
+        self.uri = uri
+        self.pointsStorage = pointsStorage
+        self.points = []  # x-sorted list of (x,y)
+        self._muted = False
+
+    def __repr__(self):
+        return "<%s %s (%s points)>" % (self.__class__.__name__, self.uri,
+                                        len(self.points))
+
+    def muted():
+        doc = "Whether to currently send levels (boolean, obviously)"
+
+        def fget(self):
+            return self._muted
+
+        def fset(self, val):
+            self._muted = val
+            dispatcher.send('mute changed', sender=self)
+
+        return locals()
+
+    muted = property(**muted())
+
+    def toggleMute(self):
+        self.muted = not self.muted
+
+    def load(self, filename):
+        self.points[:] = []
+        for line in open(filename):
+            x, y = line.split()
+            self.points.append((float(x), ast.literal_eval(y)))
+        self.points.sort()
+        dispatcher.send("points changed", sender=self)
+
+    def set_from_string(self, pts):
+        self.points[:] = []
+        vals = pts.split()
+        pairs = list(zip(vals[0::2], vals[1::2]))
+        for x, y in pairs:
+            self.points.append((float(x), ast.literal_eval(y)))
+        self.points.sort()
+        dispatcher.send("points changed", sender=self)
+
+    def points_as_string(self):
+
+        def outVal(x):
+            if isinstance(x, str):  # markers
+                return x
+            return "%.4g" % x
+
+        return ' '.join(
+            "%s %s" % (outVal(p[0]), outVal(p[1])) for p in self.points)
+
+    def save(self, filename):
+        # this is just around for markers, now
+        if filename.endswith('-music') or filename.endswith('_music'):
+            print("not saving music track")
+            return
+        f = open(filename, 'w')
+        for p in self.points:
+            f.write("%s %r\n" % p)
+        f.close()
+
+    def eval(self, t, allow_muting=True):
+        if self.muted and allow_muting:
+            return 0
+        if not self.points:
+            raise ValueError("curve has no points")
+        i = bisect_left(self.points, (t, None)) - 1
+
+        if i == -1:
+            return self.points[0][1]
+        if self.points[i][0] > t:
+            return self.points[i][1]
+        if i >= len(self.points) - 1:
+            return self.points[i][1]
+
+        p1, p2 = self.points[i], self.points[i + 1]
+        frac = (t - p1[0]) / (p2[0] - p1[0])
+        y = p1[1] + (p2[1] - p1[1]) * frac
+        return y
+
+    __call__ = eval
+
+    def insert_pt(self, new_pt):
+        """returns index of new point"""
+        i = bisect(self.points, (new_pt[0], None))
+        self.points.insert(i, new_pt)
+        # missing a check that this isn't the same X as the neighbor point
+        dispatcher.send("points changed", sender=self)
+        return i
+
+    def live_input_point(self, new_pt, clear_ahead_secs=.01):
+        x, y = new_pt
+        exist = self.points_between(x, x + clear_ahead_secs)
+        for pt in exist:
+            self.remove_point(pt)
+        self.insert_pt(new_pt)
+        dispatcher.send("points changed", sender=self)
+        # now simplify to the left
+
+    def set_points(self, updates):
+        for i, pt in updates:
+            self.points[i] = pt
+
+        # this should be on, but live_input_point made it fail a
+        # lot. need a new solution.
+        #self.checkOverlap()
+        dispatcher.send("points changed", sender=self)
+
+    def checkOverlap(self):
+        x = None
+        for p in self.points:
+            if p[0] <= x:
+                raise ValueError("overlapping points")
+            x = p[0]
+
+    def pop_point(self, i):
+        p = self.points.pop(i)
+        dispatcher.send("points changed", sender=self)
+        return p
+
+    def remove_point(self, pt):
+        self.points.remove(pt)
+        dispatcher.send("points changed", sender=self)
+
+    def indices_between(self, x1, x2, beyond=0):
+        leftidx = max(0, bisect(self.points, (x1, None)) - beyond)
+        rightidx = min(len(self.points),
+                       bisect(self.points, (x2, None)) + beyond)
+        return list(range(leftidx, rightidx))
+
+    def points_between(self, x1, x2):
+        """returns (x,y) points"""
+        return [self.points[i] for i in self.indices_between(x1, x2)]
+
+    def point_before(self, x):
+        """(x,y) of the point left of x, or None"""
+        leftidx = self.index_before(x)
+        if leftidx is None:
+            return None
+        return self.points[leftidx]
+
+    def index_before(self, x):
+        leftidx = bisect(self.points, (x, None)) - 1
+        if leftidx < 0:
+            return None
+        return leftidx
+
+
+class CurveResource(object):
+    """
+    holds a Curve, deals with graphs
+    """
+
+    def __init__(self, graph, uri):
+        # probably newCurve and loadCurve should be the constructors instead.
+        self.graph, self.uri = graph, uri
+
+    def curvePointsContext(self):
+        return self.uri
+
+    def newCurve(self, ctx, label):
+        """
+        Save type/label for a new :Curve resource.
+        Pass the ctx where the main curve data (not the points) will go.
+        """
+        if hasattr(self, 'curve'):
+            raise ValueError('CurveResource already has a curve %r' %
+                             self.curve)
+        self.graph.patch(
+            Patch(addQuads=[
+                (self.uri, RDF.type, L9['Curve'], ctx),
+                (self.uri, RDFS.label, label, ctx),
+            ]))
+        self.curve = Curve(self.uri)
+        self.curve.points.extend([(0, 0)])
+        self.saveCurve()
+        self.watchCurvePointChanges()
+
+    def loadCurve(self):
+        if hasattr(self, 'curve'):
+            raise ValueError('CurveResource already has a curve %r' %
+                             self.curve)
+        pointsFile = self.graph.value(self.uri, L9['pointsFile'])
+        self.curve = Curve(self.uri,
+                           pointsStorage='file' if pointsFile else 'graph')
+        if hasattr(self.graph, 'addHandler'):
+            self.graph.addHandler(self.pointsFromGraph)
+        else:
+            # given a currentState graph
+            self.pointsFromGraph()
+
+    def pointsFromGraph(self):
+        pts = self.graph.value(self.uri, L9['points'])
+        if pts is not None:
+            self.curve.set_from_string(pts)
+        else:
+            diskPts = self.graph.value(self.uri, L9['pointsFile'])
+            if diskPts is not None:
+                self.curve.load(os.path.join(showconfig.curvesDir(), diskPts))
+            else:
+                log.warn("curve %s has no points", self.uri)
+        self.watchCurvePointChanges()
+
+    def saveCurve(self):
+        self.pendingSave = None
+        for p in self.getSavePatches():
+            self.graph.patch(p)
+
+    def getSavePatches(self):
+        if self.curve.pointsStorage == 'file':
+            log.warn("not saving file curves anymore- skipping %s" % self.uri)
+            #cur.save("%s-%s" % (basename,name))
+            return []
+        elif self.curve.pointsStorage == 'graph':
+            return [
+                self.graph.getObjectPatch(self.curvePointsContext(),
+                                          subject=self.uri,
+                                          predicate=L9['points'],
+                                          newObject=Literal(
+                                              self.curve.points_as_string()))
+            ]
+        else:
+            raise NotImplementedError(self.curve.pointsStorage)
+
+    def watchCurvePointChanges(self):
+        """start watching and saving changes to the graph"""
+        dispatcher.connect(self.onChange, 'points changed', sender=self.curve)
+
+    def onChange(self):
+
+        # Don't write a patch for the edited curve points until they've been
+        # stable for this long. This can be very short, since it's just to
+        # stop a 100-point edit from sending many updates. If it's too long,
+        # you won't see output lights change while you drag a point.  Todo:
+        # this is just the wrong timing algorithm- it should be a max rate,
+        # not a max-hold-still-time.
+        HOLD_POINTS_GRAPH_COMMIT_SECS = .1
+
+        if getattr(self, 'pendingSave', None):
+            self.pendingSave.cancel()
+        self.pendingSave = reactor.callLater(HOLD_POINTS_GRAPH_COMMIT_SECS,
+                                             self.saveCurve)
+
+
+class Markers(Curve):
+    """Marker is like a point but the y value is a string"""
+
+    def eval(self):
+        raise NotImplementedError()
+
+
+def slope(p1, p2):
+    if p2[0] == p1[0]:
+        return 0
+    return (p2[1] - p1[1]) / (p2[0] - p1[0])
+
+
+class Curveset(object):
+
+    def __init__(self, graph, session):
+        self.graph, self.session = graph, session
+
+        self.currentSong = None
+        self.curveResources = {}  # uri : CurveResource
+
+        self.markers = Markers(uri=None, pointsStorage='file')
+
+        graph.addHandler(self.loadCurvesForSong)
+
+    def curveFromUri(self, uri):
+        return self.curveResources[uri].curve
+
+    def loadCurvesForSong(self):
+        """
+        current curves will track song's curves.
+        
+        This fires 'add_curve' dispatcher events to announce the new curves.
+        """
+        log.info('loadCurvesForSong')
+        dispatcher.send("clear_curves")
+        self.curveResources.clear()
+        self.markers = Markers(uri=None, pointsStorage='file')
+
+        self.currentSong = self.graph.value(self.session, L9['currentSong'])
+        if self.currentSong is None:
+            return
+
+        for uri in sorted(self.graph.objects(self.currentSong, L9['curve'])):
+            try:
+                cr = self.curveResources[uri] = CurveResource(self.graph, uri)
+                cr.loadCurve()
+
+                curvename = self.graph.label(uri)
+                if not curvename:
+                    raise ValueError("curve %r has no label" % uri)
+                dispatcher.send("add_curve",
+                                sender=self,
+                                uri=uri,
+                                label=curvename,
+                                curve=cr.curve)
+            except Exception as e:
+                log.error("loading %s failed: %s", uri, e)
+
+        basename = os.path.join(
+            showconfig.curvesDir(),
+            showconfig.songFilenameFromURI(self.currentSong))
+        try:
+            self.markers.load("%s.markers" % basename)
+        except IOError:
+            print("no marker file found")
+
+    def save(self):
+        """writes a file for each curve with a name
+        like basename-curvename, or saves them to the rdf graph"""
+        basename = os.path.join(
+            showconfig.curvesDir(),
+            showconfig.songFilenameFromURI(self.currentSong))
+
+        patches = []
+        for cr in list(self.curveResources.values()):
+            patches.extend(cr.getSavePatches())
+
+        self.markers.save("%s.markers" % basename)
+        # this will cause reloads that will rebuild our curve list
+        for p in patches:
+            self.graph.patch(p)
+
+    def sorter(self, name):
+        return self.curves[name].uri
+
+    def curveUrisInOrder(self):
+        return sorted(self.curveResources.keys())
+
+    def currentCurves(self):
+        # deprecated
+        for uri, cr in sorted(self.curveResources.items()):
+            with self.graph.currentState(tripleFilter=(uri, RDFS['label'],
+                                                       None)) as g:
+                yield uri, g.label(uri), cr.curve
+
+    def globalsdict(self):
+        raise NotImplementedError('subterm used to get a dict of name:curve')
+
+    def get_time_range(self):
+        return 0, dispatcher.send("get max time")[0][1]
+
+    def new_curve(self, name):
+        if isinstance(name, Literal):
+            name = str(name)
+
+        uri = self.graph.sequentialUri(self.currentSong + '/curve-')
+
+        cr = self.curveResources[uri] = CurveResource(self.graph, uri)
+        cr.newCurve(ctx=self.currentSong, label=Literal(name))
+        s, e = self.get_time_range()
+        cr.curve.points.extend([(s, 0), (e, 0)])
+
+        ctx = self.currentSong
+        self.graph.patch(
+            Patch(addQuads=[
+                (self.currentSong, L9['curve'], uri, ctx),
+            ]))
+        cr.saveCurve()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/curvecalc.glade	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,1042 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+  <requires lib="gtk+" version="3.10"/>
+  <object class="GtkAccelGroup" id="accelgroup1"/>
+  <object class="GtkAdjustment" id="adjustment1">
+    <property name="upper">100</property>
+    <property name="step_increment">1</property>
+    <property name="page_increment">10</property>
+  </object>
+  <object class="GtkTextBuffer" id="help">
+    <property name="text">Mousewheel zoom; C-p play/pause music at mouse
+Keys in a selected curve: 1..5 add point at time cursor
+Keys in any curve: q,w,e,r,t,y set marker at time cursor
+Curve point bindings: B1 drag point; C-B1 curve add point; S-B1 sketch points; B1 drag select points
+
+Old subterm system may still work:
+Drag sub into curve area for new curve+subterm
+Available in functions: nsin/ncos period=amp=1; within(a,b) bef(x) aft(x) compare to time; smoove(x) cubic smoothstep; chan(name); curvename(t) eval curve</property>
+  </object>
+  <object class="GtkWindow" id="MainWindow">
+    <property name="can_focus">False</property>
+    <child>
+      <object class="GtkBox" id="vbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkMenuBar" id="menubar1">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkMenuItem" id="menuitem1">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">_Curvecalc</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu1">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem2">
+                        <property name="label">gtk-save</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                        <signal name="activate" handler="onSave" swapped="no"/>
+                        <accelerator key="s" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem5">
+                        <property name="label">gtk-quit</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                        <signal name="activate" handler="onQuit" swapped="no"/>
+                        <accelerator key="q" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem7">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">_Edit</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu5">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem1">
+                        <property name="label">gtk-cut</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem3">
+                        <property name="label">gtk-copy</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem4">
+                        <property name="label">gtk-paste</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkImageMenuItem" id="imagemenuitem6">
+                        <property name="label">gtk-delete</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="use_underline">True</property>
+                        <property name="use_stock">True</property>
+                        <signal name="activate" handler="onDelete" swapped="no"/>
+                        <accelerator key="Delete" signal="activate"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem13">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">_Create</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu6">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem14">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Curve...</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onNewCurve" swapped="no"/>
+                        <accelerator key="n" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem15">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Subterm...</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onNewSubterm" swapped="no"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem2">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">_View</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu2">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem8">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">See current time</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onSeeCurrentTime" swapped="no"/>
+                        <accelerator key="Escape" signal="activate"/>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem9">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">See from current time -&gt; end</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onSeeTimeUntilEnd" swapped="no"/>
+                        <accelerator key="Escape" signal="activate" modifiers="GDK_SHIFT_MASK"/>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem10">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Zoom all</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onZoomAll" swapped="no"/>
+                        <accelerator key="Escape" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem11">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Zoom in (wheel up)</property>
+                        <property name="use_underline">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem12">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Zoom out (wheel down)</property>
+                        <property name="use_underline">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem17">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Redraw curves</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onRedrawCurves" swapped="no"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem3">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">_Playback</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu3">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem5">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">_Play/pause</property>
+                        <property name="use_underline">True</property>
+                        <signal name="activate" handler="onPlayPause" swapped="no"/>
+                        <accelerator key="p" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem4">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">Poin_ts</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu4">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkMenuItem" id="menuitem6">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Delete</property>
+                        <property name="use_underline">True</property>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="GtkMenuItem" id="menuitem16">
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">Debug</property>
+                <property name="use_underline">True</property>
+                <child type="submenu">
+                  <object class="GtkMenu" id="menu7">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkCheckMenuItem" id="checkmenuitem1">
+                        <property name="use_action_appearance">False</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">Python console</property>
+                        <property name="use_underline">True</property>
+                        <signal name="toggled" handler="onPythonConsole" swapped="no"/>
+                        <accelerator key="p" signal="activate" modifiers="GDK_SHIFT_MASK | GDK_CONTROL_MASK"/>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="songRow">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkBox" id="currentSongEditChoice">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <child>
+                  <placeholder/>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkLabel" id="label2">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="xalign">1</property>
+                <property name="label" translatable="yes">Player is on song </property>
+                <property name="justify">right</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkLinkButton" id="playerSong">
+                <property name="label" translatable="yes">(song)</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">True</property>
+                <property name="has_tooltip">True</property>
+                <property name="relief">none</property>
+                <property name="xalign">0</property>
+                <property name="uri">http://glade.gnome.org</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="followPlayerSongChoice">
+                <property name="label" translatable="yes">follow player song choice</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="xalign">0.5</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="padding">15</property>
+                <property name="position">3</property>
+              </packing>
+            </child>
+            <child>
+              <placeholder/>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">False</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkPaned" id="paned1">
+            <property name="height_request">600</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="orientation">vertical</property>
+            <property name="position">600</property>
+            <child>
+              <object class="GtkExpander" id="expander2">
+                <property name="height_request">400</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="expanded">True</property>
+                <child>
+                  <object class="GtkBox" id="vbox4">
+                    <property name="height_request">100</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="orientation">vertical</property>
+                    <child>
+                      <object class="GtkBox" id="curveTools">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="spacing">7</property>
+                        <child>
+                          <object class="GtkButton" id="button22">
+                            <property name="label">gtk-add</property>
+                            <property name="use_action_appearance">False</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="receives_default">True</property>
+                            <property name="use_stock">True</property>
+                            <signal name="clicked" handler="onNewCurve" swapped="no"/>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">False</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">False</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkBox" id="zoomControlBox">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="orientation">vertical</property>
+                        <child>
+                          <object class="GtkLabel" id="zoomControl">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">[zoom control]</property>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">True</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkScrolledWindow" id="scrolledwindowCurves">
+                        <property name="visible">True</property>
+                        <property name="can_focus">True</property>
+                        <property name="hscrollbar_policy">never</property>
+                        <child>
+                          <object class="GtkViewport" id="viewport2">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <child>
+                              <object class="GtkEventBox" id="eventbox1">
+                                <property name="visible">True</property>
+                                <property name="can_focus">True</property>
+                                <child>
+                                  <object class="GtkBox" id="curves">
+                                    <property name="visible">True</property>
+                                    <property name="can_focus">False</property>
+                                    <property name="orientation">vertical</property>
+                                    <child>
+                                      <placeholder/>
+                                    </child>
+                                    <child>
+                                      <placeholder/>
+                                    </child>
+                                    <child>
+                                      <placeholder/>
+                                    </child>
+                                  </object>
+                                </child>
+                              </object>
+                            </child>
+                          </object>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">True</property>
+                        <property name="fill">True</property>
+                        <property name="position">2</property>
+                      </packing>
+                    </child>
+                  </object>
+                </child>
+                <child type="label">
+                  <object class="GtkLabel" id="label10">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">Curves</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="resize">True</property>
+                <property name="shrink">False</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkExpander" id="expander1">
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <child>
+                  <object class="GtkBox" id="box1">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="orientation">vertical</property>
+                    <child>
+                      <object class="GtkScrolledWindow" id="scrolledwindow2">
+                        <property name="visible">True</property>
+                        <property name="can_focus">True</property>
+                        <property name="vadjustment">adjustment1</property>
+                        <property name="hscrollbar_policy">never</property>
+                        <child>
+                          <object class="GtkViewport" id="viewport1">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="vadjustment">adjustment1</property>
+                            <property name="shadow_type">none</property>
+                            <child>
+                              <object class="GtkTable" id="subterms">
+                                <property name="visible">True</property>
+                                <property name="can_focus">False</property>
+                                <property name="n_columns">2</property>
+                                <signal name="add" handler="onSubtermChildAdded" swapped="no"/>
+                                <signal name="map" handler="onSubtermsMap" swapped="no"/>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                                <child>
+                                  <placeholder/>
+                                </child>
+                              </object>
+                            </child>
+                          </object>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">True</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkLabel" id="newSubZone">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="ypad">18</property>
+                        <property name="label" translatable="yes">Drop new sub here</property>
+                        <signal name="drag-data-received" handler="onDragDataInNewSubZone" swapped="no"/>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkBox" id="box2">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <child>
+                          <placeholder/>
+                        </child>
+                        <child>
+                          <object class="GtkButton" id="button2">
+                            <property name="label">gtk-add</property>
+                            <property name="use_action_appearance">False</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="receives_default">True</property>
+                            <property name="use_stock">True</property>
+                            <signal name="clicked" handler="onNewSubterm" swapped="no"/>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">False</property>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <placeholder/>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">False</property>
+                        <property name="position">2</property>
+                      </packing>
+                    </child>
+                  </object>
+                </child>
+                <child type="label">
+                  <object class="GtkLabel" id="subtermsLabel">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">Subterms</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="resize">False</property>
+                <property name="shrink">False</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="statusRow">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkFrame" id="frame2">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label_xalign">0</property>
+                <property name="shadow_type">none</property>
+                <child>
+                  <object class="GtkTable" id="status">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="n_columns">2</property>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                    <child>
+                      <placeholder/>
+                    </child>
+                  </object>
+                </child>
+                <child type="label">
+                  <object class="GtkLabel" id="label1">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">Status</property>
+                    <property name="use_markup">True</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkTextView" id="textview1">
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="editable">False</property>
+                <property name="wrap_mode">word</property>
+                <property name="buffer">help</property>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">False</property>
+            <property name="padding">5</property>
+            <property name="position">3</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </object>
+  <object class="GtkImage" id="image2">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="stock">gtk-refresh</property>
+  </object>
+  <object class="GtkListStore" id="liststore1"/>
+  <object class="GtkDialog" id="newSubterm">
+    <property name="can_focus">False</property>
+    <property name="border_width">5</property>
+    <property name="type">popup</property>
+    <property name="title" translatable="yes">New curve</property>
+    <property name="modal">True</property>
+    <property name="window_position">mouse</property>
+    <property name="type_hint">normal</property>
+    <child internal-child="vbox">
+      <object class="GtkBox" id="dialog-vbox3">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="spacing">2</property>
+        <child internal-child="action_area">
+          <object class="GtkButtonBox" id="dialog-action_area3">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="layout_style">end</property>
+            <child>
+              <object class="GtkButton" id="button12">
+                <property name="label">gtk-cancel</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_stock">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="button3">
+                <property name="label">gtk-add</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="can_default">True</property>
+                <property name="has_default">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_stock">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="pack_type">end</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="label11">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label" translatable="yes">Name for new subterm</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="vbox11">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="orientation">vertical</property>
+            <child>
+              <object class="GtkComboBox" id="newSubtermName">
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="has_focus">True</property>
+                <property name="is_focus">True</property>
+                <property name="model">liststore1</property>
+                <property name="has_entry">True</property>
+                <property name="entry_text_column">0</property>
+                <child internal-child="entry">
+                  <object class="GtkEntry" id="combobox-entry1">
+                    <property name="can_focus">False</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="newSubtermMakeCurve">
+                <property name="label" translatable="yes">_Make new curve with the same name</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_underline">True</property>
+                <property name="xalign">0.5</property>
+                <property name="active">True</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+    <action-widgets>
+      <action-widget response="2">button12</action-widget>
+      <action-widget response="1">button3</action-widget>
+    </action-widgets>
+  </object>
+  <object class="GtkDialog" id="newCurve">
+    <property name="can_focus">False</property>
+    <property name="border_width">5</property>
+    <property name="title" translatable="yes">New curve</property>
+    <property name="modal">True</property>
+    <property name="window_position">mouse</property>
+    <property name="type_hint">normal</property>
+    <child internal-child="vbox">
+      <object class="GtkBox" id="dialog-vbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="spacing">2</property>
+        <child internal-child="action_area">
+          <object class="GtkButtonBox" id="dialog-action_area1">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="layout_style">end</property>
+            <child>
+              <object class="GtkButton" id="button5">
+                <property name="label">gtk-cancel</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_stock">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="button4">
+                <property name="label">gtk-add</property>
+                <property name="use_action_appearance">False</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="can_default">True</property>
+                <property name="has_default">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_stock">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="pack_type">end</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="label12">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label" translatable="yes">Name for new curve</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEntry" id="newCurveName">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="has_focus">True</property>
+            <property name="is_focus">True</property>
+            <property name="invisible_char">●</property>
+            <property name="activates_default">True</property>
+            <property name="primary_icon_activatable">False</property>
+            <property name="secondary_icon_activatable">False</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+    <action-widgets>
+      <action-widget response="2">button5</action-widget>
+      <action-widget response="1">button4</action-widget>
+    </action-widgets>
+  </object>
+  <object class="GtkSizeGroup" id="sizegroup1"/>
+  <object class="GtkSizeGroup" id="sizegroup2"/>
+  <object class="GtkTextBuffer" id="textbuffer1">
+    <property name="text" translatable="yes">song01(t)</property>
+  </object>
+  <object class="GtkBox" id="vbox2">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="orientation">vertical</property>
+    <child>
+      <object class="GtkImage" id="image1">
+        <property name="width_request">289</property>
+        <property name="height_request">120</property>
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="stock">gtk-missing-image</property>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">False</property>
+        <property name="position">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="label18">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="yalign">0.4699999988079071</property>
+        <property name="label" translatable="yes">vidref from Sat 15:30</property>
+      </object>
+      <packing>
+        <property name="expand">True</property>
+        <property name="fill">True</property>
+        <property name="position">1</property>
+      </packing>
+    </child>
+  </object>
+</interface>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/curveedit.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,55 @@
+"""
+this may be split out from curvecalc someday, since it doesn't
+need to be tied to a gui """
+import cgi
+
+from louie import dispatcher
+from rdflib import URIRef
+from twisted.internet import reactor
+import cyclone.web
+
+from cycloneerr import PrettyErrorHandler
+
+
+def serveCurveEdit(port, hoverTimeResponse, curveset):
+    """
+    /hoverTime requests actually are handled by the curvecalc gui
+    """
+    curveEdit = CurveEdit(curveset)
+
+    class HoverTime(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+        def get(self):
+            hoverTimeResponse(self)
+
+    class LiveInputPoint(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+        def post(self):
+            params = cgi.parse_qs(self.request.body)
+            curve = URIRef(params['curve'][0])
+            value = float(params['value'][0])
+            curveEdit.liveInputPoint(curve, value)
+            self.set_status(204)
+
+    reactor.listenTCP(
+        port,
+        cyclone.web.Application(handlers=[
+            (r'/hoverTime', HoverTime),
+            (r'/liveInputPoint', LiveInputPoint),
+        ],
+                                debug=True))
+
+
+class CurveEdit(object):
+
+    def __init__(self, curveset):
+        self.curveset = curveset
+        dispatcher.connect(self.inputTime, "input time")
+        self.currentTime = 0
+
+    def inputTime(self, val):
+        self.currentTime = val
+
+    def liveInputPoint(self, curveUri, value):
+        curve = self.curveset.curveFromUri(curveUri)
+        curve.live_input_point((self.currentTime, value), clear_ahead_secs=.5)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/curveview.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,1360 @@
+import math, logging
+from gi.repository import Gtk
+from gi.repository import Gdk
+from gi.repository import GooCanvas
+import louie as dispatcher
+from rdflib import Literal
+from twisted.internet import reactor
+from light9.curvecalc.zoomcontrol import RegionZoom
+from light9.curvecalc.curve import introPad, postPad
+from lib.goocanvas_compat import Points, polyline_new_line
+import imp
+
+log = logging.getLogger()
+print("curveview.py toplevel")
+
+
+def vlen(v):
+    return math.sqrt(v[0] * v[0] + v[1] * v[1])
+
+
+def angle_between(base, p0, p1):
+    p0 = p0[0] - base[0], p0[1] - base[1]
+    p1 = p1[0] - base[0], p1[1] - base[1]
+    p0 = [x / vlen(p0) for x in p0]
+    p1 = [x / vlen(p1) for x in p1]
+    dot = p0[0] * p1[0] + p0[1] * p1[1]
+    dot = max(-1, min(1, dot))
+    return math.degrees(math.acos(dot))
+
+
+class Sketch:
+    """a sketch motion on a curveview, with temporary points while you
+    draw, and simplification when you release"""
+
+    def __init__(self, curveview, ev):
+        self.curveview = curveview
+        self.pts = []
+        self.last_x = None
+
+    def motion(self, ev):
+        p = self.curveview.world_from_screen(ev.x, ev.y)
+        p = p[0], max(0, min(1, p[1]))
+        if self.last_x is not None and abs(ev.x - self.last_x) < 4:
+            return
+        self.last_x = ev.x
+        self.pts.append(p)
+        self.curveview.add_point(p)
+
+    def release(self, ev):
+        pts = sorted(self.pts)
+        finalPoints = pts[:]
+
+        dx = .01
+        to_remove = []
+        for i in range(1, len(pts) - 1):
+            x = pts[i][0]
+
+            p_left = (x - dx, self.curveview.curve(x - dx))
+            p_right = (x + dx, self.curveview.curve(x + dx))
+
+            if angle_between(pts[i], p_left, p_right) > 160:
+                to_remove.append(i)
+
+        for i in to_remove:
+            self.curveview.curve.remove_point(pts[i])
+            finalPoints.remove(pts[i])
+
+        # the simplified curve may now be too far away from some of
+        # the points, so we'll put them back. this has an unfortunate
+        # bias toward reinserting the earlier points
+        for i in to_remove:
+            p = pts[i]
+            if abs(self.curveview.curve(p[0]) - p[1]) > .1:
+                self.curveview.add_point(p)
+                finalPoints.append(p)
+
+        self.curveview.update_curve()
+        self.curveview.select_points(finalPoints)
+
+
+class SelectManip(object):
+    """
+    selection manipulator is created whenever you have a selection. It
+    draws itself on the canvas and edits the points when you drag
+    various parts
+    """
+
+    def __init__(self, parent, getSelectedIndices, getWorldPoint,
+                 getScreenPoint, getCanvasHeight, setPoints, getWorldTime,
+                 getWorldValue, getDragRange):
+        """parent goocanvas group"""
+        self.getSelectedIndices = getSelectedIndices
+        self.getWorldPoint = getWorldPoint
+        self.getScreenPoint = getScreenPoint
+        self.getCanvasHeight = getCanvasHeight
+        self.setPoints = setPoints
+        self.getWorldTime = getWorldTime
+        self.getDragRange = getDragRange
+        self.getWorldValue = getWorldValue
+        self.grp = GooCanvas.CanvasGroup(parent=parent)
+
+        self.title = GooCanvas.CanvasText(parent=self.grp,
+                                          text="selectmanip",
+                                          x=10,
+                                          y=10,
+                                          fill_color='white',
+                                          font="ubuntu 10")
+
+        self.bbox = GooCanvas.CanvasRect(parent=self.grp,
+                                         fill_color_rgba=0xffff0030,
+                                         line_width=0)
+
+        self.xTrans = polyline_new_line(
+            parent=self.grp,
+            close_path=True,
+            fill_color_rgba=0xffffff88,
+        )
+        self.centerScale = polyline_new_line(
+            parent=self.grp,
+            close_path=True,
+            fill_color_rgba=0xffffff88,
+        )
+
+        thickLine = lambda: polyline_new_line(
+            parent=self.grp, stroke_color_rgba=0xffffccff, line_width=6)
+        self.leftScale = thickLine()
+        self.rightScale = thickLine()
+        self.topScale = thickLine()
+
+        for grp, name in [
+            (self.xTrans, 'x'),
+            (self.leftScale, 'left'),
+            (self.rightScale, 'right'),
+            (self.topScale, 'top'),
+            (self.centerScale, 'centerScale'),
+        ]:
+            grp.connect("button-press-event", self.onPress, name)
+            grp.connect("button-release-event", self.onRelease, name)
+            grp.connect("motion-notify-event", self.onMotion, name)
+            grp.connect("enter-notify-event", self.onEnter, name)
+            grp.connect("leave-notify-event", self.onLeave, name)
+            # and hover highlight
+        self.update()
+
+    def onEnter(self, item, target_item, event, param):
+        self.prevColor = item.props.stroke_color_rgba
+        item.props.stroke_color_rgba = 0xff0000ff
+
+    def onLeave(self, item, target_item, event, param):
+        item.props.stroke_color_rgba = self.prevColor
+
+    def onPress(self, item, target_item, event, param):
+        self.dragStartTime = self.getWorldTime(event.x)
+        idxs = self.getSelectedIndices()
+
+        self.origPoints = [self.getWorldPoint(i) for i in idxs]
+        self.origMaxValue = max(p[1] for p in self.origPoints)
+        moveLeft, moveRight = self.getDragRange(idxs)
+
+        if param == 'centerScale':
+            self.maxPointMove = min(moveLeft, moveRight)
+
+        self.dragRange = (self.dragStartTime - moveLeft,
+                          self.dragStartTime + moveRight)
+        return True
+
+    def onMotion(self, item, target_item, event, param):
+        if hasattr(self, 'dragStartTime'):
+            origPts = list(zip(self.getSelectedIndices(), self.origPoints))
+            left = origPts[0][1][0]
+            right = origPts[-1][1][0]
+            width = right - left
+            dontCross = .001
+
+            clampLo = left if param == 'right' else self.dragRange[0]
+            clampHi = right if param == 'left' else self.dragRange[1]
+
+            def clamp(x, lo, hi):
+                return max(lo, min(hi, x))
+
+            mouseT = self.getWorldTime(event.x)
+            clampedT = clamp(mouseT, clampLo + dontCross, clampHi - dontCross)
+
+            dt = clampedT - self.dragStartTime
+
+            if param == 'x':
+                self.setPoints(
+                    (i, (orig[0] + dt, orig[1])) for i, orig in origPts)
+            elif param == 'left':
+                self.setPoints(
+                    (i,
+                     (left + dt + (orig[0] - left) / width *
+                      clamp(width - dt, dontCross, right - clampLo - dontCross),
+                      orig[1])) for i, orig in origPts)
+            elif param == 'right':
+                self.setPoints(
+                    (i,
+                     (left + (orig[0] - left) / width *
+                      clamp(width + dt, dontCross, clampHi - left - dontCross),
+                      orig[1])) for i, orig in origPts)
+            elif param == 'top':
+                v = self.getWorldValue(event.y)
+                if self.origMaxValue == 0:
+                    self.setPoints((i, (orig[0], v)) for i, orig in origPts)
+                else:
+                    scl = max(0,
+                              min(1 / self.origMaxValue, v / self.origMaxValue))
+                    self.setPoints(
+                        (i, (orig[0], orig[1] * scl)) for i, orig in origPts)
+
+            elif param == 'centerScale':
+                dt = mouseT - self.dragStartTime
+                rad = width / 2
+                tMid = left + rad
+                maxScl = (rad + self.maxPointMove - dontCross) / rad
+                newWidth = max(dontCross / width, min(
+                    (rad + dt) / rad, maxScl)) * width
+                self.setPoints(
+                    (i, (tMid + ((orig[0] - left) / width - .5) * newWidth,
+                         orig[1])) for i, orig in origPts)
+
+    def onRelease(self, item, target_item, event, param):
+        if hasattr(self, 'dragStartTime'):
+            del self.dragStartTime
+
+    def update(self):
+        """if the view or selection or selected point positions
+        change, call this to redo the layout of the manip"""
+        idxs = self.getSelectedIndices()
+        pts = [self.getScreenPoint(i) for i in idxs]
+
+        b = self.bbox.props
+        b.x = min(p[0] for p in pts) - 5
+        b.y = min(p[1] for p in pts) - 5
+        margin = 10 if len(pts) > 1 else 0
+        b.width = max(p[0] for p in pts) - b.x + margin
+        b.height = min(
+            max(p[1] for p in pts) - b.y + margin,
+            self.getCanvasHeight() - b.y - 1)
+
+        multi = (GooCanvas.CanvasItemVisibility.VISIBLE
+                 if len(pts) > 1 else GooCanvas.CanvasItemVisibility.INVISIBLE)
+        b.visibility = multi
+        self.leftScale.props.visibility = multi
+        self.rightScale.props.visibility = multi
+        self.topScale.props.visibility = multi
+        self.centerScale.props.visibility = multi
+
+        self.title.props.text = "%s %s selected" % (
+            len(idxs), "point" if len(idxs) == 1 else "points")
+
+        centerX = b.x + b.width / 2
+
+        midY = self.getCanvasHeight() * .5
+        loY = self.getCanvasHeight() * .8
+
+        self.leftScale.props.points = Points([(b.x, b.y),
+                                              (b.x, b.y + b.height)])
+        self.rightScale.props.points = Points([(b.x + b.width, b.y),
+                                               (b.x + b.width, b.y + b.height)])
+
+        self.topScale.props.points = Points([(b.x, b.y), (b.x + b.width, b.y)])
+
+        self.updateXTrans(centerX, midY)
+
+        self.centerScale.props.points = Points([(centerX - 5, loY - 5),
+                                                (centerX + 5, loY - 5),
+                                                (centerX + 5, loY + 5),
+                                                (centerX - 5, loY + 5)])
+
+    def updateXTrans(self, centerX, midY):
+        x1 = centerX - 30
+        x2 = centerX - 20
+        x3 = centerX + 20
+        x4 = centerX + 30
+        y1 = midY - 10
+        y2 = midY - 5
+        y3 = midY + 5
+        y4 = midY + 10
+        shape = [
+            (x1, midY),  # left tip
+            (x2, y1),
+            (x2, y2),
+            (x3, y2),
+            (x3, y1),
+            (x4, midY),  # right tip
+            (x3, y4),
+            (x3, y3),
+            (x2, y3),
+            (x2, y4)
+        ]
+
+        self.xTrans.props.points = Points(shape)
+
+    def destroy(self):
+        self.grp.remove()
+
+
+class Curveview(object):
+    """
+    graphical curve widget only. Please pack .widget
+
+    <caller's parent>
+     -> self.widget <caller packs this>
+       -> EventBox
+         -> Box vertical, for border
+           -> self.canvas GooCanvas
+             -> root CanvasItem
+               ..various groups and items..
+
+    The canvas x1/x2/y1/y2 coords are updated to match self.widget.
+    
+    """
+
+    def __init__(self,
+                 curve,
+                 markers,
+                 knobEnabled=False,
+                 isMusic=False,
+                 zoomControl=None):
+        """knobEnabled=True highlights the previous key and ties it to a
+        hardware knob"""
+        self.curve = curve
+        self.markers = markers
+        self.knobEnabled = knobEnabled
+        self._isMusic = isMusic
+        self.zoomControl = zoomControl
+
+        self.redrawsEnabled = False
+
+        box = self.createOuterWidgets()
+        self.canvas = self.createCanvasWidget(box)
+        self.trackWidgetSize()
+        self.update_curve()
+
+        self._time = -999
+        self.last_mouse_world = None
+        self.entered = False  # is the mouse currently over this widget
+        self.selected_points = []  # idx of points being dragged
+        self.dots = {}
+        # self.bind("<Enter>",self.focus)
+        dispatcher.connect(self.playPause, "onPlayPause")
+        dispatcher.connect(self.input_time, "input time")
+        dispatcher.connect(self.update_curve, "zoom changed")
+        dispatcher.connect(self.update_curve,
+                           "points changed",
+                           sender=self.curve)
+        dispatcher.connect(self.update_curve, "mute changed", sender=self.curve)
+        dispatcher.connect(self.select_between, "select between")
+        dispatcher.connect(self.acls, "all curves lose selection")
+        if self.knobEnabled:
+            dispatcher.connect(self.knob_in, "knob in")
+            dispatcher.connect(self.slider_in, "set key")
+
+        # todo: hold control to get a [+] cursor
+        #        def curs(ev):
+        #            print ev.state
+        #        self.bind("<KeyPress>",curs)
+        #        self.bind("<KeyRelease-Control_L>",lambda ev: curs(0))
+
+        # this binds on c-a-b1, etc
+        if 0:  # unported
+            self.regionzoom = RegionZoom(self, self.world_from_screen,
+                                         self.screen_from_world)
+
+        self.sketch = None  # an in-progress sketch
+
+        self.dragging_dots = False
+        self.selecting = False
+
+    def acls(self, butNot=None):
+        if butNot is self:
+            return
+        self.unselect()
+
+    def createOuterWidgets(self):
+        self.timelineLine = self.curveGroup = self.selectManip = None
+        self.widget = Gtk.EventBox()
+        self.widget.set_can_focus(True)
+        self.widget.add_events(Gdk.EventMask.KEY_PRESS_MASK |
+                               Gdk.EventMask.FOCUS_CHANGE_MASK)
+        self.onFocusOut()
+
+        box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
+        box.set_border_width(1)
+        self.widget.add(box)
+        box.show()
+        return box
+
+    def trackWidgetSize(self):
+        """
+        Also tried:
+
+            visibility-notify-event
+            (Gdk.EventMask.VISIBILITY_NOTIFY_MASK) fires on some
+            resizes but definitely not all. During window resizes,
+            sometimes I have to 'shake' the window size to get all
+            curves to update.
+        
+            configure-event seems to never fire.
+
+            size-allocate seems right but i get into infinite bounces
+            between two sizes
+        """
+
+        def sizeEvent(w, alloc):
+            p = self.canvas.props
+            if (alloc.width, alloc.height) != (p.x2, p.y2):
+                p.x1, p.x2 = 0, alloc.width
+                p.y1, p.y2 = 0, alloc.height
+                # calling update_curve in this event usually doesn't work
+                reactor.callLater(0, self.update_curve)
+            return False
+
+        #self.widget.connect('size-allocate', sizeEvent) # see docstring
+
+        def visEvent(w, alloc):
+            self.setCanvasToWidgetSize()
+            return False
+
+        self.widget.add_events(Gdk.EventMask.VISIBILITY_NOTIFY_MASK)
+        self.widget.connect('visibility-notify-event', visEvent)
+
+    def setCanvasToWidgetSize(self):
+        p = self.canvas.props
+        w = self.widget.get_allocated_width()
+        h = self.widget.get_allocated_height()
+        if (w, h) != (p.x2, p.y2):
+            p.x1, p.x2 = 0, w
+            p.y1, p.y2 = 0, h
+            self.update_curve()
+
+    def createCanvasWidget(self, parent):
+        # this is only separate from createOuterWidgets because in the
+        # past, i worked around display bugs by recreating the whole
+        # canvas widget. If that's not necessary, this could be more
+        # clearly combined with createOuterWidgets since there's no
+        # time you'd want that one but not this one.
+        canvas = GooCanvas.Canvas()
+        parent.pack_start(canvas, expand=True, fill=True, padding=0)
+        canvas.show()
+
+        p = canvas.props
+        p.background_color = 'black'
+        root = canvas.get_root_item()
+
+        canvas.connect("leave-notify-event", self.onLeave)
+        canvas.connect("enter-notify-event", self.onEnter)
+        canvas.connect("motion-notify-event", self.onMotion)
+        canvas.connect("scroll-event", self.onScroll)
+        canvas.connect("button-release-event", self.onRelease)
+        root.connect("button-press-event", self.onCanvasPress)
+
+        self.widget.connect("key-press-event", self.onKeyPress)
+
+        self.widget.connect("focus-in-event", self.onFocusIn)
+        self.widget.connect("focus-out-event", self.onFocusOut)
+        #self.widget.connect("event", self.onAny)
+        return canvas
+
+    def onAny(self, w, event):
+        print("   %s on %s" % (event, w))
+
+    def onFocusIn(self, *args):
+        dispatcher.send('curve row focus change')
+        dispatcher.send("all curves lose selection", butNot=self)
+
+        self.widget.modify_bg(Gtk.StateFlags.NORMAL, Gdk.color_parse("red"))
+
+    def onFocusOut(self, widget=None, event=None):
+        dispatcher.send('curve row focus change')
+        self.widget.modify_bg(Gtk.StateFlags.NORMAL, Gdk.color_parse("gray30"))
+
+        # you'd think i'm unselecting when we lose focus, but we also
+        # lose focus when the user moves off the toplevel window, and
+        # that's not a time to forget the selection. See the 'all
+        # curves lose selection' signal for the fix.
+
+    def onKeyPress(self, widget, event):
+        if event.string in list('12345'):
+            x = int(event.string)
+            self.add_point((self.current_time(), (x - 1) / 4.0))
+        if event.string in list('qwerty'):
+            self.add_marker((self.current_time(), event.string))
+
+    def onDelete(self):
+        if self.selected_points:
+            self.remove_point_idx(*self.selected_points)
+
+    def onCanvasPress(self, item, target_item, event):
+        # when we support multiple curves per canvas, this should find
+        # the close one and add a point to that. Binding to the line
+        # itself is probably too hard to hit. Maybe a background-color
+        # really thick line would be a nice way to allow a sloppier
+        # click
+
+        self.widget.grab_focus()
+
+        _, flags = event.get_state()
+        if flags & Gdk.ModifierType.CONTROL_MASK:
+            self.new_point_at_mouse(event)
+        elif flags & Gdk.ModifierType.SHIFT_MASK:
+            self.sketch_press(event)
+        else:
+            self.select_press(event)
+
+        # this stops some other handler that wants to unfocus
+        return True
+
+    def playPause(self):
+        """
+        user has pressed ctrl-p over a curve view, possibly this
+        one. Returns the time under the mouse if we know it, or else
+        None
+
+        todo: there should be a faint timecursor line under the mouse
+        so it's more obvious that we use that time for some
+        events. Rt-click should include Ctrl+P as 'play/pause from
+        here'
+        """
+        # maybe self.canvas.get_pointer would be ok for this? i didn't try it
+        if self.entered and hasattr(self, 'lastMouseX'):
+            t = self.world_from_screen(self.lastMouseX, 0)[0]
+            return t
+        return None
+
+    def goLive(self):
+        """this is for startup performance only, since the curves were
+        getting redrawn many times. """
+        self.redrawsEnabled = True
+        self.update_curve()
+
+    def knob_in(self, curve, value):
+        """user turned a hardware knob, which edits the point to the
+        left of the current time"""
+        if curve != self.curve:
+            return
+        idx = self.curve.index_before(self.current_time())
+        if idx is not None:
+            pos = self.curve.points[idx]
+            self.curve.set_points([(idx, (pos[0], value))])
+
+    def slider_in(self, curve, value=None):
+        """user pushed on a slider. make a new key.  if value is None,
+        the value will be the same as the last."""
+        if curve != self.curve:
+            return
+
+        if value is None:
+            value = self.curve.eval(self.current_time())
+
+        self.curve.insert_pt((self.current_time(), value))
+
+    def print_state(self, msg=""):
+        if 0:
+            print("%s: dragging_dots=%s selecting=%s" %
+                  (msg, self.dragging_dots, self.selecting))
+
+    def select_points(self, pts):
+        """set selection to the given point values (tuples, not indices)"""
+        idxs = []
+        for p in pts:
+            idxs.append(self.curve.points.index(p))
+        self.select_indices(idxs)
+
+    def select_indices(self, idxs):
+        """set selection to these point indices. This is the only
+        writer to self.selected_points"""
+        self.selected_points = idxs
+        self.highlight_selected_dots()
+        if self.selected_points and not self.selectManip:
+            self.selectManip = SelectManip(
+                self.canvas.get_root_item(),
+                getSelectedIndices=lambda: sorted(self.selected_points),
+                getWorldPoint=lambda i: self.curve.points[i],
+                getScreenPoint=lambda i: self.screen_from_world(self.curve.
+                                                                points[i]),
+                getWorldTime=lambda x: self.world_from_screen(x, 0)[0],
+                getWorldValue=lambda y: self.world_from_screen(0, y)[1],
+                getCanvasHeight=lambda: self.canvas.props.y2,
+                setPoints=self.setPoints,
+                getDragRange=self.getDragRange,
+            )
+        if not self.selected_points and self.selectManip:
+            self.selectManip.destroy()
+            self.selectManip = None
+
+        self.selectionChanged()
+
+    def getDragRange(self, idxs):
+        """
+        if you're dragging these points, what's the most time you can move
+        left and right before colliding (exactly) with another
+        point
+        """
+        maxLeft = maxRight = 99999
+        cp = self.curve.points
+        for i in idxs:
+            nextStatic = i
+            while nextStatic >= 0 and nextStatic in idxs:
+                nextStatic -= 1
+            if nextStatic >= 0:
+                maxLeft = min(maxLeft, cp[i][0] - cp[nextStatic][0])
+
+            nextStatic = i
+            while nextStatic <= len(cp) - 1 and nextStatic in idxs:
+                nextStatic += 1
+            if nextStatic <= len(cp) - 1:
+                maxRight = min(maxRight, cp[nextStatic][0] - cp[i][0])
+        return maxLeft, maxRight
+
+    def setPoints(self, updates):
+        self.curve.set_points(updates)
+
+    def selectionChanged(self):
+        if self.selectManip:
+            self.selectManip.update()
+
+    def select_press(self, ev):
+        # todo: these select_ handlers are getting called on c-a-drag
+        # zooms too. the dispatching should be more selective than
+        # just calling both handlers all the time
+        self.print_state("select_press")
+        if self.dragging_dots:
+            return
+        if not self.selecting:
+            self.selecting = True
+            self.select_start = self.world_from_screen(ev.x, 0)[0]
+            #cursors.push(self,"gumby")
+
+    def select_motion(self, ev):
+        if not self.selecting:
+            return
+        start = self.select_start
+        cur = self.world_from_screen(ev.x, 0)[0]
+        self.select_between(start, cur)
+
+    def select_release(self, ev):
+        self.print_state("select_release")
+
+        # dotrelease never gets called, but I can clear that state here
+        self.dragging_dots = False
+
+        if not self.selecting:
+            return
+        #cursors.pop(self)
+        self.selecting = False
+        self.select_between(self.select_start,
+                            self.world_from_screen(ev.x, 0)[0])
+
+    def sketch_press(self, ev):
+        self.sketch = Sketch(self, ev)
+
+    def sketch_motion(self, ev):
+        if self.sketch:
+            self.sketch.motion(ev)
+
+    def sketch_release(self, ev):
+        if self.sketch:
+            self.sketch.release(ev)
+            self.sketch = None
+
+    def current_time(self):
+        return self._time
+
+    def _coords(self):
+        z = self.zoomControl
+        ht = self.canvas.props.y2
+        marginBottom = 3 if ht > 40 else 0
+        marginTop = marginBottom
+        return z, ht, marginBottom, marginTop
+
+    def screen_from_world(self, p):
+        z, ht, marginBottom, marginTop = self._coords()
+        return ((p[0] - z.start) / (z.end - z.start) * self.canvas.props.x2,
+                (ht - marginBottom) - p[1] * (ht - (marginBottom + marginTop)))
+
+    def world_from_screen(self, x, y):
+        z, ht, marginBottom, marginTop = self._coords()
+        return (x / self.canvas.props.x2 * (z.end - z.start) + z.start,
+                ((ht - marginBottom) - y) / (ht - (marginBottom + marginTop)))
+
+    def input_time(self, val, forceUpdate=False):
+        if self._time == val:
+            return
+        self.update_time_bar(val)
+
+    def alive(self):
+        # Some handlers still run after a view is destroyed, which
+        # leads to crashes in somewhere like
+        # goocanvas_add_item. Workaround is to disable certain methods
+        # when the widget is gone. Correct solution would be to stop
+        # and free those handlers when the widget is gone.
+        return self.canvas.is_visible()
+
+    def update_time_bar(self, t):
+        if not self.alive():
+            return
+        if not getattr(self, 'timelineLine', None):
+            self.timelineGroup = GooCanvas.CanvasGroup(
+                parent=self.canvas.get_root_item())
+            self.timelineLine = polyline_new_line(parent=self.timelineGroup,
+                                                  points=Points([(0, 0),
+                                                                 (0, 0)]),
+                                                  line_width=2,
+                                                  stroke_color='red')
+
+        try:
+            pts = [
+                self.screen_from_world((t, 0)),
+                self.screen_from_world((t, 1))
+            ]
+        except ZeroDivisionError:
+            pts = [(-1, -1), (-1, -1)]
+        self.timelineLine.set_property('points', Points(pts))
+
+        self._time = t
+        if self.knobEnabled:
+            self.delete('knob')
+            prevKey = self.curve.point_before(t)
+            if prevKey is not None:
+                pos = self.screen_from_world(prevKey)
+                self.create_oval(pos[0] - 8,
+                                 pos[1] - 8,
+                                 pos[0] + 8,
+                                 pos[1] + 8,
+                                 outline='#800000',
+                                 tags=('knob',))
+                dispatcher.send("knob out", value=prevKey[1], curve=self.curve)
+
+    def update_curve(self, *args):
+        if not getattr(self, '_pending_update', False):
+            self._pending_update = True
+            reactor.callLater(.01, self._update_curve)
+
+    def _update_curve(self):
+        try:
+            self._update_curve2()
+        except Exception:
+            log.error("in update_curve on %s", self.curve.uri)
+            raise
+
+    def _update_curve2(self):
+        if not getattr(self, '_pending_update', False):
+            return
+        self._pending_update = False
+        if not self.alive():
+            return
+        if not self.redrawsEnabled:
+            print("no redrawsEnabled, skipping", self)
+            return
+
+        visible_x = (self.world_from_screen(0, 0)[0],
+                     self.world_from_screen(self.canvas.props.x2, 0)[0])
+
+        visible_idxs = self.curve.indices_between(visible_x[0],
+                                                  visible_x[1],
+                                                  beyond=1)
+        visible_points = [self.curve.points[i] for i in visible_idxs]
+
+        if getattr(self, 'curveGroup', None):
+            self.curveGroup.remove()
+        self.curveGroup = GooCanvas.CanvasGroup(
+            parent=self.canvas.get_root_item())
+        self.curveGroup.lower(None)
+
+        self.canvas.set_property("background-color",
+                                 "gray20" if self.curve.muted else "black")
+
+        self.update_time_bar(self._time)
+        self._draw_line(visible_points, area=True)
+        self._draw_markers(
+            self.markers.points[i]
+            for i in self.markers.indices_between(visible_x[0], visible_x[1]))
+        if self.canvas.props.y2 > 80:
+            self._draw_time_tics(visible_x)
+
+            self.dots = {}  # idx : canvas rectangle
+
+            if len(visible_points) < 50 and not self.curve.muted:
+                self._draw_handle_points(visible_idxs, visible_points)
+
+        self.selectionChanged()
+
+    def is_music(self):
+        """are we one of the music curves (which might be drawn a bit
+        differently)"""
+        return self._isMusic
+
+    def _draw_markers(self, pts):
+        colorMap = {
+            'q': '#598522',
+            'w': '#662285',
+            'e': '#852922',
+            'r': '#85225C',
+            't': '#856B22',
+            'y': '#227085',
+        }
+        for t, name in pts:
+            x = int(self.screen_from_world((t, 0))[0]) + .5
+            polyline_new_line(self.curveGroup,
+                              x,
+                              0,
+                              x,
+                              self.canvas.props.y2,
+                              line_width=.4 if name in 'rty' else .8,
+                              stroke_color=colorMap.get(name, 'gray'))
+
+    def _draw_time_tics(self, visible_x):
+        tic = self._draw_one_tic
+
+        tic(0, "0")
+        t1, t2 = visible_x
+        if t2 - t1 < 30:
+            for t in range(int(t1), int(t2) + 1):
+                tic(t, str(t))
+        tic(introPad, str(introPad))
+
+        endtimes = dispatcher.send("get max time")
+        if endtimes:
+            endtime = endtimes[0][1]
+            tic(endtime, "end %.1f" % endtime)
+            tic(endtime - postPad, "post %.1f" % (endtime - postPad))
+
+    def _draw_one_tic(self, t, label):
+        try:
+            x = self.screen_from_world((t, 0))[0]
+            if not 0 <= x < self.canvas.props.x2:
+                return
+            x = max(5, x)  # cheat left-edge stuff onscreen
+        except ZeroDivisionError:
+            x = -100
+
+        ht = self.canvas.props.y2
+        polyline_new_line(self.curveGroup,
+                          x,
+                          ht,
+                          x,
+                          ht - 20,
+                          line_width=.5,
+                          stroke_color='gray70')
+        GooCanvas.CanvasText(parent=self.curveGroup,
+                             fill_color="white",
+                             anchor=GooCanvas.CanvasAnchorType.SOUTH,
+                             font="ubuntu 7",
+                             x=x + 3,
+                             y=ht - 20,
+                             text=label)
+
+    def _draw_line(self, visible_points, area=False):
+        if not visible_points:
+            return
+        linepts = []
+        step = 1
+        linewidth = 1.5
+        maxPointsToDraw = self.canvas.props.x2 / 2
+        if len(visible_points) > maxPointsToDraw:
+            step = int(len(visible_points) / maxPointsToDraw)
+            linewidth = .8
+        for p in visible_points[::step]:
+            try:
+                x, y = self.screen_from_world(p)
+            except ZeroDivisionError:
+                x = y = -100
+            linepts.append((int(x) + .5, int(y) + .5))
+
+        if self.curve.muted:
+            fill = 'grey34'
+        else:
+            fill = 'white'
+
+        if area:
+            try:
+                base = self.screen_from_world((0, 0))[1]
+            except ZeroDivisionError:
+                base = -100
+            base = base + linewidth / 2
+            areapts = linepts[:]
+            if len(areapts) >= 1:
+                areapts.insert(0, (0, areapts[0][1]))
+                areapts.append((self.canvas.props.x2, areapts[-1][1]))
+            polyline_new_line(
+                parent=self.curveGroup,
+                points=Points([(areapts[0][0], base)] + areapts +
+                              [(areapts[-1][0], base)]),
+                close_path=True,
+                line_width=0,
+                # transparent as a workaround for
+                # covering some selectmanips (which
+                # become unclickable)
+                fill_color_rgba=0x00800080,
+            )
+
+        self.pl = polyline_new_line(
+            parent=self.curveGroup,
+            points=Points(linepts),
+            line_width=linewidth,
+            stroke_color=fill,
+        )
+
+    def _draw_handle_points(self, visible_idxs, visible_points):
+        for i, p in zip(visible_idxs, visible_points):
+            rad = 6
+            worldp = p
+            try:
+                p = self.screen_from_world(p)
+            except ZeroDivisionError:
+                p = (-100, -100)
+            dot = GooCanvas.CanvasRect(
+                parent=self.curveGroup,
+                x=int(p[0] - rad) + .5,
+                y=int(p[1] - rad) + .5,
+                width=rad * 2,
+                height=rad * 2,
+                stroke_color='gray90',
+                fill_color='blue',
+                line_width=1,
+                #tags=('curve','point', 'handle%d' % i)
+            )
+
+            if worldp[1] == 0:
+                rad += 3
+                GooCanvas.CanvasEllipse(
+                    parent=self.curveGroup,
+                    center_x=p[0],
+                    center_y=p[1],
+                    radius_x=rad,
+                    radius_y=rad,
+                    line_width=2,
+                    stroke_color='#00a000',
+                    #tags=('curve','point', 'handle%d' % i)
+                )
+            dot.connect("button-press-event", self.dotpress, i)
+            #self.tag_bind('handle%d' % i,"<ButtonPress-1>",
+            #              lambda ev,i=i: self.dotpress(ev,i))
+            #self.tag_bind('handle%d' % i, "<Key-d>",
+            #              lambda ev, i=i: self.remove_point_idx(i))
+
+            self.dots[i] = dot
+
+        self.highlight_selected_dots()
+
+    def find_index_near(self, x, y):
+        tags = self.gettags(self.find_closest(x, y))
+        try:
+            handletags = [t for t in tags if t.startswith('handle')]
+            return int(handletags[0][6:])
+        except IndexError:
+            raise ValueError("no point found")
+
+    def new_point_at_mouse(self, ev):
+        p = self.world_from_screen(ev.x, ev.y)
+        x = p[0]
+        y = self.curve.eval(x)
+        self.add_point((x, y))
+
+    def add_points(self, pts):
+        idxs = [self.curve.insert_pt(p) for p in pts]
+        self.select_indices(idxs)
+
+    def add_point(self, p):
+        self.add_points([p])
+
+    def add_marker(self, p):
+        self.markers.insert_pt(p)
+
+    def remove_point_idx(self, *idxs):
+        idxs = list(idxs)
+        while idxs:
+            i = idxs.pop()
+
+            self.curve.pop_point(i)
+            newsel = []
+            newidxs = []
+            for si in range(len(self.selected_points)):
+                sp = self.selected_points[si]
+                if sp == i:
+                    continue
+                if sp > i:
+                    sp -= 1
+                newsel.append(sp)
+            for ii in range(len(idxs)):
+                if ii > i:
+                    ii -= 1
+                newidxs.append(idxs[ii])
+
+            self.select_indices(newsel)
+            idxs[:] = newidxs
+
+    def highlight_selected_dots(self):
+        if not self.redrawsEnabled:
+            return
+
+        for i, d in list(self.dots.items()):
+            if i in self.selected_points:
+                d.set_property('fill_color', 'red')
+            else:
+                d.set_property('fill_color', 'blue')
+
+    def dotpress(self, r1, r2, ev, dotidx):
+        self.print_state("dotpress")
+        if dotidx not in self.selected_points:
+            self.select_indices([dotidx])
+
+        self.last_mouse_world = self.world_from_screen(ev.x, ev.y)
+        self.dragging_dots = True
+
+    def select_between(self, start, end):
+        if start > end:
+            start, end = end, start
+        self.select_indices(self.curve.indices_between(start, end))
+
+    def onEnter(self, widget, event):
+        self.entered = True
+
+    def onLeave(self, widget, event):
+        self.entered = False
+
+    def onMotion(self, widget, event):
+        self.lastMouseX = event.x
+
+        if event.state & Gdk.ModifierType.SHIFT_MASK and 1:  # and B1
+            self.sketch_motion(event)
+            return
+
+        self.select_motion(event)
+
+        if not self.dragging_dots:
+            return
+        if not event.state & 256:
+            return  # not lmb-down
+
+        # this way is accumulating error and also making it harder to
+        # undo (e.g. if the user moves far out of the window or
+        # presses esc or something). Instead, we should be resetting
+        # the points to their start pos plus our total offset.
+        cur = self.world_from_screen(event.x, event.y)
+        if self.last_mouse_world:
+            delta = (cur[0] - self.last_mouse_world[0],
+                     cur[1] - self.last_mouse_world[1])
+        else:
+            delta = 0, 0
+        self.last_mouse_world = cur
+
+        self.translate_points(delta)
+
+    def translate_points(self, delta):
+        moved = False
+
+        cp = self.curve.points
+        updates = []
+        for idx in self.selected_points:
+
+            newp = [cp[idx][0] + delta[0], cp[idx][1] + delta[1]]
+
+            newp[1] = max(0, min(1, newp[1]))
+
+            if idx > 0 and newp[0] <= cp[idx - 1][0]:
+                continue
+            if idx < len(cp) - 1 and newp[0] >= cp[idx + 1][0]:
+                continue
+            moved = True
+            updates.append((idx, tuple(newp)))
+        self.curve.set_points(updates)
+        return moved
+
+    def unselect(self):
+        self.select_indices([])
+
+    def onScroll(self, widget, event):
+        t = self.world_from_screen(event.x, 0)[0]
+        self.zoomControl.zoom_about_mouse(
+            t,
+            factor=1.5 if event.direction == Gdk.ScrollDirection.DOWN else 1 /
+            1.5)
+        # Don't actually scroll the canvas! (it shouldn't have room to
+        # scroll anyway, but it does because of some coordinate errors
+        # and borders and stuff)
+        return True
+
+    def onRelease(self, widget, event):
+        self.print_state("dotrelease")
+
+        if event.state & Gdk.ModifierType.SHIFT_MASK:  # relese-B1
+            self.sketch_release(event)
+            return
+
+        self.select_release(event)
+
+        if not self.dragging_dots:
+            return
+        self.last_mouse_world = None
+        self.dragging_dots = False
+
+
+class CurveRow(object):
+    """
+    one of the repeating curve rows (including widgets on the left)
+
+    i wish these were in a list-style TreeView so i could set_reorderable on it
+
+    please pack self.box
+    """
+
+    def __init__(self, graph, name, curve, markers, zoomControl):
+        self.graph = graph
+        self.name = name
+        self.box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
+        self.box.set_border_width(1)
+
+        self.cols = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
+        self.box.add(self.cols)
+
+        controls = Gtk.Frame()
+        controls.set_size_request(160, -1)
+        controls.set_shadow_type(Gtk.ShadowType.OUT)
+        self.cols.pack_start(controls, expand=False, fill=True, padding=0)
+        self.setupControls(controls, name, curve)
+
+        self.curveView = Curveview(curve,
+                                   markers,
+                                   isMusic=name in ['music', 'smooth_music'],
+                                   zoomControl=zoomControl)
+
+        self.initCurveView()
+        dispatcher.connect(self.rebuild, "all curves rebuild")
+
+    def isFocus(self):
+        return self.curveView.widget.is_focus()
+
+    def rebuild(self):
+        raise NotImplementedError('obsolete, if curves are drawing right')
+        self.curveView.rebuild()
+        self.initCurveView()
+        self.update_ui_to_collapsed_state()
+
+    def destroy(self):
+        self.curveView.entered = False  # help suppress bad position events
+        del self.curveView
+        self.box.destroy()
+
+    def initCurveView(self):
+        self.curveView.widget.show()
+        self.setHeight(100)
+        self.cols.pack_start(self.curveView.widget,
+                             expand=True,
+                             fill=True,
+                             padding=0)
+
+    def setHeight(self, h):
+        self.curveView.widget.set_size_request(-1, h)
+
+        # this should have been automatic when the size changed, but
+        # the signals for that are wrong somehow.
+        reactor.callLater(0, self.curveView.setCanvasToWidgetSize)
+
+    def setupControls(self, controls, name, curve):
+        box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0)
+        controls.add(box)
+
+        curve_name_label = Gtk.LinkButton()
+        print("need to truncate this name length somehow")
+
+        def update_label():
+            # todo: abort if we don't still exist...
+            p = curve_name_label.props
+            p.uri = curve.uri
+            p.label = self.graph.label(curve.uri)
+
+        self.graph.addHandler(update_label)
+
+        self.muted = Gtk.CheckButton("M")
+        self.muted.connect("toggled", self.sync_mute_to_curve)
+        dispatcher.connect(self.mute_changed, 'mute changed', sender=curve)
+
+        box.pack_start(curve_name_label, expand=True, fill=True, padding=0)
+        box.pack_start(self.muted, expand=True, fill=True, padding=0)
+
+    def onDelete(self):
+        self.curveView.onDelete()
+
+    def sync_mute_to_curve(self, *args):
+        """send value from CheckButton to the master attribute inside Curve"""
+        new_mute = self.muted.get_active()
+        self.curveView.curve.muted = new_mute
+
+    def update_mute_look(self):
+        """set colors on the widgets in the row according to self.muted.get()"""
+        # not yet ported for gtk
+        return
+        if self.curveView.curve.muted:
+            new_bg = 'grey20'
+        else:
+            new_bg = 'normal'
+
+        for widget in self.widgets:
+            widget['bg'] = new_bg
+
+    def mute_changed(self):
+        """call this if curve.muted changed"""
+        self.muted.set_active(self.curveView.curve.muted)
+        #self.update_mute_look()
+
+
+class Curvesetview(object):
+    """
+    
+    """
+
+    def __init__(self, graph, curvesVBox, zoomControlBox, curveset):
+        self.graph = graph
+        self.live = True
+        self.curvesVBox = curvesVBox
+        self.curveset = curveset
+        self.allCurveRows = set()
+        self.visibleHeight = 1000
+
+        self.zoomControl = self.initZoomControl(zoomControlBox)
+        self.zoomControl.redrawzoom()
+
+        for uri, label, curve in curveset.currentCurves():
+            self.add_curve(uri, label, curve)
+
+        dispatcher.connect(self.clear_curves, "clear_curves")
+        dispatcher.connect(self.add_curve, "add_curve", sender=self.curveset)
+        dispatcher.connect(self.set_featured_curves, "set_featured_curves")
+        dispatcher.connect(self.song_has_changed, "song_has_changed")
+
+        self.newcurvename = Gtk.EntryBuffer.new("", 0)
+
+        eventBox = self.curvesVBox.get_parent()
+        eventBox.connect("key-press-event", self.onKeyPress)
+        eventBox.connect("button-press-event", self.takeFocus)
+
+        self.watchCurveAreaHeight()
+
+    def __del__(self):
+        print("del curvesetview", id(self))
+
+    def initZoomControl(self, zoomControlBox):
+        import light9.curvecalc.zoomcontrol
+        imp.reload(light9.curvecalc.zoomcontrol)
+        zoomControl = light9.curvecalc.zoomcontrol.ZoomControl()
+        zoomControlBox.add(zoomControl.widget)
+        zoomControl.widget.show_all()
+        return zoomControl
+
+    def clear_curves(self):
+        """curveset is about to re-add all new curves"""
+        while self.allCurveRows:
+            self.allCurveRows.pop().destroy()
+
+    def song_has_changed(self):
+        self.zoomControl.redrawzoom()
+
+    def takeFocus(self, *args):
+        """the whole curveset's eventbox is what gets the focus, currently, so
+        keys like 'c' can work in it"""
+        dispatcher.send("all curves lose selection")
+        self.curvesVBox.get_parent().grab_focus()
+
+    def curveRow_from_name(self, name):
+        for cr in self.allCurveRows:
+            if cr.name == name:
+                return cr
+        raise ValueError("couldn't find curveRow named %r" % name)
+
+    def set_featured_curves(self, curveNames):
+        """bring these curves to the top of the stack"""
+        for n in curveNames[::-1]:
+            self.curvesVBox.reorder_child(
+                self.curveRow_from_name(n).box, Gtk.PACK_START)
+
+    def onKeyPress(self, widget, event):
+        if not self.live:  # workaround for old instances living past reload()
+            return
+
+        #r = self.row_under_mouse()
+        #key = event.string
+        pass  # no handlers right now
+
+    def row_under_mouse(self):
+        x, y = self.curvesVBox.get_pointer()
+        for r in self.allCurveRows:
+            inRowX, inRowY = self.curvesVBox.translate_coordinates(r.box, x, y)
+            alloc = r.box.get_allocation()
+            if 0 <= inRowX < alloc.width and 0 <= inRowY < alloc.height:
+                return r
+        raise ValueError("no curveRow is under the mouse")
+
+    def focus_entry(self):
+        self.entry.focus()
+
+    def new_curve(self, event):
+        self.curveset.new_curve(self.newcurvename.get())
+        self.newcurvename.set('')
+
+    def add_curve(self, uri, label, curve):
+        if isinstance(label, Literal):
+            label = str(label)
+
+        f = CurveRow(self.graph, label, curve, self.curveset.markers,
+                     self.zoomControl)
+        self.curvesVBox.pack_start(f.box, expand=True, fill=True, padding=0)
+        f.box.show_all()
+        self.allCurveRows.add(f)
+        self.setRowHeights()
+        f.curveView.goLive()
+
+    def watchCurveAreaHeight(self):
+
+        def sizeEvent(w, size):
+            # this is firing really often
+            if self.visibleHeight == size.height:
+                return
+            log.debug("size.height is new: %s", size.height)
+            self.visibleHeight = size.height
+            self.setRowHeights()
+
+        visibleArea = self.curvesVBox.get_parent().get_parent()
+        visibleArea.connect('size-allocate', sizeEvent)
+
+        dispatcher.connect(self.setRowHeights, "curve row focus change")
+
+    def setRowHeights(self):
+        nRows = len(self.allCurveRows)
+        if not nRows:
+            return
+        anyFocus = any(r.isFocus() for r in self.allCurveRows)
+
+        evenHeight = max(14, self.visibleHeight // nRows) - 3
+        if anyFocus:
+            focusHeight = max(100, evenHeight)
+            if nRows > 1:
+                otherHeight = max(14, (self.visibleHeight - focusHeight) //
+                                  (nRows - 1)) - 3
+        else:
+            otherHeight = evenHeight
+        for row in self.allCurveRows:
+            row.setHeight(focusHeight if row.isFocus() else otherHeight)
+
+    def row(self, name):
+        if isinstance(name, Literal):
+            name = str(name)
+        matches = [r for r in self.allCurveRows if r.name == name]
+        if not matches:
+            raise ValueError("no curveRow named %r. only %s" %
+                             (name, [r.name for r in self.allCurveRows]))
+        return matches[0]
+
+    def goLive(self):
+        """for startup performance, none of the curves redraw
+        themselves until this is called once (and then they're normal)"""
+
+        for cr in self.allCurveRows:
+            cr.curveView.goLive()
+
+    def onDelete(self):
+        for r in self.allCurveRows:
+            r.onDelete()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/musicaccess.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+import json
+from louie import dispatcher
+from rdflib import URIRef
+from light9 import networking
+from twisted.internet import reactor
+from twisted.web.client import Agent
+from twisted.internet.protocol import Protocol
+from twisted.internet.defer import Deferred
+from zope.interface import implements
+from twisted.internet.defer import succeed
+from twisted.web.iweb import IBodyProducer
+
+
+class GatherJson(Protocol):
+    """calls back the 'finished' deferred with the parsed json data we
+    received"""
+
+    def __init__(self, finished):
+        self.finished = finished
+        self.buf = ""
+
+    def dataReceived(self, bytes):
+        self.buf += bytes
+
+    def connectionLost(self, reason):
+        self.finished.callback(json.loads(self.buf))
+
+
+class StringProducer(object):
+    # http://twistedmatrix.com/documents/current/web/howto/client.html
+    implements(IBodyProducer)
+
+    def __init__(self, body):
+        self.body = body
+        self.length = len(body)
+
+    def startProducing(self, consumer):
+        consumer.write(self.body)
+        return succeed(None)
+
+    def pauseProducing(self):
+        pass
+
+    def stopProducing(self):
+        pass
+
+
+class Music:
+
+    def __init__(self):
+        self.recenttime = 0
+        self.player = Agent(reactor)
+        self.timePath = networking.musicPlayer.path("time")
+
+    def current_time(self):
+        """return deferred which gets called with the current
+        time. This gets called really often"""
+        d = self.player.request("GET", self.timePath)
+        d.addCallback(self._timeReturned)
+        return d
+
+    def _timeReturned(self, response):
+        done = Deferred()
+        done.addCallback(self._bodyReceived)
+        response.deliverBody(GatherJson(done))
+        return done
+
+    def _bodyReceived(self, data):
+        if 't' in data:
+            dispatcher.send("input time", val=data['t'])
+        if 'song' in data and data['song']:
+            dispatcher.send("current_player_song", song=URIRef(data['song']))
+        return data['t']  # pass along to the real receiver
+
+    def playOrPause(self, t=None):
+        if t is None:
+            # could be better
+            self.current_time().addCallback(lambda t: self.playOrPause(t))
+        else:
+            self.player.request("POST",
+                                networking.musicPlayer.path("seekPlayOrPause"),
+                                bodyProducer=StringProducer(json.dumps({"t":
+                                                                        t})))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/output.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,72 @@
+import time, logging
+from twisted.internet import reactor
+from light9 import Submaster, dmxclient
+
+from louie import dispatcher
+log = logging.getLogger("output")
+
+
+class Output(object):
+    lastsendtime = 0
+    lastsendlevs = None
+
+    def __init__(self, graph, session, music, curveset, currentSubterms):
+        self.graph, self.session, self.music = graph, session, music
+        self.currentSubterms = currentSubterms
+        self.curveset = curveset
+
+        self.recent_t = []
+        self.later = None
+
+        self.update()
+
+    def update(self):
+        d = self.music.current_time()
+        d.addCallback(self.update2)
+        d.addErrback(self.updateerr)
+
+    def updateerr(self, e):
+
+        print(e.getTraceback())
+        dispatcher.send("update status", val=e.getErrorMessage())
+        if self.later and not self.later.cancelled and not self.later.called:
+            self.later.cancel()
+        self.later = reactor.callLater(1, self.update)
+
+    def update2(self, t):
+        # spot alsa soundcard offset is always 0, we get times about a
+        # second ahead of what's really getting played
+        #t = t - .7
+        dispatcher.send("update status",
+                        val="ok: receiving time from music player")
+        if self.later and not self.later.cancelled and not self.later.called:
+            self.later.cancel()
+
+        self.later = reactor.callLater(.02, self.update)
+
+        self.recent_t = self.recent_t[-50:] + [t]
+        period = (self.recent_t[-1] - self.recent_t[0]) / len(self.recent_t)
+        dispatcher.send("update period", val=period)
+        self.send_dmx(t)
+
+    def send_dmx(self, t):
+        dispatcher.send("curves to sliders", t=t)
+
+        if not self.currentSubterms:
+            return
+
+        scaledsubs = []
+        for st in self.currentSubterms:
+            scl = st.scaled(t)
+            scaledsubs.append(scl)
+
+        out = Submaster.sub_maxes(*scaledsubs)
+        levs = out.get_levels()
+        now = time.time()
+        if now - self.lastsendtime > 5 or levs != self.lastsendlevs:
+            dispatcher.send("output levels", val=levs)
+            dmxclient.outputlevels(out.get_dmx_list(),
+                                   twisted=1,
+                                   clientid='curvecalc')
+            self.lastsendtime = now
+            self.lastsendlevs = levs
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/subterm.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,144 @@
+import logging
+from rdflib import Literal
+from louie import dispatcher
+import light9.Effects
+from light9 import Submaster
+from light9.Patch import get_dmx_channel
+from rdfdb.patch import Patch
+from light9.namespaces import L9
+log = logging.getLogger()
+
+
+class Expr(object):
+    """singleton, provides functions for use in subterm expressions,
+    e.g. chases"""
+
+    def __init__(self):
+        self.effectGlobals = light9.Effects.configExprGlobals()
+
+    def exprGlobals(self, startDict, t):
+        """globals dict for use by expressions"""
+
+        glo = startDict.copy()
+
+        # add in functions from Effects
+        glo.update(self.effectGlobals)
+
+        def chan(name):
+            return Submaster.Submaster(name=name,
+                                       levels={get_dmx_channel(name): 1.0})
+
+        glo['chan'] = chan
+        glo['within'] = lambda a, b: a < t < b
+        glo['bef'] = lambda x: t < x
+
+        def aft(t, x, smooth=0):
+            left = x - smooth / 2
+            right = x + smooth / 2
+            if left < t < right:
+                return light9.Effects.smoove((t - left) / (right - left))
+            return t > x
+
+        glo['aft'] = lambda x, smooth=0: aft(t, x, smooth)
+
+        glo['smooth_random'] = lambda speed=1: glo['smooth_random2'](t, speed)
+        glo['notch_random'] = lambda speed=1: glo['notch_random2'](t, speed)
+
+        glo['noise'] = glo['smooth_random']
+        glo['notch'] = glo['notch_random']
+
+        return glo
+
+
+exprglo = Expr()
+
+
+class Subterm(object):
+    """one Submaster and its expression evaluator"""
+
+    def __init__(self, graph, subterm, saveContext, curveset):
+        self.graph, self.uri = graph, subterm
+        self.saveContext = saveContext
+        self.curveset = curveset
+        self.ensureExpression(saveContext)
+
+        self.submasters = Submaster.get_global_submasters(self.graph)
+
+    def ensureExpression(self, saveCtx):
+        with self.graph.currentState(tripleFilter=(self.uri, None,
+                                                   None)) as current:
+            if current.value(self.uri, L9['expression']) is None:
+                self.graph.patch(
+                    Patch(addQuads=[
+                        (self.uri, L9['expression'], Literal("..."), saveCtx),
+                    ]))
+
+    def scaled(self, t):
+        with self.graph.currentState(tripleFilter=(self.uri, None,
+                                                   None)) as current:
+            subexpr_eval = self.eval(current, t)
+            # we prevent any exceptions from escaping, since they cause us to
+            # stop sending levels
+            try:
+                if isinstance(subexpr_eval, Submaster.Submaster):
+                    # if the expression returns a submaster, just return it
+                    return subexpr_eval
+                else:
+                    # otherwise, return our submaster multiplied by the value
+                    # returned
+                    if subexpr_eval == 0:
+                        return Submaster.Submaster("zero", {})
+                    subUri = current.value(self.uri, L9['sub'])
+                    sub = self.submasters.get_sub_by_uri(subUri)
+                    return sub * subexpr_eval
+            except Exception as e:
+                dispatcher.send("expr_error", sender=self.uri, exc=repr(e))
+                return Submaster.Submaster(name='Error: %s' % str(e), levels={})
+
+    def curves_used_by_expr(self):
+        """names of curves that are (maybe) used in this expression"""
+
+        with self.graph.currentState(tripleFilter=(self.uri, None,
+                                                   None)) as current:
+            expr = current.value(self.uri, L9['expression'])
+
+        used = []
+        for name in self.curveset.curveNamesInOrder():
+            if name in expr:
+                used.append(name)
+        return used
+
+    def eval(self, current, t):
+        """current graph is being passed as an optimization. It should be
+        equivalent to use self.graph in here."""
+
+        objs = list(current.objects(self.uri, L9['expression']))
+        if len(objs) > 1:
+            raise ValueError("found multiple expressions for %s: %s" %
+                             (self.uri, objs))
+
+        expr = current.value(self.uri, L9['expression'])
+        if not expr:
+            dispatcher.send("expr_error",
+                            sender=self.uri,
+                            exc="no expr, using 0")
+            return 0
+        glo = self.curveset.globalsdict()
+        glo['t'] = t
+
+        glo = exprglo.exprGlobals(glo, t)
+        glo['getsub'] = lambda name: self.submasters.get_sub_by_name(name)
+        glo['chan'] = lambda name: Submaster.Submaster(
+            "chan", {get_dmx_channel(name): 1})
+
+        try:
+            self.lasteval = eval(expr, glo)
+        except Exception as e:
+            dispatcher.send("expr_error", sender=self.uri, exc=e)
+            return Submaster.Submaster("zero", {})
+        else:
+            dispatcher.send("expr_error", sender=self.uri, exc="ok")
+        return self.lasteval
+
+    def __repr__(self):
+        return "<Subterm %s>" % self.uri
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/subtermview.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,134 @@
+import logging
+from gi.repository import Gtk
+from louie import dispatcher
+from rdflib import Literal, URIRef
+from light9.namespaces import L9
+log = logging.getLogger()
+
+# inspired by http://www.daa.com.au/pipermail/pygtk/2008-August/015772.html
+# keeping a ref to the __dict__ of the object stops it from getting zeroed
+keep = []
+
+
+class Subexprview(object):
+
+    def __init__(self, graph, ownerSubterm, saveContext, curveset):
+        self.graph, self.ownerSubterm = graph, ownerSubterm
+        self.saveContext = saveContext
+        self.curveset = curveset
+
+        self.box = Gtk.HBox()
+
+        self.entryBuffer = Gtk.EntryBuffer("", -1)
+        self.entry = Gtk.Entry()
+        self.error = Gtk.Label("")
+
+        self.box.pack_start(self.entry, expand=True)
+        self.box.pack_start(self.error, expand=False)
+
+        self.entry.set_buffer(self.entryBuffer)
+        self.graph.addHandler(self.set_expression_from_graph)
+        self.entryBuffer.connect("deleted-text", self.entry_changed)
+        self.entryBuffer.connect("inserted-text", self.entry_changed)
+
+        self.entry.connect("focus-in-event", self.onFocus)
+
+        dispatcher.connect(self.exprError,
+                           "expr_error",
+                           sender=self.ownerSubterm)
+        keep.append(self.__dict__)
+
+    def onFocus(self, *args):
+        curveNames = self.curveset.curveNamesInOrder()
+        currentExpr = self.entryBuffer.get_text()
+
+        usedCurves = sorted([n for n in curveNames if n in currentExpr])
+
+        dispatcher.send("set_featured_curves", curveNames=usedCurves)
+
+    def exprError(self, exc):
+        self.error.set_text(str(exc))
+
+    def set_expression_from_graph(self):
+        e = str(self.graph.value(self.ownerSubterm, L9['expression']))
+        print("from graph, set to %r" % e)
+
+        if e != self.entryBuffer.get_text():
+            self.entryBuffer.set_text(e, len(e))
+
+    def entry_changed(self, *args):
+        log.info("want to patch to %r", self.entryBuffer.get_text())
+        self.graph.patchObject(self.saveContext, self.ownerSubterm,
+                               L9['expression'],
+                               Literal(self.entryBuffer.get_text()))
+
+
+class Subtermview(object):
+    """
+    has .label and .exprView widgets for you to put in a table
+    """
+
+    def __init__(self, st, curveset):
+        self.subterm = st
+        self.graph = st.graph
+
+        self.label = Gtk.Label("sub")
+        self.graph.addHandler(self.setName)
+
+        self.label.drag_dest_set(flags=Gtk.DEST_DEFAULT_ALL,
+                                 targets=[('text/uri-list', 0, 0)],
+                                 actions=Gtk.gdk.ACTION_COPY)
+        self.label.connect("drag-data-received", self.onDataReceivedOnLabel)
+
+        sev = Subexprview(self.graph, self.subterm.uri,
+                          self.subterm.saveContext, curveset)
+        self.exprView = sev.box
+
+    def onDataReceivedOnLabel(self, widget, context, x, y, selection,
+                              targetType, time):
+        self.graph.patchObject(self.subterm.saveContext, self.subterm.uri,
+                               L9['sub'], URIRef(selection.data.strip()))
+
+    def setName(self):
+        # some of this could be pushed into Submaster
+        sub = self.graph.value(self.subterm.uri, L9['sub'])
+        if sub is None:
+            tail = self.subterm.uri.rsplit('/', 1)[-1]
+            self.label.set_text("no sub (%s)" % tail)
+            return
+        label = self.graph.label(sub)
+        if label is None:
+            self.label.set_text("sub %s has no label" % sub)
+            return
+        self.label.set_text(label)
+
+
+def add_one_subterm(subterm, curveset, master, show=False):
+    stv = Subtermview(subterm, curveset)
+
+    y = master.get_property('n-rows')
+    master.attach(stv.label, 0, 1, y, y + 1, xoptions=0, yoptions=0)
+    master.attach(stv.exprView, 1, 2, y, y + 1, yoptions=0)
+    scrollToRowUponAdd(stv.label)
+    if show:
+        master.show_all()
+
+
+def scrollToRowUponAdd(widgetInRow):
+    """when this table widget is ready, scroll the table so we can see it"""
+
+    # this doesn't work right, yet
+    return
+
+    vp = widgetInRow
+    while vp.get_name() != 'GtkViewport':
+        log.info("walk %s", vp.get_name())
+        vp = vp.get_parent()
+    adj = vp.props.vadjustment
+
+    def firstExpose(widget, event, adj, widgetInRow):
+        log.info("scroll %s", adj.props.value)
+        adj.props.value = adj.props.upper
+        widgetInRow.disconnect(handler)
+
+    handler = widgetInRow.connect('expose-event', firstExpose, adj, widgetInRow)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/curvecalc/zoomcontrol.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,380 @@
+from gi.repository import GooCanvas
+import louie as dispatcher
+from light9.curvecalc import cursors
+from lib.goocanvas_compat import Points, polyline_new_line
+from twisted.internet import reactor
+
+
+class ZoomControl(object):
+    """
+    please pack .widget
+    """
+
+    mintime = 0
+
+    def maxtime():
+        doc = "seconds at the right edge of the bar"
+
+        def fget(self):
+            return self._maxtime
+
+        def fset(self, value):
+            self._maxtime = value
+            self.redrawzoom()
+
+        return locals()
+
+    maxtime = property(**maxtime())
+
+    _end = _start = 0
+
+    def start():
+
+        def fget(self):
+            return self._start
+
+        def fset(self, v):
+            v = max(self.mintime, v)
+            # don't protect for start<end since zooming sometimes sets
+            # start temporarily after end
+            self._start = v
+
+        return locals()
+
+    start = property(**start())
+
+    def end():
+
+        def fget(self):
+            return self._end
+
+        def fset(self, v):
+            v = min(self.maxtime, v)
+            self._end = v
+
+        return locals()
+
+    end = property(**end())
+
+    def offset():
+        doc = "virtual attr that adjusts start and end together"
+
+        def fget(self):
+            # work off the midpoint so that "crushing works equally
+            # well in both directions
+            return (self.start + self.end) / 2
+
+        def fset(self, value):
+            d = self.end - self.start
+            self.start = value - d / 2
+            self.end = value + d / 2
+
+        return locals()
+
+    offset = property(**offset())
+
+    def __init__(self, **kw):
+        self.widget = GooCanvas.Canvas(bounds_padding=5)
+        self.widget.set_property("background-color", "gray60")
+        self.widget.set_size_request(-1, 30)
+        self.widget.props.x2 = 2000
+
+        endtimes = dispatcher.send("get max time")
+        if endtimes:
+            self.maxtime = endtimes[0][1]
+        else:
+            self.maxtime = 0
+
+        self.start = 0
+        self.end = 250
+
+        self.root = self.widget.get_root_item()
+        self.leftbrack = polyline_new_line(parent=self.root,
+                                           line_width=5,
+                                           stroke_color='black')
+        self.rightbrack = polyline_new_line(parent=self.root,
+                                            line_width=5,
+                                            stroke_color='black')
+        self.shade = GooCanvas.CanvasRect(parent=self.root,
+                                          fill_color='gray70',
+                                          line_width=.5)
+        self.time = polyline_new_line(parent=self.root,
+                                      line_width=2,
+                                      stroke_color='red')
+
+        self.redrawzoom()
+        self.widget.connect("size-allocate", self.redrawzoom)
+
+        self.widget.connect("motion-notify-event", self.adjust)
+        self.widget.connect("button-release-event", self.release)
+        self.leftbrack.connect(
+            "button-press-event", lambda i, t, ev: self.press(ev, 'start'))
+        self.rightbrack.connect(
+            "button-press-event", lambda i, t, ev: self.press(ev, 'end'))
+        self.shade.connect(
+            "button-press-event", lambda i, t, ev: self.press(ev, 'offset'))
+
+        dispatcher.connect(self.input_time, "input time")
+        dispatcher.connect(self.max_time, "max time")
+        dispatcher.connect(self.zoom_about_mouse, "zoom about mouse")
+        dispatcher.connect(self.see_time, "see time")
+        dispatcher.connect(self.see_time_until_end, "see time until end")
+        dispatcher.connect(self.show_all, "show all")
+        dispatcher.connect(self.zoom_to_range, "zoom to range")
+        self.created = 1
+        self.lastTime = 0
+
+    def max_time(self, maxtime):
+        self.maxtime = maxtime
+        self.redrawzoom()
+
+    def zoom_to_range(self, start, end):
+        self.start = start
+        self.end = end
+        self.redrawzoom()
+
+    def show_all(self):
+        self.start = self.mintime
+        self.end = self.maxtime
+        self.redrawzoom()
+
+    def zoom_about_mouse(self, t, factor):
+        self.start = t - factor * (t - self.start)
+        self.end = t + factor * (self.end - t)
+        self.redrawzoom()
+
+    def see_time(self, t=None):
+        """defaults to current time"""
+        if t is None:
+            t = self.lastTime
+        vis_seconds = self.end - self.start
+        # note that setting self.offset positions the time in the
+        # *middle*.
+        margin = vis_seconds * -.4
+        if t < self.start or t > (self.end - vis_seconds * .6):
+            self.offset = t - margin
+
+        self.redrawzoom()
+
+    def see_time_until_end(self, t=None):
+        """defaults to current time"""
+        if t is None:
+            t = self.lastTime
+        self.start = t - 2
+        self.end = self.maxtime
+
+        self.redrawzoom()
+
+    def input_time(self, val):
+        """move time cursor to this time"""
+        self.lastTime = val
+        try:
+            x = self.can_for_t(self.lastTime)
+        except ZeroDivisionError:
+            x = -100
+        self.time.set_property("points", Points([(x, 0),
+                                                 (x, self.size.height)]))
+
+    def press(self, ev, attr):
+        self.adjustingattr = attr
+
+    def release(self, widget, ev):
+        if hasattr(self, 'adjustingattr'):
+            del self.adjustingattr
+        if hasattr(self, 'lastx'):
+            del self.lastx
+
+    def adjust(self, widget, ev):
+
+        if not hasattr(self, 'adjustingattr'):
+            return
+        attr = self.adjustingattr
+
+        if not hasattr(self, 'lastx'):
+            self.lastx = ev.x
+        new = self.can_for_t(getattr(self, attr)) + (ev.x - self.lastx)
+        self.lastx = ev.x
+        setattr(self, attr, self.t_for_can(new))
+        self.redrawzoom()
+
+    def can_for_t(self, t):
+        a, b = self.mintime, self.maxtime
+        return (t - a) / (b - a) * (self.size.width - 30) + 20
+
+    def t_for_can(self, x):
+        a, b = self.mintime, self.maxtime
+        return (x - 20) / (self.size.width - 30) * (b - a) + a
+
+    def redrawzoom(self, *args):
+        # often, this was clearing the zoom widget and not repainting right
+        reactor.callLater(0, self._redrawzoom)
+
+    def _redrawzoom(self):
+        """redraw pieces based on start/end"""
+        self.size = self.widget.get_allocation()
+        dispatcher.send("zoom changed")
+        if not hasattr(self, 'created'):
+            return
+        y1, y2 = 3, self.size.height - 3
+        lip = 6
+        try:
+            scan = self.can_for_t(self.start)
+            ecan = self.can_for_t(self.end)
+        except ZeroDivisionError:
+            # todo: set the zoom to some clear null state
+            return
+
+        self.leftbrack.set_property(
+            "points",
+            Points([(scan + lip, y1), (scan, y1), (scan, y2),
+                    (scan + lip, y2)]))
+        self.rightbrack.set_property(
+            "points",
+            Points([(ecan - lip, y1), (ecan, y1), (ecan, y2),
+                    (ecan - lip, y2)]))
+        self.shade.set_properties(x=scan + 5,
+                                  y=y1 + lip,
+                                  width=max(0, ecan - 5 - (scan + 5)),
+                                  height=max(0, y2 - lip - (y1 + lip)))
+
+        self.redrawTics()
+
+    def redrawTics(self):
+        if hasattr(self, 'ticsGroup'):
+            self.ticsGroup.remove()
+        self.ticsGroup = GooCanvas.CanvasGroup(parent=self.root)
+
+        lastx = -1000
+
+        for t in range(0, int(self.maxtime)):
+            x = self.can_for_t(t)
+            if 0 < x < self.size.width and x - lastx > 30:
+                txt = str(t)
+                if lastx == -1000:
+                    txt = txt + "sec"
+                GooCanvas.CanvasPolyline(parent=self.ticsGroup,
+                                         points=Points([(x, 0), (x, 15)]),
+                                         line_width=.8,
+                                         stroke_color='black')
+                GooCanvas.CanvasText(parent=self.ticsGroup,
+                                     x=x,
+                                     y=self.size.height - 1,
+                                     anchor=GooCanvas.CanvasAnchorType.SOUTH,
+                                     text=txt,
+                                     font='ubuntu 7')
+                lastx = x
+
+
+class RegionZoom:
+    """rigs c-a-b1 to drag out an area to zoom to. also catches other types of drag events, like b1 drag for selecting points
+
+    this is used with Curveview
+    """
+
+    def __init__(self, canvas, world_from_screen, screen_from_world):
+        self.canvas, self.world_from_screen = canvas, world_from_screen
+        self.screen_from_world = screen_from_world
+
+        for evtype, method in [("ButtonPress-1", self.press),
+                               ("Motion", self.motion),
+                               ("ButtonRelease-1", self.release)]:
+            #canvas.bind("<Control-Alt-%s>" % evtype, method, add=True)
+            if 1 or evtype != "ButtonPress-1":
+                canvas.bind("<%s>" % evtype, method, add=True)
+
+        canvas.bind("<Leave>", self.finish)
+        self.start_t = self.old_cursor = None
+        self.state = self.mods = None
+
+    def press(self, ev):
+        if self.state is not None:
+            self.finish()
+
+        if ev.state == 12:
+            self.mods = "c-a"
+        elif ev.state == 13:
+            # todo: right now this never happens because only the
+            # sketching handler gets the event
+            self.mods = "c-s-a"
+        elif ev.state == 0:
+            return  # no
+            self.mods = "none"
+        else:
+            return
+        self.state = "buttonpress"
+
+        self.start_t = self.end_t = self.world_from_screen(ev.x, 0)[0]
+        self.start_x = ev.x
+        can = self.canvas
+
+        for pos in ('start_t', 'end_t', 'hi', 'lo'):
+            can.create_line(0,
+                            0,
+                            50,
+                            50,
+                            width=3,
+                            fill='yellow',
+                            tags=("regionzoom", pos))
+        # if updatelines isn't called here, subsequent updatelines
+        # will fail for reasons i don't understand
+        self.updatelines()
+
+        # todo: just holding the modifiers ought to turn on the zoom
+        # cursor (which is not finished)
+        cursors.push(can, "@light9/cursor1.xbm")
+
+    def updatelines(self):
+
+        # better would be a gray25 rectangle over the region
+
+        can = self.canvas
+        pos_x = {}
+        height = can.winfo_height()
+        for pos in ('start_t', 'end_t'):
+            pos_x[pos] = x = self.screen_from_world((getattr(self, pos), 0))[0]
+            cid = can.find_withtag("regionzoom && %s" % pos)
+            can.coords(cid, x, 0, x, height)
+
+        for tag, frac in [('hi', .1), ('lo', .9)]:
+            cid = can.find_withtag("regionzoom && %s" % tag)
+            can.coords(cid, pos_x['start_t'], frac * height, pos_x['end_t'],
+                       frac * height)
+
+    def motion(self, ev):
+        if self.state != "buttonpress":
+            return
+
+        self.end_t = self.world_from_screen(ev.x, 0)[0]
+        self.updatelines()
+
+    def release(self, ev):
+        if self.state != "buttonpress":
+            return
+
+        if abs(self.start_x - ev.x) < 10:
+            # clicked
+            if self.mods in ("c-a", "c-s-a"):
+                factor = 1 / 1.5
+                if self.mods == "c-s-a":
+                    factor = 1.5  # c-s-a-b1 zooms out
+                dispatcher.send("zoom about mouse",
+                                t=self.start_t,
+                                factor=factor)
+
+            self.finish()
+            return
+
+        start, end = min(self.start_t, self.end_t), max(self.start_t,
+                                                        self.end_t)
+        if self.mods == "c-a":
+            dispatcher.send("zoom to range", start=start, end=end)
+        elif self.mods == "none":
+            dispatcher.send("select between", start=start, end=end)
+        self.finish()
+
+    def finish(self, *ev):
+        if self.state is not None:
+            self.state = None
+            self.canvas.delete("regionzoom")
+            self.start_t = None
+            cursors.pop(self.canvas)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/dmxchanedit.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,243 @@
+"""
+
+widget to show all dmx channel levels and allow editing. levels might
+not actually match what dmxserver is outputting.
+
+proposal for new focus and edit system:
+- rows can be selected
+- the chan number or label can be used to select rows. dragging over rows brings all of them into or out of the current selection
+- numbers drag up and down (like today)
+- if you drag a number in a selected row, all the selected numbers change
+- if you start dragging a number in an unselected row, your row becomes the new selection and then the edit works
+
+
+proposal for new attribute system:
+- we always want to plan some attributes for each light: where to center; what stage to cover; what color gel to apply; whether the light is burned out
+- we have to stop packing these into the names. Names should be like 'b33' or 'blue3' or just '44'. maybe 'blacklight'.
+
+"""
+
+import tkinter as tk
+from rdflib import RDF
+import math, logging
+from decimal import Decimal
+from light9.namespaces import L9
+log = logging.getLogger('dmxchanedit')
+stdfont = ('Arial', 7)
+
+
+def gradient(lev, low=(80, 80, 180), high=(255, 55, 50)):
+    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
+    col = "#%02X%02X%02X" % tuple(out)
+    return col
+
+
+class Onelevel(tk.Frame):
+    """a name/level pair
+
+    source data is like this:
+        ch:b11-c     a :Channel;
+         :output dmx:c54;
+         rdfs:label "b11-c" .
+
+    and the level is like this:
+
+       ?editor :currentSub ?sub .
+       ?sub :lightLevel [:channel ?ch; :level ?level] .
+
+    levels come in with self.setTo and go out by the onLevelChange
+    callback. This object does not use the graph for level values,
+    which I'm doing for what I think is efficiency. Unclear why I
+    didn't use Observable for that API.
+    """
+
+    def __init__(self, parent, graph, channelUri, onLevelChange):
+        tk.Frame.__init__(self, parent, height=20)
+        self.graph = graph
+        self.onLevelChange = onLevelChange
+        self.uri = channelUri
+        self.currentLevel = 0  # the level we're displaying, 0..1
+
+        # no statement yet
+        self.channelnum = int(
+            self.graph.value(self.uri, L9['output']).rsplit('/c')[-1])
+
+        # 3 widgets, left-to-right:
+
+        # channel number -- will turn yellow when being altered
+        self.num_lab = tk.Label(self,
+                                text=str(self.channelnum),
+                                width=3,
+                                bg='grey40',
+                                fg='white',
+                                font=stdfont,
+                                padx=0,
+                                pady=0,
+                                bd=0,
+                                height=1)
+        self.num_lab.pack(side='left')
+
+        # text description of channel
+        self.desc_lab = tk.Label(self,
+                                 width=14,
+                                 font=stdfont,
+                                 anchor='w',
+                                 padx=0,
+                                 pady=0,
+                                 bd=0,
+                                 height=1,
+                                 bg='black',
+                                 fg='white')
+        self.graph.addHandler(self.updateLabel)
+        self.desc_lab.pack(side='left')
+
+        # current level of channel, shows intensity with color
+        self.level_lab = tk.Label(self,
+                                  width=3,
+                                  bg='lightBlue',
+                                  anchor='e',
+                                  font=stdfont,
+                                  padx=1,
+                                  pady=0,
+                                  bd=0,
+                                  height=1)
+        self.level_lab.pack(side='left')
+
+        self.setupmousebindings()
+
+    def updateLabel(self):
+        self.desc_lab.config(text=self.graph.label(self.uri))
+
+    def setupmousebindings(self):
+
+        def b1down(ev):
+            self.desc_lab.config(bg='cyan')
+            self._start_y = ev.y
+            self._start_lev = self.currentLevel
+
+        def b1motion(ev):
+            delta = self._start_y - ev.y
+            self.setlevel(max(0, min(1, self._start_lev + delta * .005)))
+
+        def b1up(ev):
+            self.desc_lab.config(bg='black')
+
+        def b3up(ev):
+            self.setlevel(0.0)
+
+        def b3down(ev):
+            self.setlevel(1.0)
+
+        def b2down(ev):  # same thing for now
+            self.setlevel(1.0)
+
+        # make the buttons work in the child windows
+        for w in self.winfo_children():
+            for e, func in (('<ButtonPress-1>',
+                             b1down), ('<B1-Motion>',
+                                       b1motion), ('<ButtonRelease-1>', b1up),
+                            ('<ButtonPress-2>',
+                             b2down), ('<ButtonRelease-3>',
+                                       b3up), ('<ButtonPress-3>', b3down)):
+
+                w.bind(e, func)
+
+    def colorlabel(self):
+        """color the level label based on its own text (which is 0..100)"""
+        txt = self.level_lab['text'] or "0"
+        lev = float(txt) / 100
+        self.level_lab.config(bg=gradient(lev))
+
+    def setlevel(self, newlev):
+        """UI received a level change, which we put in the graph"""
+        self.onLevelChange(self.uri, newlev)
+
+    def setTo(self, newLevel):
+        """levelbox saw a change in the graph"""
+        self.currentLevel = min(1, max(0, newLevel))
+        newLevel = "%d" % (self.currentLevel * 100)
+        olddisplay = self.level_lab.cget('text')
+        if newLevel != olddisplay:
+            self.level_lab.config(text=newLevel)
+            self.colorlabel()
+
+
+class Levelbox(tk.Frame):
+    """
+    this also watches all the levels in the sub and sets the boxes when they change
+    """
+
+    def __init__(self, parent, graph, currentSub):
+        """
+        currentSub is an Observable(PersistentSubmaster)
+        """
+        tk.Frame.__init__(self, parent)
+
+        self.currentSub = currentSub
+        self.graph = graph
+        graph.addHandler(self.updateChannels)
+
+        self.currentSub.subscribe(lambda _: graph.addHandler(self.
+                                                             updateLevelValues))
+
+    def updateChannels(self):
+        """(re)make Onelevel boxes for the defined channels"""
+
+        [ch.destroy() for ch in self.winfo_children()]
+        self.levelFromUri = {}  # channel : OneLevel
+
+        chans = list(self.graph.subjects(RDF.type, L9.Channel))
+        chans.sort(
+            key=lambda c: int(self.graph.value(c, L9.output).rsplit('/c')[-1]))
+        cols = 2
+        rows = int(math.ceil(len(chans) / cols))
+
+        def make_frame(parent):
+            f = tk.Frame(parent, bd=0, bg='black')
+            f.pack(side='left')
+            return f
+
+        columnFrames = [make_frame(self) for x in range(cols)]
+
+        for i, channel in enumerate(chans):  # sort?
+            # frame for this channel
+            f = Onelevel(columnFrames[i // rows], self.graph, channel,
+                         self.onLevelChange)
+
+            self.levelFromUri[channel] = f
+            f.pack(side='top')
+
+    def updateLevelValues(self):
+        """set UI level from graph"""
+        submaster = self.currentSub()
+        if submaster is None:
+            return
+        sub = submaster.uri
+        if sub is None:
+            raise ValueError("currentSub is %r" % submaster)
+
+        remaining = set(self.levelFromUri.keys())
+        for ll in self.graph.objects(sub, L9['lightLevel']):
+            chan = self.graph.value(ll, L9['channel'])
+            try:
+                lev = self.graph.value(ll, L9['level']).toPython()
+            except AttributeError as e:
+                log.error('on lightlevel %r:', ll)
+                log.exception(e)
+                continue
+            if isinstance(lev, Decimal):
+                lev = float(lev)
+            assert isinstance(lev, (int, float)), repr(lev)
+            try:
+                self.levelFromUri[chan].setTo(lev)
+                remaining.remove(chan)
+            except KeyError as e:
+                log.exception(e)
+        for channel in remaining:
+            self.levelFromUri[channel].setTo(0)
+
+    def onLevelChange(self, chan, newLevel):
+        """UI received a change which we put in the graph"""
+        if self.currentSub() is None:
+            raise ValueError("no currentSub in Levelbox")
+        self.currentSub().editLevel(chan, newLevel)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/dmxclient.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,76 @@
+""" module for clients to use for easy talking to the dmx
+server. sending levels is now a simple call to
+dmxclient.outputlevels(..)
+
+client id is formed from sys.argv[0] and the PID.  """
+
+import xmlrpc.client, os, sys, socket, time, logging
+from twisted.internet import defer
+from txzmq import ZmqEndpoint, ZmqFactory, ZmqPushConnection
+import json
+
+from light9 import networking
+_dmx = None
+log = logging.getLogger('dmxclient')
+
+procname = os.path.basename(sys.argv[0])
+procname = procname.replace('.py', '')
+_id = "%s-%s-%s" % (procname, socket.gethostname(), os.getpid())
+
+
+class TwistedZmqClient(object):
+
+    def __init__(self, service):
+        zf = ZmqFactory()
+        e = ZmqEndpoint('connect', 'tcp://%s:%s' % (service.host, service.port))
+
+        class Push(ZmqPushConnection):
+            pass  # highWaterMark = 3000
+
+        self.conn = Push(zf, e)
+
+    def send(self, clientid, levellist):
+        self.conn.push(
+            json.dumps({
+                'clientid': clientid,
+                'levellist': levellist
+            }))
+
+
+def outputlevels(levellist, twisted=0, clientid=_id):
+    """present a list of dmx channel levels, each scaled from
+    0..1. list can be any length- it will apply to the first len() dmx
+    channels.
+
+    if the server is not found, outputlevels will block for a
+    second."""
+
+    global _dmx, _id
+
+    if _dmx is None:
+        url = networking.dmxServer.url
+        if not twisted:
+            _dmx = xmlrpc.client.Server(url)
+        else:
+            _dmx = TwistedZmqClient(networking.dmxServerZmq)
+
+    if not twisted:
+        try:
+            _dmx.outputlevels(clientid, levellist)
+        except socket.error as e:
+            log.error("dmx server error %s, waiting" % e)
+            time.sleep(1)
+        except xmlrpc.client.Fault as e:
+            log.error("outputlevels had xml fault: %s" % e)
+            time.sleep(1)
+    else:
+        _dmx.send(clientid, levellist)
+        return defer.succeed(None)
+
+
+dummy = os.getenv('DMXDUMMY')
+if dummy:
+    print("dmxclient: DMX is in dummy mode.")
+
+    def outputlevels(*args, **kw):  # noqa
+        pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/editchoice.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,118 @@
+import tkinter as tk
+from rdflib import URIRef
+from light9.tkdnd import dragSourceRegister, dropTargetRegister
+
+
+class Local(object):
+    """placeholder for the local uri that EditChoice does not
+    manage. Set resourceObservable to Local to indicate that you're
+    unlinked"""
+
+
+class EditChoice(object):
+    """
+    Observable <-> linker UI
+
+    widget for tying some UI to a shared resource for editing, or
+    unlinking it (which means associating it with a local resource
+    that's not named or shared). This object does not own the choice
+    of resource; the caller does.
+
+    UI actions:
+    - drag a uri on here to make it the one we're editing
+
+    - button to clear the currentSub (putting it back to
+      sessionLocalSub, and also resetting sessionLocalSub to be empty
+      again)
+
+    - drag the sub uri off of here to send it to another receiver,
+      but, if we're in local mode, the local sub should not be so
+      easily addressable. Maybe you just can't drag it off.
+
+
+    Todo:
+
+    - filter by type so you can't drag a curve onto a subcomposer
+
+    - 'save new' : make a new sub: transfers the current data (from a shared sub or
+      from the local one) to the new sub. If you're on a local sub,
+      the new sub is named automatically, ideally something brief,
+      pretty distinct, readable, and based on the lights that are
+      on. If you're on a named sub, the new one starts with a
+      'namedsub 2' style name. The uri can also be with a '2' suffix,
+      although maybe that will be stupid. If you change the name
+      before anyone knows about this uri, we could update the current
+      sub's uri to a slug of the new label.
+
+    - rename this sub: not available if you're on a local sub. Sets
+      the label of a named sub. Might update the uri of the named sub
+      if it's new enough that no one else would have that uri. Not
+      sure where we measure that 'new enough' value. Maybe track if
+      the sub has 'never been dragged out of this subcomposer
+      session'? But subs will also show up in other viewers and
+      finders.
+
+    - list of recent resources that this choice was set to
+    """
+
+    def __init__(self, parent, graph, resourceObservable, label="Editing:"):
+        """
+        getResource is called to get the URI of the currently
+        """
+        self.graph = graph
+        self.frame = tk.Frame(parent, relief='raised', border=2)
+        self.frame.pack(side='top')
+        tk.Label(self.frame, text=label).pack(side='left')
+        self.currentLinkFrame = tk.Frame(self.frame)
+        self.currentLinkFrame.pack(side='left')
+
+        self.subIcon = tk.Label(self.currentLinkFrame,
+                                text="...",
+                                borderwidth=2,
+                                relief='raised',
+                                padx=10,
+                                pady=10)
+        self.subIcon.pack()
+
+        self.resourceObservable = resourceObservable
+        resourceObservable.subscribe(self.uriChanged)
+
+        # when the value is local, this should stop being a drag source
+        dragSourceRegister(self.subIcon, 'copy', 'text/uri-list',
+                           self.resourceObservable)
+
+        def onEv(ev):
+            self.resourceObservable(URIRef(ev.data))
+            return "link"
+
+        self.onEv = onEv
+
+        b = tk.Button(self.frame, text="Unlink", command=self.switchToLocalSub)
+        b.pack(side='left')
+
+        # it would be nice if I didn't receive my own drags here, and
+        # if the hover display wasn't per widget
+        for target in ([self.frame, self.currentLinkFrame] +
+                       self.frame.winfo_children() +
+                       self.currentLinkFrame.winfo_children()):
+            dropTargetRegister(target,
+                               typeList=["*"],
+                               onDrop=onEv,
+                               hoverStyle=dict(background="#555500"))
+
+    def uriChanged(self, newUri):
+        # if this resource had a type icon or a thumbnail, those would be
+        # cool to show in here too
+        if newUri is Local:
+            self.subIcon.config(text="(local)")
+        else:
+            self.graph.addHandler(self.updateLabel)
+
+    def updateLabel(self):
+        uri = self.resourceObservable()
+        print("get label", repr(uri))
+        label = self.graph.label(uri)
+        self.subIcon.config(text=label or uri)
+
+    def switchToLocalSub(self):
+        self.resourceObservable(Local)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/editchoicegtk.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,99 @@
+import logging
+from gi.repository import Gtk
+from gi.repository import Gdk
+from rdflib import URIRef
+log = logging.getLogger('editchoicegtk')
+
+
+class Local(object):
+    """placeholder for the local uri that EditChoice does not
+    manage. Set resourceObservable to Local to indicate that you're
+    unlinked"""
+
+
+class EditChoice(Gtk.HBox):
+    """
+    this is a gtk port of editchoice.EditChoice
+    """
+
+    def __init__(self, graph, resourceObservable, label="Editing:"):
+        """
+        getResource is called to get the URI of the currently
+        """
+        self.graph = graph
+
+        # the outer box should have a distinctive border so it's more
+        # obviously a special drop target
+        Gtk.HBox.__init__(self)
+        self.pack_start(Gtk.Label(label), False, True, 0)  #expand, fill, pad
+
+        # this is just a label, but it should look like a physical
+        # 'thing' (and gtk labels don't work as drag sources)
+        self.currentLink = Gtk.Button("http://bar")
+
+        self.pack_start(self.currentLink, True, True, 0)  #expand, fill, pad
+
+        self.unlinkButton = Gtk.Button(label="Unlink")
+        self.pack_start(self.unlinkButton, False, True, 0)  #expand, fill pad
+
+        self.unlinkButton.connect("clicked", self.onUnlink)
+
+        self.show_all()
+
+        self.resourceObservable = resourceObservable
+        resourceObservable.subscribe(self.uriChanged)
+
+        self.makeDragSource()
+        self.makeDropTarget()
+
+    def makeDropTarget(self):
+
+        def ddr(widget, drag_context, x, y, selection_data, info, timestamp):
+            dtype = selection_data.get_data_type()
+            if dtype.name() not in ['text/uri-list', 'TEXT']:
+                raise ValueError("unknown DnD selection type %r" % dtype)
+            data = selection_data.get_data().strip()
+            log.debug('drag_data_received data=%r', data)
+            self.resourceObservable(URIRef(data))
+
+        self.currentLink.drag_dest_set(
+            flags=Gtk.DestDefaults.ALL,
+            targets=[
+                Gtk.TargetEntry.new('text/uri-list', 0, 0),
+                Gtk.TargetEntry.new('TEXT', 0,
+                                    0),  # getting this from chrome :(
+            ],
+            actions=Gdk.DragAction.LINK | Gdk.DragAction.COPY)
+        self.currentLink.connect("drag_data_received", ddr)
+
+    def makeDragSource(self):
+        self.currentLink.drag_source_set(
+            start_button_mask=Gdk.ModifierType.BUTTON1_MASK,
+            targets=[
+                Gtk.TargetEntry.new(target='text/uri-list', flags=0, info=0)
+            ],
+            actions=Gdk.DragAction.LINK | Gdk.DragAction.COPY)
+
+        def source_drag_data_get(btn, context, selection_data, info, time):
+            selection_data.set_uris([self.resourceObservable()])
+
+        self.currentLink.connect("drag_data_get", source_drag_data_get)
+
+    def uriChanged(self, newUri):
+        # if this resource had a type icon or a thumbnail, those would be
+        # cool to show in here too
+        if newUri is Local:
+            self.currentLink.set_label("(local)")
+            self.currentLink.drag_source_unset()
+        else:
+            self.graph.addHandler(self.updateLabel)
+            self.makeDragSource()
+        self.unlinkButton.set_sensitive(newUri is not Local)
+
+    def updateLabel(self):
+        uri = self.resourceObservable()
+        label = self.graph.label(uri)
+        self.currentLink.set_label(label or uri or "")
+
+    def onUnlink(self, *args):
+        self.resourceObservable(Local)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/edit.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,205 @@
+from rdflib import URIRef, Literal
+from twisted.internet.defer import inlineCallbacks, returnValue
+import treq
+
+from light9 import networking
+from light9.curvecalc.curve import CurveResource
+from light9.namespaces import L9, RDF, RDFS
+from rdfdb.patch import Patch
+
+
+def clamp(x, lo, hi):
+    return max(lo, min(hi, x))
+
+
+@inlineCallbacks
+def getMusicStatus():
+    resp = yield treq.get(networking.musicPlayer.path('time'), timeout=.5)
+    body = yield resp.json_content()
+    returnValue(body)
+
+
+@inlineCallbacks
+def songEffectPatch(graph, dropped, song, event, ctx):
+    """
+    some uri was 'dropped' in the curvecalc timeline. event is 'default' or 'start' or 'end'.
+    """
+    with graph.currentState(tripleFilter=(dropped, None, None)) as g:
+        droppedTypes = list(g.objects(dropped, RDF.type))
+        droppedLabel = g.label(dropped)
+        droppedCodes = list(g.objects(dropped, L9['code']))
+
+    quads = []
+    fade = 2 if event == 'default' else 0
+
+    if _songHasEffect(graph, song, dropped):
+        # bump the existing curve
+        pass
+    else:
+        effect, q = _newEffect(graph, song, ctx)
+        quads.extend(q)
+
+        curve = graph.sequentialUri(song + "/curve-")
+        yield _newEnvelopeCurve(graph, ctx, curve, droppedLabel, fade)
+        quads.extend([
+            (song, L9['curve'], curve, ctx),
+            (effect, RDFS.label, droppedLabel, ctx),
+            (effect, L9['code'], Literal('env = %s' % curve.n3()), ctx),
+        ])
+
+        if L9['EffectClass'] in droppedTypes:
+            quads.extend([
+                (effect, RDF.type, dropped, ctx),
+            ] + [(effect, L9['code'], c, ctx) for c in droppedCodes])
+        elif L9['Submaster'] in droppedTypes:
+            quads.extend([
+                (effect, L9['code'], Literal('out = %s * env' % dropped.n3()),
+                 ctx),
+            ])
+        else:
+            raise NotImplementedError(
+                "don't know how to add an effect from %r (types=%r)" %
+                (dropped, droppedTypes))
+
+        _maybeAddMusicLine(quads, effect, song, ctx)
+
+    print("adding")
+    for qq in quads:
+        print(qq)
+    returnValue(Patch(addQuads=quads))
+
+
+@inlineCallbacks
+def songNotePatch(graph, dropped, song, event, ctx, note=None):
+    """
+    drop into effectsequencer timeline
+
+    ported from timeline.coffee makeNewNote
+    """
+    with graph.currentState(tripleFilter=(dropped, None, None)) as g:
+        droppedTypes = list(g.objects(dropped, RDF.type))
+
+    quads = []
+    fade = 2 if event == 'default' else 0.1
+
+    if note:
+        musicStatus = yield getMusicStatus()
+        songTime = musicStatus['t']
+        _finishCurve(graph, note, quads, ctx, songTime)
+    else:
+        if L9['Effect'] in droppedTypes:
+            musicStatus = yield getMusicStatus()
+            songTime = musicStatus['t']
+            note = _makeNote(graph, song, note, quads, ctx, dropped, songTime,
+                             event, fade)
+        else:
+            raise NotImplementedError
+
+    returnValue((note, Patch(addQuads=quads)))
+
+
+def _point(ctx, uri, t, v):
+    return [(uri, L9['time'], Literal(round(t, 3)), ctx),
+            (uri, L9['value'], Literal(round(v, 3)), ctx)]
+
+
+def _finishCurve(graph, note, quads, ctx, songTime):
+    with graph.currentState() as g:
+        origin = g.value(note, L9['originTime']).toPython()
+        curve = g.value(note, L9['curve'])
+
+    pt2 = graph.sequentialUri(curve + 'p')
+    pt3 = graph.sequentialUri(curve + 'p')
+    quads.extend([(curve, L9['point'], pt2, ctx)] +
+                 _point(ctx, pt2, songTime - origin, 1) +
+                 [(curve, L9['point'], pt3, ctx)] +
+                 _point(ctx, pt3, songTime - origin + .5, 0))
+
+
+def _makeNote(graph, song, note, quads, ctx, dropped, songTime, event, fade):
+    note = graph.sequentialUri(song + '/n')
+    curve = graph.sequentialUri(note + 'c')
+    quads.extend([
+        (song, L9['note'], note, ctx),
+        (note, RDF.type, L9['Note'], ctx),
+        (note, L9['curve'], curve, ctx),
+        (note, L9['effectClass'], dropped, ctx),
+        (note, L9['originTime'], Literal(songTime), ctx),
+        (curve, RDF.type, L9['Curve'], ctx),
+        (curve, L9['attr'], L9['strength'], ctx),
+    ])
+    if event == 'default':
+        coords = [(0 - fade, 0), (0, 1), (20, 1), (20 + fade, 0)]
+    elif event == 'start':
+        coords = [
+            (0 - fade, 0),
+            (0, 1),
+        ]
+    elif event == 'end':  # probably unused- goes to _finishCurve instead
+        coords = [(20, 1), (20 + fade, 0)]
+    else:
+        raise NotImplementedError(event)
+    for t, v in coords:
+        pt = graph.sequentialUri(curve + 'p')
+        quads.extend([(curve, L9['point'], pt, ctx)] + _point(ctx, pt, t, v))
+    return note
+
+
+def _songHasEffect(graph, song, uri):
+    """does this song have an effect of class uri or a sub curve for sub
+    uri? this should be simpler to look up."""
+    return False  # todo
+
+
+def musicCurveForSong(uri):
+    return URIRef(uri + 'music')
+
+
+def _newEffect(graph, song, ctx):
+    effect = graph.sequentialUri(song + "/effect-")
+    quads = [
+        (song, L9['effect'], effect, ctx),
+        (effect, RDF.type, L9['Effect'], ctx),
+    ]
+    print("_newEffect", effect, quads)
+    return effect, quads
+
+
+@inlineCallbacks
+def _newEnvelopeCurve(graph, ctx, uri, label, fade=2):
+    """this does its own patch to the graph"""
+
+    cr = CurveResource(graph, uri)
+    cr.newCurve(ctx, label=Literal(label))
+    yield _insertEnvelopePoints(cr.curve, fade)
+    cr.saveCurve()
+
+
+@inlineCallbacks
+def _insertEnvelopePoints(curve, fade=2):
+    # wrong: we might not be adding to the currently-playing song.
+    musicStatus = yield getMusicStatus()
+    songTime = musicStatus['t']
+    songDuration = musicStatus['duration']
+
+    t1 = clamp(songTime - fade, .1, songDuration - .1 * 2) + fade
+    t2 = clamp(songTime + 20, t1 + .1, songDuration)
+
+    curve.insert_pt((t1 - fade, 0))
+    curve.insert_pt((t1, 1))
+    curve.insert_pt((t2, 1))
+    curve.insert_pt((t2 + fade, 0))
+
+
+def _maybeAddMusicLine(quads, effect, song, ctx):
+    """
+    add a line getting the current music into 'music' if any code might
+    be mentioning that var
+    """
+
+    for spoc in quads:
+        if spoc[1] == L9['code'] and 'music' in spoc[2]:
+            quads.extend([(effect, L9['code'],
+                           Literal('music = %s' % musicCurveForSong(song).n3()),
+                           ctx)])
+            break
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effect_function_library.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,67 @@
+"""repo of the EffectFunctions in the graph. Includes URI->realPythonFunction"""
+import logging
+from dataclasses import dataclass, field
+from typing import Callable, List, Optional, cast
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDF, RDFS, Literal
+
+from light9.namespaces import FUNC, L9
+from light9.newtypes import EffectAttr, EffectFunction, VTUnion
+from light9.typedgraph import typedValue
+
+from . import effect_functions
+
+log = logging.getLogger('effectfuncs')
+
+
+@dataclass
+class _EffectFunctionInput:
+    effectAttr: EffectAttr
+    defaultValue: Optional[VTUnion]
+
+
+@dataclass
+class _RdfEffectFunction:
+    uri: EffectFunction
+    label: Optional[Literal]
+    inputs: List[_EffectFunctionInput]
+
+
+@dataclass
+class EffectFunctionLibrary:
+    """parses :EffectFunction structures"""
+    graph: SyncedGraph
+
+    funcs: List[_RdfEffectFunction] = field(default_factory=list)
+
+    def __post_init__(self):
+        self.graph.addHandler(self._compile)
+
+    def _compile(self):
+        self.funcs = []
+        for subj in self.graph.subjects(RDF.type, L9['EffectFunction']):
+            label = typedValue(Literal | None, self.graph, subj, RDFS.label)
+            inputs = []
+            for inp in self.graph.objects(subj, L9['input']):
+                inputs.append(
+                    _EffectFunctionInput(  #
+                        typedValue(EffectAttr, self.graph, inp, L9['effectAttr']),  #
+                        typedValue(VTUnion | None, self.graph, inp, L9['defaultValue'])))
+
+            self.funcs.append(_RdfEffectFunction(cast(EffectFunction, subj), label, inputs))
+
+    def getFunc(self, uri: EffectFunction) -> Callable:
+        return {
+            FUNC['scale']: effect_functions.effect_scale,
+            FUNC['strobe']: effect_functions.effect_strobe,
+        }[uri]
+
+    def getDefaultValue(self, uri: EffectFunction, attr: EffectAttr) -> VTUnion:
+        for f in self.funcs:
+            if f.uri == uri:
+                for i in f.inputs:
+                    if i.effectAttr == attr:
+                        if i.defaultValue is not None:
+                            return i.defaultValue
+        raise ValueError(f'no default for {uri} {attr}')
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effect_function_library_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,38 @@
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.mock_syncedgraph import MockSyncedGraph
+
+PREFIXES = '''
+@prefix : <http://light9.bigasterisk.com/> .
+@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
+@prefix effect: <http://light9.bigasterisk.com/effect/> .
+@prefix func: <http://light9.bigasterisk.com/effectFunction/> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+'''
+
+GRAPH = PREFIXES + '''
+
+    func:scale
+        a :EffectFunction;
+        rdfs:label "a submaster- scales :deviceSettings";
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :deviceSettings; ] . # e.g. "par2 at color=red; par3 at color=white"
+
+    func:strobe
+        a :EffectFunction;
+        rdfs:label "blink specified devices";
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :period; :defaultValue 0.5 ],
+            [ :effectAttr :onTime; :defaultValue 0.1 ],
+            [ :effectAttr :deviceSettings ] .
+'''
+
+
+class TestParsesGraph:
+
+    def test(self):
+        g = MockSyncedGraph(GRAPH)
+        lib = EffectFunctionLibrary(g)
+        assert len(lib.funcs) == 2
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effect_functions.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,63 @@
+import logging
+import random
+
+from PIL import Image
+from webcolors import rgb_to_hex
+
+from light9.effect.scale import scale
+from light9.effect.settings import DeviceSettings
+from light9.namespaces import L9
+
+random.seed(0)
+
+log = logging.getLogger('effectfunc')
+
+
+def sample8(img, x, y, repeat=False):
+    if not (0 <= y < img.height):
+        return (0, 0, 0)
+    if 0 <= x < img.width:
+        return img.getpixel((x, y))
+    elif not repeat:
+        return (0, 0, 0)
+    else:
+        return img.getpixel((x % img.width, y))
+
+
+def effect_scale(strength: float, devs: DeviceSettings) -> DeviceSettings:
+    out = []
+    if strength != 0:
+        for d, da, v in devs.asList():
+            out.append((d, da, scale(v, strength)))
+    return DeviceSettings(devs.graph, out)
+
+
+def effect_strobe(
+        songTime: float,  #
+        strength: float,
+        period: float,
+        onTime: float,
+        devs: DeviceSettings) -> DeviceSettings:
+    if period == 0:
+        scl = 0
+    else:
+        scl = strength if (songTime % period) < onTime else 0
+    return effect_scale(scl, devs)
+
+
+def effect_image(
+    songTime: float,  #
+    strength: float,
+    period: float,
+    image: Image.Image,
+    devs: DeviceSettings,
+) -> DeviceSettings:
+    x = int((songTime / period) * image.width)
+    out = []
+    for y, (d, da, v) in enumerate(devs.asOrderedList()):
+        if da != L9['color']:
+            continue
+        color8 = sample8(image, x, y, repeat=True)
+        color = rgb_to_hex(tuple(color8))
+        out.append((d, da, scale(color, strength * v)))
+    return DeviceSettings(devs.graph, out)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effecteval.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,466 @@
+import logging
+import math
+import random
+from colorsys import hsv_to_rgb
+
+from noise import pnoise1
+from PIL import Image
+from rdflib import Literal, Namespace
+from webcolors import hex_to_rgb, rgb_to_hex
+
+from light9.effect.scale import scale
+from light9.namespaces import DEV, L9
+
+SKY = Namespace('http://light9.bigasterisk.com/theater/skyline/device/')
+
+random.seed(0)
+
+log = logging.getLogger('effecteval')
+log.info("reload effecteval")
+
+
+def literalColor(rnorm, gnorm, bnorm):
+    return Literal(rgb_to_hex((
+        int(rnorm * 255),  #
+        int(gnorm * 255),  #
+        int(bnorm * 255))))
+
+
+def literalColorHsv(h, s, v):
+    return literalColor(*hsv_to_rgb(h, s, v))
+
+
+def nsin(x):
+    return (math.sin(x * (2 * math.pi)) + 1) / 2
+
+
+def ncos(x):
+    return (math.cos(x * (2 * math.pi)) + 1) / 2
+
+
+def nsquare(t, on=.5):
+    return (t % 1.0) < on
+
+
+def lerp(a, b, t):
+    return a + (b - a) * t
+
+
+def noise(t):
+    return pnoise1(t % 1000.0, 2)
+
+
+def clamp(lo, hi, x):
+    return max(lo, min(hi, x))
+
+
+def clamp255(x):
+    return min(255, max(0, x))
+
+
+def _8bit(f):
+    if not isinstance(f, (int, float)):
+        raise TypeError(repr(f))
+    return clamp255(int(f * 255))
+
+
+def effect_Curtain(effectSettings, strength, songTime, noteTime):
+    return {(L9['device/lowPattern%s' % n], L9['color']): literalColor(strength, strength, strength) for n in range(301, 308 + 1)}
+
+
+def effect_animRainbow(effectSettings, strength, songTime, noteTime):
+    out = {}
+    tint = effectSettings.get(L9['tint'], '#ffffff')
+    tintStrength = float(effectSettings.get(L9['tintStrength'], 0))
+    tr, tg, tb = hex_to_rgb(tint)
+    for n in range(1, 5 + 1):
+        scl = strength * nsin(songTime + n * .3)**3
+        col = literalColor(scl * lerp(nsin(songTime + n * .2), tr / 255, tintStrength), scl * lerp(nsin(songTime + n * .2 + .3), tg / 255, tintStrength),
+                           scl * lerp(nsin(songTime + n * .3 + .6), tb / 255, tintStrength))
+
+        dev = L9['device/aura%s' % n]
+        out.update({
+            (dev, L9['color']): col,
+            (dev, L9['zoom']): .9,
+        })
+        ang = songTime * 4
+        out.update({
+            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4) + .2 * math.sin(ang + n),
+            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4) + .5 * math.cos(ang + n),
+        })
+    return out
+
+
+def effect_auraSparkles(effectSettings, strength, songTime, noteTime):
+    out = {}
+    tint = effectSettings.get(L9['tint'], '#ffffff')
+    print(effectSettings)
+    tr, tg, tb = hex_to_rgb(tint)
+    for n in range(1, 5 + 1):
+        scl = strength * ((int(songTime * 10) % n) < 1)
+        col = literalColorHsv((songTime + (n / 5)) % 1, 1, scl)
+
+        dev = L9['device/aura%s' % n]
+        out.update({
+            (dev, L9['color']): col,
+            (dev, L9['zoom']): .95,
+        })
+        ang = songTime * 4
+        out.update({
+            (dev, L9['rx']): lerp(.27, .8, (n - 1) / 4) + .2 * math.sin(ang + n),
+            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4) + .4 * math.cos(ang + n),
+        })
+    return out
+
+
+def effect_qpan(effectSettings, strength, songTime, noteTime):
+    dev = L9['device/q2']
+    dur = 4
+    col = scale(scale('#ffffff', strength), effectSettings.get(L9['colorScale']) or '#ffffff')
+    return {
+        (dev, L9['color']): col,
+        (dev, L9['focus']): 0.589,
+        (dev, L9['rx']): lerp(0.778, 0.291, clamp(0, 1, noteTime / dur)),
+        (dev, L9['ry']): 0.5,
+        (dev, L9['zoom']): 0.714,
+    }
+
+
+def effect_pulseRainbow(effectSettings, strength, songTime, noteTime):
+    out = {}
+    tint = effectSettings.get(L9['tint'], '#ffffff')
+    tintStrength = float(effectSettings.get(L9['tintStrength'], 0))
+    tr, tg, tb = hex_to_rgb(tint)
+    for n in range(1, 5 + 1):
+        scl = strength
+        col = literalColor(scl * lerp(nsin(songTime + n * .2), tr / 255, tintStrength), scl * lerp(nsin(songTime + n * .2 + .3), tg / 255, tintStrength),
+                           scl * lerp(nsin(songTime + n * .3 + .6), tb / 255, tintStrength))
+
+        dev = L9['device/aura%s' % n]
+        out.update({
+            (dev, L9['color']): col,
+            (dev, L9['zoom']): .5,
+        })
+        out.update({
+            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4),
+            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4),
+        })
+    return out
+
+
+def effect_aurawash(effectSettings, strength, songTime, noteTime):
+    out = {}
+    scl = strength
+    period = float(effectSettings.get(L9['period'], 125 / 60 / 4))
+    if period < .05:
+        quantTime = songTime
+    else:
+        quantTime = int(songTime / period) * period
+    noisePos = quantTime * 6.3456
+
+    col = literalColorHsv(noise(noisePos), 1, scl)
+    col = scale(col, effectSettings.get(L9['colorScale']) or '#ffffff')
+
+    print(songTime, quantTime, col)
+
+    for n in range(1, 5 + 1):
+        dev = L9['device/aura%s' % n]
+        out.update({
+            (dev, L9['color']): col,
+            (dev, L9['zoom']): .5,
+        })
+        out.update({
+            (dev, L9['rx']): lerp(.27, .7, (n - 1) / 4),
+            (dev, L9['ry']): lerp(.46, .52, (n - 1) / 4),
+        })
+    return out
+
+
+def effect_qsweep(effectSettings, strength, songTime, noteTime):
+    out = {}
+    period = float(effectSettings.get(L9['period'], 2))
+
+    col = effectSettings.get(L9['colorScale'], '#ffffff')
+    col = scale(col, effectSettings.get(L9['strength'], 1))
+
+    for n in range(1, 3 + 1):
+        dev = L9['device/q%s' % n]
+        out.update({
+            (dev, L9['color']): col,
+            (dev, L9['zoom']): effectSettings.get(L9['zoom'], .5),
+        })
+        out.update({
+            (dev, L9['rx']): lerp(.3, .8, nsin(songTime / period + n / 4)),
+            (dev, L9['ry']): effectSettings.get(L9['ry'], .2),
+        })
+    return out
+
+
+def effect_qsweepusa(effectSettings, strength, songTime, noteTime):
+    out = {}
+    period = float(effectSettings.get(L9['period'], 2))
+
+    colmap = {
+        1: '#ff0000',
+        2: '#998888',
+        3: '#0050ff',
+    }
+
+    for n in range(1, 3 + 1):
+        dev = L9['device/q%s' % n]
+        out.update({
+            (dev, L9['color']): scale(colmap[n], effectSettings.get(L9['strength'], 1)),
+            (dev, L9['zoom']): effectSettings.get(L9['zoom'], .5),
+        })
+        out.update({
+            (dev, L9['rx']): lerp(.3, .8, nsin(songTime / period + n / 4)),
+            (dev, L9['ry']): effectSettings.get(L9['ry'], .5),
+        })
+    return out
+
+
+chase1_members = [
+    DEV['backlight1'],
+    DEV['lip1'],
+    DEV['backlight2'],
+    DEV['down2'],
+    DEV['lip2'],
+    DEV['backlight3'],
+    DEV['down3'],
+    DEV['lip3'],
+    DEV['backlight4'],
+    DEV['down4'],
+    DEV['lip4'],
+    DEV['backlight5'],
+    DEV['down5Edge'],
+    DEV['lip5'],
+    #DEV['upCenter'],
+]
+chase2_members = chase1_members * 10
+random.shuffle(chase2_members)
+
+
+def effect_chase1(effectSettings, strength, songTime, noteTime):
+    members = chase1_members + chase1_members[-2:0:-1]
+
+    out = {}
+    period = float(effectSettings.get(L9['period'], 2 / len(members)))
+
+    for i, dev in enumerate(members):
+        cursor = (songTime / period) % float(len(members))
+        dist = abs(i - cursor)
+        radius = 3
+        if dist < radius:
+            col = effectSettings.get(L9['colorScale'], '#ffffff')
+            col = scale(col, effectSettings.get(L9['strength'], 1))
+            col = scale(col, (1 - dist / radius))
+
+            out.update({
+                (dev, L9['color']): col,
+            })
+    return out
+
+
+def effect_chase2(effectSettings, strength, songTime, noteTime):
+    members = chase2_members
+
+    out = {}
+    period = float(effectSettings.get(L9['period'], 0.3))
+
+    for i, dev in enumerate(members):
+        cursor = (songTime / period) % float(len(members))
+        dist = abs(i - cursor)
+        radius = 3
+        if dist < radius:
+            col = effectSettings.get(L9['colorScale'], '#ffffff')
+            col = scale(col, effectSettings.get(L9['strength'], 1))
+            col = scale(col, (1 - dist / radius))
+
+            out.update({
+                (dev, L9['color']): col,
+            })
+    return out
+
+
+def effect_whirlscolor(effectSettings, strength, songTime, noteTime):
+    out = {}
+
+    col = effectSettings.get(L9['colorScale'], '#ffffff')
+    col = scale(col, effectSettings.get(L9['strength'], 1))
+
+    for n in (1, 3):
+        dev = L9['device/q%s' % n]
+        scl = strength
+        col = literalColorHsv(((songTime / 5) + (n / 5)) % 1, 1, scl)
+        out.update({
+            (dev, L9['color']): col,
+        })
+
+    return out
+
+
+def effect_orangeSearch(effectSettings, strength, songTime, noteTime):
+    dev = L9['device/auraStage']
+    return {
+        (dev, L9['color']): '#a885ff',
+        (dev, L9['rx']): lerp(.65, 1, nsin(songTime / 2.0)),
+        (dev, L9['ry']): .6,
+        (dev, L9['zoom']): 1,
+    }
+
+
+def effect_Strobe(effectSettings, strength, songTime, noteTime):
+    rate = 2
+    duty = .3
+    offset = 0
+    f = (((songTime + offset) * rate) % 1.0)
+    c = (f < duty) * strength
+    col = rgb_to_hex((int(c * 255), int(c * 255), int(c * 255)))
+    return {(L9['device/colorStrip'], L9['color']): Literal(col)}
+
+
+def effect_lightning(effectSettings, strength, songTime, noteTime):
+    devs = [
+        L9['device/veryLow1'], L9['device/veryLow2'], L9['device/veryLow3'], L9['device/veryLow4'], L9['device/veryLow5'], L9['device/backlight1'],
+        L9['device/backlight2'], L9['device/backlight3'], L9['device/backlight4'], L9['device/backlight5'], L9['device/down2'], L9['device/down3'],
+        L9['device/down4'], L9['device/hexLow3'], L9['device/hexLow5'], L9['device/postL1'], L9['device/postR1']
+    ]
+    out = {}
+    col = rgb_to_hex((int(255 * strength),) * 3)
+    for i, dev in enumerate(devs):
+        n = noise(songTime * 8 + i * 6.543)
+        if n > .4:
+            out[(dev, L9['color'])] = col
+    return out
+
+
+def sample8(img, x, y, repeat=False):
+    if not (0 <= y < img.height):
+        return (0, 0, 0)
+    if 0 <= x < img.width:
+        return img.getpixel((x, y))
+    elif not repeat:
+        return (0, 0, 0)
+    else:
+        return img.getpixel((x % img.width, y))
+
+
+def effect_image(effectSettings, strength, songTime, noteTime):
+    out = {}
+    imageSetting = effectSettings.get(L9["image"], 'specks.png')
+    imgPath = f'cur/anim/{imageSetting}'
+    t_offset = effectSettings.get(L9['tOffset'], 0)
+    pxPerSec = effectSettings.get(L9['pxPerSec'], 30)
+    img = Image.open(imgPath)
+    x = (noteTime * pxPerSec)
+
+    colorScale = hex_to_rgb(effectSettings.get(L9['colorScale'], '#ffffff'))
+
+    for dev, y in [
+        (SKY['strip1'], 0),
+        (SKY['strip2'], 1),
+        (SKY['strip3'], 2),
+        (SKY['par3'], 3),  # dl
+        (SKY['par4'], 4),  # ul
+        (SKY['par7'], 5),  # ur
+        (SKY['par1'], 6),  # dr
+        ('cyc1', 7),
+        ('cyc2', 8),
+        ('cyc3', 9),
+        ('cyc4', 10),
+        ('down1', 11),
+        ('down2', 12),
+        ('down3', 13),
+        ('down4', 14),
+        ('down5', 15),
+        ('down6', 16),
+        ('down7', 17),
+    ]:
+        color8 = sample8(img, x, y, effectSettings.get(L9['repeat'], True))
+        color = map(lambda v: v / 255 * strength, color8)
+        color = [v * cs / 255 for v, cs in zip(color, colorScale)]
+        if dev in ['cyc1', 'cyc2', 'cyc3', 'cyc4']:
+            column = dev[-1]
+            out[(SKY[f'cycRed{column}'], L9['brightness'])] = color[0]
+            out[(SKY[f'cycGreen{column}'], L9['brightness'])] = color[1]
+            out[(SKY[f'cycBlue{column}'], L9['brightness'])] = color[2]
+        else:
+            out[(dev, L9['color'])] = rgb_to_hex(tuple(map(_8bit, color)))
+    return out
+
+
+def effect_cyc(effectSettings, strength, songTime, noteTime):
+    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
+    r, g, b = map(lambda x: strength * x / 255, hex_to_rgb(colorScale))
+
+    out = {
+        (SKY['cycRed1'], L9['brightness']): r,
+        (SKY['cycRed2'], L9['brightness']): r,
+        (SKY['cycRed3'], L9['brightness']): r,
+        (SKY['cycRed4'], L9['brightness']): r,
+        (SKY['cycGreen1'], L9['brightness']): g,
+        (SKY['cycGreen2'], L9['brightness']): g,
+        (SKY['cycGreen3'], L9['brightness']): g,
+        (SKY['cycGreen4'], L9['brightness']): g,
+        (SKY['cycBlue1'], L9['brightness']): b,
+        (SKY['cycBlue2'], L9['brightness']): b,
+        (SKY['cycBlue3'], L9['brightness']): b,
+        (SKY['cycBlue4'], L9['brightness']): b,
+    }
+
+    return out
+
+
+cycChase1_members = [
+    SKY['cycRed1'],
+    SKY['cycRed2'],
+    SKY['cycRed3'],
+    SKY['cycRed4'],
+    SKY['cycGreen1'],
+    SKY['cycGreen2'],
+    SKY['cycGreen3'],
+    SKY['cycGreen4'],
+    SKY['cycBlue1'],
+    SKY['cycBlue2'],
+    SKY['cycBlue3'],
+    SKY['cycBlue4'],
+]
+cycChase1_members = cycChase1_members * 20
+random.shuffle(cycChase1_members)
+
+
+def effect_cycChase1(effectSettings, strength, songTime, noteTime):
+    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
+    r, g, b = map(lambda x: x / 255, hex_to_rgb(colorScale))
+    tintAmount = {'Red': r, 'Green': g, 'Blue': b}
+
+    members = cycChase1_members
+
+    out = {}
+    period = float(effectSettings.get(L9['period'], 6 / len(members)))
+
+    for i, dev in enumerate(members):
+        cursor = (songTime / period) % float(len(members))
+        dist = abs(i - cursor)
+        radius = 7
+        if dist < radius:
+            colorFromUri = str(dev).split('/')[-1].split('cyc')[1][:-1]
+            scale = strength * tintAmount[colorFromUri]
+            out.update({
+                (dev, L9['brightness']): (1 - dist / radius) * scale,
+            })
+    return out
+
+
+def effect_parNoise(effectSettings, strength, songTime, noteTime):
+    colorScale = effectSettings.get(L9['colorScale'], '#ffffff')
+    r, g, b = map(lambda x: x / 255, hex_to_rgb(colorScale))
+    out = {}
+    speed = 10
+    gamma = .6
+    for dev in [SKY['strip1'], SKY['strip2'], SKY['strip3']]:
+        out[(dev, L9['color'])] = scale(
+            rgb_to_hex((_8bit(r * math.pow(max(.01, noise(speed * songTime)), gamma)), _8bit(g * math.pow(max(.01, noise(speed * songTime + 10)), gamma)),
+                        _8bit(b * math.pow(max(.01, noise(speed * songTime + 20)), gamma)))), strength)
+
+    return out
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effecteval2.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,122 @@
+import traceback
+import inspect
+import logging
+from dataclasses import dataclass
+from typing import Callable, List, Optional
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDF
+from rdflib.term import Node
+
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.settings import DeviceSettings, EffectSettings
+from light9.namespaces import L9
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectFunction, EffectUri, VTUnion)
+from light9.typedgraph import typedValue
+
+log = logging.getLogger('effecteval')
+
+
+@dataclass
+class Config:
+    effectFunction: EffectFunction
+    esettings: EffectSettings
+    devSettings: Optional[DeviceSettings]  # the EffectSettings :effectAttr :devSettings item, if there was one
+    func: Callable
+    funcArgs: List[inspect.Parameter]
+
+
+@dataclass
+class EffectEval2:
+    """Runs one effect code to turn EffectSettings (e.g. strength) into DeviceSettings"""
+    graph: SyncedGraph
+    uri: EffectUri
+    lib: EffectFunctionLibrary
+
+    config: Optional[Config] = None
+
+    def __post_init__(self):
+        self.graph.addHandler(self._compile)
+
+    def _compile(self):
+        self.config = None
+        if not self.graph.contains((self.uri, RDF.type, L9['Effect'])):
+            return
+
+        try:
+            effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
+            effSets = []
+            devSettings = None
+            for s in self.graph.objects(self.uri, L9['setting']):
+                attr = typedValue(EffectAttr, self.graph, s, L9['effectAttr'])
+                if attr == L9['deviceSettings']:
+                    value = typedValue(Node, self.graph, s, L9['value'])
+
+                    rows = []
+                    for ds in self.graph.objects(value, L9['setting']):
+                        d = typedValue(DeviceUri, self.graph, ds, L9['device'])
+                        da = typedValue(DeviceAttr, self.graph, ds, L9['deviceAttr'])
+                        v = typedValue(VTUnion, self.graph, ds, L9['value'])
+                        rows.append((d, da, v))
+                    devSettings = DeviceSettings(self.graph, rows)
+                else:
+                    value = typedValue(VTUnion, self.graph, s, L9['value'])
+                    effSets.append((self.uri, attr, value))
+            esettings = EffectSettings(self.graph, effSets)
+
+            try:
+                effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
+            except ValueError:
+                raise ValueError(f'{self.uri} has no :effectFunction')
+            func = self.lib.getFunc(effectFunction)
+
+            # This should be in EffectFunctionLibrary
+            funcArgs = list(inspect.signature(func).parameters.values())
+
+            self.config = Config(effectFunction, esettings, devSettings, func, funcArgs)
+        except Exception:
+            log.error(f"while compiling {self.uri}")
+            traceback.print_exc()
+
+    def compute(self, songTime: float, inputs: EffectSettings) -> DeviceSettings:
+        """
+        calls our function using inputs (publishedAttr attrs, e.g. :strength)
+        and effect-level settings including a special attr called :deviceSettings
+        with DeviceSettings as its value
+        """
+        if self.config is None:
+            return DeviceSettings(self.graph, [])
+
+        c = self.config
+        kw = {}
+        for arg in c.funcArgs:
+            if arg.annotation == DeviceSettings:
+                v = c.devSettings
+                if v is None: # asked for ds but we have none
+                    log.debug("%s asked for devs but we have none in config", self.uri)
+                    return DeviceSettings(self.graph, [])
+            elif arg.name == 'songTime':
+                v = songTime
+            else:
+                eaForName = EffectAttr(L9[arg.name])
+                v = self._getEffectAttrValue(eaForName, inputs)
+
+            kw[arg.name] = v
+
+        if False and log.isEnabledFor(logging.DEBUG):
+            log.debug('calling %s with %s', c.func, kw)
+        return c.func(**kw)
+
+    def _getEffectAttrValue(self, attr: EffectAttr, inputs: EffectSettings) -> VTUnion:
+        c = self.config
+        if c is None:
+            raise
+        try:
+            return inputs.getValue(self.uri, attr, defaultToZero=False)
+        except KeyError:
+            pass
+        try:
+            return c.esettings.getValue(self.uri, attr, defaultToZero=False)
+        except KeyError:
+            pass
+        return self.lib.getDefaultValue(c.effectFunction, attr)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/effecteval_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,123 @@
+from typing import List, Tuple
+
+import pytest
+
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.effecteval2 import EffectEval2
+from light9.effect.settings import DeviceSettings, EffectSettings
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import DEV, L9
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectUri, HexColor, VTUnion)
+
+PREFIX = '''
+    @prefix : <http://light9.bigasterisk.com/> .
+    @prefix dev: <http://light9.bigasterisk.com/device/> .
+    @prefix effect: <http://light9.bigasterisk.com/effect/> .
+    @prefix func: <http://light9.bigasterisk.com/effectFunction/> .
+    @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+'''
+
+GRAPH = PREFIX + '''
+
+    func:scale
+        a :EffectFunction;
+        rdfs:label "a submaster- scales :deviceSettings";
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :deviceSettings; ] . # e.g. "par2 at color=red; par3 at color=white"
+
+    func:strobe
+        a :EffectFunction;
+        rdfs:label "blink specified devices";
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :period; :defaultValue 0.5 ],
+            [ :effectAttr :onTime; :defaultValue 0.1 ],
+            [ :effectAttr :deviceSettings ] .
+
+    func:image
+        a :EffectFunction;
+        rdfs:label "sample image at x=time";
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :period; :defaultValue 2.0 ],
+            [ :effectAttr :image; :defaultValue "specks.png" ],
+            [ :effectAttr :deviceSettings; rdfs:comment "these might have a :sort key or a :y value" ] .
+
+
+    :effectSub
+        a :Effect;
+        :effectFunction func:scale;
+        :publishAttr :strength;
+        :setting [ :effectAttr :deviceSettings; :value [
+                   :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ]  ] ].
+
+    :effectDefaultStrobe
+        a :Effect;
+        :effectFunction func:strobe;
+        :publishAttr :strength;
+        :setting [ :effectAttr :deviceSettings; :value [
+                   :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
+
+    :effectCustomStrobe
+        a :Effect;
+        :effectFunction func:strobe;
+        :publishAttr :strength;
+        :setting
+            [ :effectAttr :period; :value 3.0],
+            [ :effectAttr :onTime; :value 0.5],
+            [ :effectAttr :deviceSettings; :value [
+              :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
+'''
+
+effectSub = EffectUri(L9['effectSub'])
+effectDefaultStrobe = EffectUri(L9['effectDefaultStrobe'])
+effectCustomStrobe = EffectUri(L9['effectCustomStrobe'])
+
+
+def light1At(col: str) -> List[Tuple[DeviceUri, DeviceAttr, VTUnion]]:
+    return [(DeviceUri(DEV['light1']), DeviceAttr(L9['color']), HexColor(col))]
+
+
+@pytest.fixture
+def effectFunctions():
+    g = MockSyncedGraph(GRAPH)
+    return EffectFunctionLibrary(g)
+
+
+class TestEffectEval:
+
+    def test_scalesColors(self, effectFunctions):
+        g = effectFunctions.graph
+        ee = EffectEval2(g, effectSub, effectFunctions)
+        s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.5)])
+        ds = ee.compute(songTime=0, inputs=s)
+        assert ds == DeviceSettings(g, light1At('#7f0000'))
+
+    def test_cullsZeroOutputs(self, effectFunctions):
+        g = effectFunctions.graph
+        ee = EffectEval2(g, effectSub, effectFunctions)
+        s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.0)])
+        ds = ee.compute(songTime=0, inputs=s)
+        assert ds == DeviceSettings(g, [])
+
+    def test_strobeDefaults(self, effectFunctions):
+        g = effectFunctions.graph
+        ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
+        s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 1.0)])
+        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+        assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, [])
+
+    def strobeMultsStrength(self, effectFunctions):
+        g = effectFunctions.graph
+        ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
+        s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 0.5)])
+        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#7f0000'))
+
+    def test_strobeCustom(self, effectFunctions):
+        g = effectFunctions.graph
+        ee = EffectEval2(g, effectCustomStrobe, effectFunctions)
+        s = EffectSettings(g, [(effectCustomStrobe, EffectAttr(L9['strength']), 1.0)])
+        assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+        assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+        assert ee.compute(songTime=.6, inputs=s) == DeviceSettings(g, [])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/scale.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,27 @@
+from decimal import Decimal
+
+from webcolors import hex_to_rgb, rgb_to_hex
+
+from light9.newtypes import VTUnion
+
+
+def scale(value: VTUnion, strength: float):
+    if isinstance(value, Decimal):
+        raise TypeError()
+
+    if isinstance(value, str):
+        if value[0] == '#':
+            if strength == '#ffffff':
+                return value
+            r, g, b = hex_to_rgb(value)
+            # if isinstance(strength, Literal):
+            #     strength = strength.toPython()
+            # if isinstance(strength, str):
+            #     sr, sg, sb = [v / 255 for v in hex_to_rgb(strength)]
+            if True:
+                sr = sg = sb = strength
+            return rgb_to_hex((int(r * sr), int(g * sg), int(b * sb)))
+    elif isinstance(value, (int, float)):
+        return value * strength
+
+    raise NotImplementedError("%r,%r" % (value, strength))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/__init__.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,1 @@
+from .note import Note
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/eval_faders.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,111 @@
+import traceback
+import logging
+import time
+from dataclasses import dataclass
+from typing import List, Optional, cast
+
+from prometheus_client import Summary
+from rdfdb import SyncedGraph
+from rdflib import URIRef
+from rdflib.term import Node
+
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.effecteval2 import EffectEval2
+from light9.effect.settings import DeviceSettings, EffectSettings
+from light9.namespaces import L9, RDF
+from light9.newtypes import EffectAttr, EffectUri, UnixTime
+from light9.typedgraph import typedValue
+
+log = logging.getLogger('seq.fader')
+
+COMPILE = Summary('compile_graph_fader', 'compile')
+COMPUTE_ALL_FADERS = Summary('compute_all_faders', 'compile')
+
+
+@dataclass
+class Fader:
+    graph: SyncedGraph
+    lib: EffectFunctionLibrary
+    uri: URIRef
+    effect: EffectUri
+    setEffectAttr: EffectAttr
+
+    value: Optional[float] = None  # mutable
+
+    def __post_init__(self):
+        self.ee = EffectEval2(self.graph, self.effect, self.lib)
+
+
+class FaderEval:
+    """peer to Sequencer, but this one takes the current :Fader settings -> sendToCollector
+
+    """
+
+    def __init__(self, graph: SyncedGraph, lib: EffectFunctionLibrary):
+        self.graph = graph
+        self.lib = lib
+        self.faders: List[Fader] = []
+        self.grandMaster = 1.0
+
+        self.graph.addHandler(self._compile)
+        self.graph.addHandler(self._compileGm)
+
+    @COMPILE.time()
+    def _compile(self) -> None:
+        """rebuild our data from the graph"""
+        self.faders = []
+        for fader in self.graph.subjects(RDF.type, L9['Fader']):
+            try:
+                self.faders.append(self._compileFader(fader))
+            except ValueError:
+                pass
+
+        # this could go in a second, smaller addHandler call to avoid rebuilding Fader objs constantly
+        for f in self.faders:
+            f.value = None
+            try:
+                setting = typedValue(Node, self.graph, f.uri, L9['setting'])
+            except ValueError:
+                continue
+
+            try:
+                f.value = typedValue(float, self.graph, setting, L9['value'])
+            except ValueError:
+                continue
+
+    def _compileFader(self, fader: URIRef) -> Fader:
+        effect = typedValue(EffectUri, self.graph, fader, L9['effect'])
+        setting = typedValue(Node, self.graph, fader, L9['setting'])
+        setAttr = typedValue(EffectAttr, self.graph, setting, L9['effectAttr'])
+        return Fader(self.graph, self.lib, cast(URIRef, fader), effect, setAttr)
+
+    def _compileGm(self):
+        try:
+            self.grandMaster = typedValue(float, self.graph, L9.grandMaster, L9.value)
+        except ValueError:
+            return
+
+    @COMPUTE_ALL_FADERS.time()
+    def computeOutput(self) -> DeviceSettings:
+        faderEffectOutputs: List[DeviceSettings] = []
+        now = UnixTime(time.time())
+        for f in self.faders:
+            try:
+                if f.value is None:
+                    log.warning(f'{f.value=}; should be set during _compile. Skipping {f.uri}')
+                    continue
+                v = f.value
+                v *= self.grandMaster
+                effectSettings = EffectSettings(self.graph, [(f.effect, f.setEffectAttr, v)])
+
+                ds = f.ee.compute(now, effectSettings)
+                faderEffectOutputs.append(ds)
+            except Exception:
+                log.warning(f'on fader {f}')
+                traceback.print_exc()
+                continue
+
+        merged = DeviceSettings.merge(self.graph, faderEffectOutputs)
+        # please remove (after fixing stats display to show it)
+        log.debug("computed %s faders in %.1fms", len(self.faders), (time.time() - now) * 1000)
+        return merged
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/eval_faders_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,79 @@
+from unittest import mock
+
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.sequencer.eval_faders import FaderEval
+from light9.effect.settings import DeviceSettings
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9
+
+PREFIXES = '''
+@prefix : <http://light9.bigasterisk.com/> .
+@prefix effect: <http://light9.bigasterisk.com/effect/> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
+@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
+@prefix func: <http://light9.bigasterisk.com/effectFunction/> .
+'''
+
+NOTE_GRAPH = PREFIXES + '''
+    :brightness
+        a :DeviceAttr;
+        rdfs:label "brightness";
+        :dataType :scalar .
+
+    :strength
+        a :EffectAttr;
+        rdfs:label "strength" .
+
+    :SimpleDimmer
+        a :DeviceClass;
+        rdfs:label "SimpleDimmer";
+        :deviceAttr :brightness;
+        :attr [ :outputAttr :level; :dmxOffset 0 ] .
+
+    :light1
+        a :SimpleDimmer;
+        :dmxUniverse dmxA:;
+        :dmxBase 178 .
+
+
+    func:scale
+        a :EffectFunction;
+        :input
+            [ :effectAttr :strength; :defaultValue 0.0 ],
+            [ :effectAttr :deviceSettings; ] .
+
+
+    effect:effect1
+        a :Effect;
+        :effectFunction func:scale;
+        :setting [:effectAttr :deviceSettings; :value [
+            :setting [
+              :device :light1; :deviceAttr :brightness; :value 0.5
+            ]
+        ] ] .
+
+
+    :fade1
+        a :Fader;
+        :effect effect:effect1;
+        :setting :fs1 .
+    :fs1
+        :value 0.6 ;
+        :effectAttr :strength .
+
+        '''
+
+
+class TestFaderEval:
+
+    def test_faderValueScalesEffectSettings(self):
+        g = MockSyncedGraph(NOTE_GRAPH)
+        sender = mock.MagicMock()
+
+        eff = EffectFunctionLibrary(g)
+        f = FaderEval(g, eff)
+        devSettings = f.computeOutput()
+        assert devSettings == DeviceSettings(g, [(L9['light1'], L9['brightness'], 0.3)])
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/note.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,155 @@
+import bisect
+import logging
+import time
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple, Union, cast
+from light9.typedgraph import typedValue
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import Literal
+
+from light9.effect.settings import BareEffectSettings
+from light9.namespaces import L9
+from light9.newtypes import (Curve, EffectAttr, NoteUri, VTUnion)
+
+log = logging.getLogger('sequencer')
+
+
+def pyType(n):
+    ret = n.toPython()
+    if isinstance(ret, Decimal):
+        return float(ret)
+    return ret
+
+
+def prettyFormat(x: Union[float, str]):
+    if isinstance(x, float):
+        return round(x, 4)
+    return x
+
+
+@dataclass
+class Note:
+    """A Note outputs EffectAttr settings.
+
+    Sample graph:
+     :note1 a :Note; :curve :n1c1; :effectClass effect:allcolor;
+
+    It can animate the EffectAttr settings over time, in two ways:
+     * a `timed note` has an envelope curve that animates
+       the :strength EffectAttr over time
+     * an `untimed note` has no curve, a fixed strength, but
+       still passes the wall clock time to its effect so the
+       effect can include animation. A `Fader` is an untimed note.
+
+    This obj is immutable, I think, but the graph can change,
+    which can affect the output. However, I think this doesn't
+    do its own rebuilds, and it's up to the caller to addHandler
+    around the creation of Note objects.
+    """
+    graph: SyncedGraph
+    uri: NoteUri
+    # simpleOutputs: SimpleOutputs
+    timed: bool = True
+
+    def __post_init__(self):  # graph ok
+        ec = self.graph.value(self.uri, L9['effectClass'])
+        if ec is None:
+            raise ValueError(f'note {self.uri} has no :effectClass')
+        self.effectClass = EffectClass(ec)
+
+        self.baseEffectSettings = self.getBaseEffectSettings()
+
+        if self.timed:
+            originTime = typedValue(float, self.graph, self.uri, L9['originTime'])
+            self.points: List[Tuple[float, float]] = []
+            for curve in self.graph.objects(self.uri, L9['curve']):
+                self.points.extend(self.getCurvePoints(cast(Curve, curve), L9['strength'], originTime))
+            self.points.sort()
+        else:
+            self.points = []
+            self.value = typedValue(float, self.graph, self.uri, L9['value'])
+
+    def getBaseEffectSettings(self) -> BareEffectSettings:  # graph ok
+        """i think these are settings that are fixed over time,
+        e.g. that you set in the note's body in the timeline editor
+        """
+        out: Dict[EffectAttr, VTUnion] = {}
+        for s in self.graph.objects(self.uri, L9['setting']):
+            settingValues = dict(self.graph.predicate_objects(s))
+            ea = cast(EffectAttr, settingValues[L9['effectAttr']])
+            out[ea] = pyType(settingValues[L9['value']])
+        return BareEffectSettings(s=out)
+
+    def getCurvePoints(self, curve: Curve, attr, originTime: float) -> List[Tuple[float, float]]:
+        points = []
+        po = list(self.graph.predicate_objects(curve))
+        if dict(po).get(L9['attr'], None) != attr:
+            return []
+        for point in [row[1] for row in po if row[0] == L9['point']]:
+            po2 = dict(self.graph.predicate_objects(point))
+            t = cast(Literal, po2[L9['time']]).toPython()
+            if not isinstance(t, float):
+                raise TypeError
+
+            v = cast(Literal, po2[L9['value']]).toPython()
+            if not isinstance(v, float):
+                raise TypeError
+            points.append((originTime + t, v))
+        return points
+
+    def activeAt(self, t: float) -> bool:
+        return self.points[0][0] <= t <= self.points[-1][0]
+
+    def evalCurve(self, t: float) -> float:
+        i = bisect.bisect_left(self.points, (t, None)) - 1
+
+        if i == -1:
+            return self.points[0][1]
+        if self.points[i][0] > t:
+            return self.points[i][1]
+        if i >= len(self.points) - 1:
+            return self.points[i][1]
+
+        p1, p2 = self.points[i], self.points[i + 1]
+        frac = (t - p1[0]) / (p2[0] - p1[0])
+        y = p1[1] + (p2[1] - p1[1]) * frac
+        return y
+
+    def outputCurrent(self):  # no graph
+
+        return self._outputSettings(t=None, strength=self.value)
+
+    def _outputSettings(
+            self,
+            t: float | None,
+            strength: Optional[float] = None  #
+    ) -> Tuple[BareEffectSettings, Dict]:  # no graph
+
+        if t is None:
+            if self.timed:
+                raise TypeError()
+            t = time.time()  # so live effects will move
+        report: Dict[str, Any] = {
+            'note': str(self.uri),
+            'effectClass': str(self.effectClass),
+        }
+
+        s = self.evalCurve(t) if strength is None else strength
+        out = self.baseEffectSettings.withStrength(s)
+        report['effectSettings'] = dict((str(k), prettyFormat(v)) for k, v in sorted(out.s.items()))
+        report['nonZero'] = s > 0
+
+        return out, report
+
+        # old api had this
+
+        startTime = self.points[0][0] if self.timed else 0
+        out, evalReport = self.effectEval.outputFromEffect(
+            effectSettings,
+            songTime=t,
+            # note: not using origin here since it's going away
+            noteTime=t - startTime)
+        report['devicesAffected'] = len(out.devices())
+        return out, report
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/note_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,78 @@
+import pytest
+
+from light9.effect.sequencer import Note
+from light9.effect.settings import BareEffectSettings
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9
+from light9.newtypes import EffectAttr, NoteUri
+
+PREFIXES = '''
+@prefix : <http://light9.bigasterisk.com/> .
+@prefix effect: <http://light9.bigasterisk.com/effect/> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
+@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
+'''
+
+FADER_GRAPH = PREFIXES + '''
+    :fade1
+        a :Fader;
+        :effectClass effect:effect1;
+        :effectAttr :strength;
+        :value 0.6 .
+'''
+
+
+# class TestUntimedFaderNote:
+
+#     def test_returnsEffectSettings(self):
+#         g = MockSyncedGraph(FADER_GRAPH)
+#         n = Note(g, NoteUri(L9['fade1']), timed=False)
+#         out, report = n.outputCurrent()
+#         assert report['effectSettings'] == {'http://light9.bigasterisk.com/strength': 0.6}
+#         assert out == BareEffectSettings(s={EffectAttr(L9['strength']): 0.6})
+
+
+NOTE_GRAPH = PREFIXES + '''
+    :brightness
+        a :DeviceAttr;
+        rdfs:label "brightness";
+        :dataType :scalar .
+
+    :strength
+        a :EffectAttr;
+        rdfs:label "strength" .
+
+    :SimpleDimmer
+        a :DeviceClass;
+        rdfs:label "SimpleDimmer";
+        :deviceAttr :brightness;
+        :attr [ :outputAttr :level; :dmxOffset 0 ] .
+
+    dev:light1
+        a :SimpleDimmer;
+        :dmxUniverse dmxA:;
+        :dmxBase 178 .
+
+    effect:effect1
+        a :EffectClass;
+        :setting effect:effect1_set1 .
+    effect:effect1_set1
+        :device dev:light1;
+        :deviceAttr :brightness;
+        :scaledValue 0.5 .
+    :fade1
+        a :Fader;
+        :effectClass effect:effect1;
+        :effectAttr :strength;
+        :value 0.6 .
+        '''
+
+
+class TestTimedNote:
+
+    @pytest.mark.skip()
+    def test_scalesStrengthWithCurve(self):
+        pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/sequencer.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,168 @@
+'''
+copies from effectloop.py, which this should replace
+'''
+
+import asyncio
+import importlib
+import logging
+import time
+import traceback
+from typing import Callable, Coroutine, Dict, List, cast
+
+from louie import All, dispatcher
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import URIRef
+from twisted.internet import reactor
+from twisted.internet.inotify import INotify
+from twisted.python.filepath import FilePath
+
+from light9.ascoltami.musictime_client import MusicTime
+from light9.effect import effecteval
+from light9.effect.sequencer import Note
+from light9.effect.settings import DeviceSettings
+from light9.metrics import metrics
+from light9.namespaces import L9, RDF
+from light9.newtypes import NoteUri, Song
+
+log = logging.getLogger('sequencer')
+
+
+class StateUpdate(All):
+    pass
+
+
+class CodeWatcher:
+
+    def __init__(self, onChange):
+        self.onChange = onChange
+
+        self.notifier = INotify()
+        self.notifier.startReading()
+        self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc',
+                                                                 '.py')),
+                            callbacks=[self.codeChange])
+
+    def codeChange(self, watch, path, mask):
+
+        def go():
+            log.info("reload effecteval")
+            importlib.reload(effecteval)
+            self.onChange()
+
+        # in case we got an event at the start of the write
+        reactor.callLater(.1, go) # type: ignore
+
+
+class Sequencer:
+    """Notes from the graph + current song playback -> sendToCollector"""
+    def __init__(self,
+                 graph: SyncedGraph,
+                 sendToCollector: Callable[[DeviceSettings], Coroutine[None ,None,None]],
+                 fps=40,
+                 ):
+        self.graph = graph
+        self.fps = fps
+        metrics('update_loop_goal_fps').set(self.fps)
+        metrics('update_loop_goal_latency').set(1 / self.fps)
+        self.sendToCollector = sendToCollector
+        self.music = MusicTime(period=.2)
+
+        self.recentUpdateTimes: List[float] = []
+        self.lastStatLog = 0.0
+        self._compileGraphCall = None
+        self.notes: Dict[Song, List[Note]] = {}  # song: [notes]
+        self.simpleOutputs = SimpleOutputs(self.graph)
+        self.graph.addHandler(self.compileGraph)
+        self.lastLoopSucceeded = False
+
+        # self.codeWatcher = CodeWatcher(onChange=self.onCodeChange)
+        asyncio.create_task(self.updateLoop())
+
+    def onCodeChange(self):
+        log.debug('seq.onCodeChange')
+        self.graph.addHandler(self.compileGraph)
+        #self.updateLoop()
+
+    def compileGraph(self) -> None:
+        """rebuild our data from the graph"""
+        for song in self.graph.subjects(RDF.type, L9['Song']):
+
+            def compileSong(song: Song = cast(Song, song)) -> None:
+                self.compileSong(song)
+
+            self.graph.addHandler(compileSong)
+
+    def compileSong(self, song: Song) -> None:
+        anyErrors = False
+        self.notes[song] = []
+        for note in self.graph.objects(song, L9['note']):
+            try:
+                n = Note(self.graph, NoteUri(cast(NoteUri, note)))
+            except Exception:
+                log.warn(f"failed to build Note {note} - skipping")
+                anyErrors = True
+                continue
+            self.notes[song].append(n)
+        if not anyErrors:
+            log.info(f'built all notes for {song}')
+
+    async def updateLoop(self):
+        while True:
+            frameStart = time.time()
+            try:
+                sec = await self.update()
+            except Exception as e:
+                self.lastLoopSucceeded = False
+                traceback.print_exc()
+                log.warn('updateLoop: %r', e)
+                await asyncio.sleep(1)
+                continue
+            else:
+                took = time.time() - frameStart
+                metrics('update_loop_latency').observe(took)
+
+                if not self.lastLoopSucceeded:
+                    log.info('Sequencer.update is working')
+                    self.lastLoopSucceeded = True
+
+                delay = max(0, 1 / self.fps - took)
+                await asyncio.sleep(delay)
+                continue
+
+    async def update(self):
+        with metrics('update_s0_getMusic').time():
+            musicState = {'t':123.0,'song':'http://light9.bigasterisk.com/show/dance2019/song5'}#self.music.getLatest()
+            if not musicState.get('song') or not isinstance(
+                    musicState.get('t'), float):
+                return
+            song = Song(URIRef(musicState['song']))
+            # print('dispsend')
+            # import pdb;pdb.set_trace()
+            dispatcher.send(StateUpdate,
+                            update={
+                                'song': str(song),
+                                't': musicState['t']
+                            })
+
+        with metrics('update_s1_eval').time():
+            settings = []
+            songNotes = sorted(cast(List[Note], self.notes.get(song, [])), key=lambda n: n.uri)
+            noteReports = []
+            for note in songNotes:
+                try:
+                    s, report = note.outputSettings(musicState['t'])
+                except Exception:
+                    traceback.print_exc()
+                    raise
+                noteReports.append(report)
+                settings.append(s)
+            devSettings = DeviceSettings.fromList(self.graph, settings)
+        dispatcher.send(StateUpdate, update={'songNotes': noteReports})
+
+        with metrics('update_s3_send').time():  # our measurement
+            sendSecs = await self.sendToCollector(devSettings)
+
+        # sendToCollector's own measurement.
+        # (sometimes it's None, not sure why, and neither is mypy)
+        #if isinstance(sendSecs, float):
+        #    metrics('update_s3_send_client').observe(sendSecs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/service.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,85 @@
+"""
+plays back effect notes from the timeline (and an untimed note from the faders)
+"""
+
+import asyncio
+import json
+import logging
+import time
+
+from louie import dispatcher
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from sse_starlette.sse import EventSourceResponse
+from starlette.applications import Starlette
+from starlette.routing import Route
+from starlette_exporter import PrometheusMiddleware, handle_metrics
+
+from lib.background_loop import loop_forever
+from light9 import networking
+from light9.collector.collector_client_asyncio import sendToCollector
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.sequencer.eval_faders import FaderEval
+from light9.effect.sequencer.sequencer import Sequencer, StateUpdate
+from light9.run_local import log
+
+RATE = 20
+
+
+async def changes():
+    state = {}
+    q = asyncio.Queue()
+
+    def onBroadcast(update):
+        state.update(update)
+        q.put_nowait(None)
+
+    dispatcher.connect(onBroadcast, StateUpdate)
+
+    lastSend = 0
+    while True:
+        await q.get()
+        now = time.time()
+        if now > lastSend + .2:
+            lastSend = now
+            yield json.dumps(state)
+
+
+async def send_page_updates(request):
+    return EventSourceResponse(changes())
+
+
+def main():
+    graph = SyncedGraph(networking.rdfdb.url, "effectSequencer")
+    logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
+ 
+    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
+    logging.getLogger('syncedgraph').setLevel(logging.INFO)
+ 
+    logging.getLogger('effecteval').setLevel(logging.INFO)
+    logging.getLogger('seq.fader').setLevel(logging.INFO)
+
+    # seq = Sequencer(graph, send)  # per-song timed notes
+    lib = EffectFunctionLibrary(graph)
+    faders = FaderEval(graph, lib)  # bin/fade's untimed effects
+
+    #@metrics('computeAndSend').time() # needs rework with async
+    async def update(first_run):
+        ds = faders.computeOutput()
+        await sendToCollector('effectSequencer', session='0', settings=ds)
+
+    faders_loop = loop_forever(func=update, metric_prefix='faders', sleep_period=1 / RATE)
+
+    app = Starlette(
+        debug=True,
+        routes=[
+            Route('/updates', endpoint=send_page_updates),
+        ],
+    )
+
+    app.add_middleware(PrometheusMiddleware)
+    app.add_route("/metrics", handle_metrics)
+
+    return app
+
+
+app = main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/service_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,13 @@
+import asyncio
+
+from light9.run_local import log
+
+
+def test_import():
+
+    async def go():
+        # this sets up some watcher tasks
+        from light9.effect.sequencer.service import app
+        print(app)
+
+    asyncio.run(go(), debug=True)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/web/Light9SequencerUi.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,166 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "../../../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "../../../web/SyncedGraph";
+
+debug.enable("*");
+const log = debug("sequencer");
+
+interface Note {
+  note: string;
+  nonZero: boolean;
+  rowClass?: string; // added in message handler below
+  effectClass: string;
+  effectSettings: { [attr: string]: string };
+  effectSettingsPairs: EffectSettingsPair[]; // added in message handler below
+  devicesAffected: number;
+}
+interface Report {
+  song: string;
+  songUri: NamedNode; // added in message handler below
+  t: number;
+  roundT?: number; // added in message handler below
+  recentFps: number;
+  recentDeltas: number[];
+  recentDeltasStyle: string[]; // added in message handler below
+  songNotes: Note[];
+}
+interface EffectSettingsPair {
+  effectAttr: string;
+  value: any;
+}
+@customElement("light9-sequencer-ui")
+export class Light9SequencerUi extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+      }
+      td {
+        white-space: nowrap;
+        padding: 0 10px;
+        vertical-align: top;
+        vertical-align: top;
+        text-align: start;
+      }
+      tr.active {
+        background: #151515;
+      }
+      .inactive > * {
+        opacity: 0.5;
+      }
+      .effectSetting {
+        display: inline-block;
+        background: #1b1e21;
+        margin: 1px 3px;
+      }
+      .chart {
+        height: 40px;
+        background: #222;
+        display: inline-flex;
+        align-items: flex-end;
+      }
+      .chart > div {
+        background: #a4a54f;
+        width: 8px;
+        margin: 0 1px;
+      }
+      .number {
+        display: inline-block;
+        min-width: 4em;
+      }
+    `,
+  ];
+  render() {
+    return [
+      html` <rdfdb-synced-graph></rdfdb-synced-graph>
+
+        <h1>Sequencer <a href="metrics">[metrics]</a></h1>
+
+        <h2>Song</h2>`,
+      this.report
+        ? html`
+
+      <resource-display .uri=${this.graph.Uri(this.report.song)}"></resource-display>
+      t=${this.report.roundT}
+
+      <h3>Notes</h3>
+
+      <table>
+        <tr>
+          <th>Note</th>
+          <th>Effect class</th>
+          <th>Effect settings</th>
+          <th>Devices affected</th>
+        </tr>
+        ${this.report.songNotes.map(
+          (item: Note) => html`
+            <tr class="${item.rowClass}">
+              <td><resource-display .uri="${this.graph.Uri(item.note)}"></resource-display></td>
+              <td><resource-display .uri="${this.graph.Uri(item.effectClass)}"></resource-display></td>
+              <td>
+                ${item.effectSettingsPairs.map(
+                  (item) => html`
+                    <div>
+                      <span class="effectSetting">
+                        <resource-display .uri="${this.graph.Uri(item.effectAttr)}"></resource-display>:
+                        <span class="number">${item.value}</span>
+                      </span>
+                    </div>
+                  `
+                )}
+              </td>
+              <td>${item.devicesAffected}</td>
+            </tr>
+          `
+        )}
+      </table>
+    `
+        : html`waiting for first report...`,
+    ];
+  }
+
+  graph!: SyncedGraph;
+  @property() report!: Report;
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      const source = new EventSource("./api/updates");
+      source.addEventListener("message", this.onMessage.bind(this));
+    });
+  }
+  onMessage(ev: MessageEvent) {
+    const report = JSON.parse(ev.data) as Report;
+    report.roundT = Math.floor((report.t || 0) * 1000) / 1000;
+    report.recentFps = Math.floor((report.recentFps || 0) * 10) / 10;
+    report.recentDeltasStyle = (report.recentDeltas || []).map((dt) => {
+      const height = Math.min(40, (dt / 0.085) * 20);
+      return `height: ${height}px;`;
+    });
+    report.songUri = this.graph.Uri(report.song);
+
+    const fakeUris = (report.songNotes || []).map((obj) => {
+      return { value: obj.note, orig: obj };
+    });
+    const s = this.graph.sortedUris(fakeUris);
+    report.songNotes = s.map((u) => {
+      return u.orig;
+    });
+
+    (report.songNotes || []).forEach((note) => {
+      note.rowClass = note.nonZero ? "active" : "inactive";
+      note.effectSettingsPairs = [];
+
+      const attrs = Object.keys(note.effectSettings);
+      attrs.sort();
+      attrs.forEach((attr) => {
+        note.effectSettingsPairs.push({ effectAttr: attr, value: note.effectSettings[attr] } as EffectSettingsPair);
+      });
+    });
+    this.report = report;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/web/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>effect sequencer</title>
+    <meta charset="utf-8" />
+
+    <link rel="stylesheet" href="./style.css" />
+    <script type="module" src="../effectSequencer/Light9SequencerUi"></script>
+  </head>
+  <body>
+    <light9-sequencer-ui></light9-sequencer-ui>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/sequencer/web/vite.config.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+import { defineConfig } from "vite";
+
+const servicePort = 8213;
+export default defineConfig({
+  base: "/effectSequencer/",
+  root: "./light9/effect/sequencer/web",
+  publicDir: "../web",
+  server: {
+    host: "0.0.0.0",
+    strictPort: true,
+    port: servicePort + 100,
+    hmr: {
+      port: servicePort + 200,
+    },
+  },
+  clearScreen: false,
+  define: {
+    global: {},
+  },
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/settings.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,311 @@
+"""
+Data structure and convertors for a table of (device,attr,value)
+rows. These might be effect attrs ('strength'), device attrs ('rx'),
+or output attrs (dmx channel).
+
+BareSettings means (attr,value), no device.
+"""
+from __future__ import annotations
+
+import decimal
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict, Iterable, List, Sequence, Set, Tuple, cast
+
+import numpy
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import Literal, URIRef
+
+from light9.collector.device import resolve
+from light9.localsyncedgraph import LocalSyncedGraph
+from light9.namespaces import L9, RDF
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, HexColor, VTUnion)
+
+log = logging.getLogger('settings')
+
+
+def parseHex(h):
+    if h[0] != '#':
+        raise ValueError(h)
+    return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
+
+
+def parseHexNorm(h):
+    return [x / 255 for x in parseHex(h)]
+
+
+def toHex(rgbFloat: Sequence[float]) -> HexColor:
+    assert len(rgbFloat) == 3
+    scaled = (max(0, min(255, int(v * 255))) for v in rgbFloat)
+    return HexColor('#%02x%02x%02x' % tuple(scaled))
+
+
+def getVal(graph, subj):
+    lit = graph.value(subj, L9['value']) or graph.value(subj, L9['scaledValue'])
+    ret = lit.toPython()
+    if isinstance(ret, decimal.Decimal):
+        ret = float(ret)
+    return ret
+
+
+GraphType = SyncedGraph | LocalSyncedGraph
+
+
+class _Settings:
+    """
+    Generic for DeviceUri/DeviceAttr/VTUnion or EffectClass/EffectAttr/VTUnion
+
+    default values are 0 or '#000000'. Internal rep must not store zeros or some
+    comparisons will break.
+    """
+    EntityType = DeviceUri
+    AttrType = DeviceAttr
+
+    def __init__(self, graph: GraphType, settingsList: List[Tuple[Any, Any, VTUnion]]):
+        self.graph = graph  # for looking up all possible attrs
+        self._compiled: Dict[self.__class__.EntityType, Dict[self.__class__.AttrType, VTUnion]] = {}
+        for e, a, v in settingsList:
+            attrVals = self._compiled.setdefault(e, {})
+            if a in attrVals:
+                v = resolve(
+                    e,  # Hey, this is supposed to be DeviceClass (which is not convenient for us), but so far resolve() doesn't use that arg
+                    a,
+                    [attrVals[a], v])
+            attrVals[a] = v
+        # self._compiled may not be final yet- see _fromCompiled
+        self._delZeros()
+
+    @classmethod
+    def _fromCompiled(cls, graph: GraphType, compiled: Dict[EntityType, Dict[AttrType, VTUnion]]):
+        obj = cls(graph, [])
+        obj._compiled = compiled
+        obj._delZeros()
+        return obj
+
+    @classmethod
+    def fromList(cls, graph: GraphType, others: List[_Settings]):
+        """note that others may have multiple values for an attr"""
+        self = cls(graph, [])
+        for s in others:
+            # if not isinstance(s, cls):
+            #     raise TypeError(s)
+            for row in s.asList():  # could work straight from s._compiled
+                if row[0] is None:
+                    raise TypeError('bad row %r' % (row,))
+                dev, devAttr, value = row
+                devDict = self._compiled.setdefault(dev, {})
+                if devAttr in devDict:
+                    existingVal: VTUnion = devDict[devAttr]
+                    # raise NotImplementedError('fixme: dev is to be a deviceclass (but it is currently unused)')
+                    value = resolve(dev, devAttr, [existingVal, value])
+                devDict[devAttr] = value
+        self._delZeros()
+        return self
+
+    @classmethod
+    def _mult(cls, weight, row, dd) -> VTUnion:
+        if isinstance(row[2], str):
+            prev = parseHexNorm(dd.get(row[1], '#000000'))
+            return toHex(prev + weight * numpy.array(parseHexNorm(row[2])))
+        else:
+            return dd.get(row[1], 0) + weight * row[2]
+
+    @classmethod
+    def fromBlend(cls, graph: GraphType, others: List[Tuple[float, _Settings]]):
+        """others is a list of (weight, Settings) pairs"""
+        out = cls(graph, [])
+        for weight, s in others:
+            if not isinstance(s, cls):
+                raise TypeError(s)
+            for row in s.asList():  # could work straight from s._compiled
+                if row[0] is None:
+                    raise TypeError('bad row %r' % (row,))
+                dd = out._compiled.setdefault(row[0], {})
+
+                newVal = cls._mult(weight, row, dd)
+                dd[row[1]] = newVal
+        out._delZeros()
+        return out
+
+    def _zeroForAttr(self, attr: AttrType) -> VTUnion:
+        if attr == L9['color']:
+            return HexColor('#000000')
+        return 0.0
+
+    def _delZeros(self):
+        for dev, av in list(self._compiled.items()):
+            for attr, val in list(av.items()):
+                if val == self._zeroForAttr(attr):
+                    del av[attr]
+            if not av:
+                del self._compiled[dev]
+
+    def __hash__(self):
+        itemed = tuple([(d, tuple([(a, v) for a, v in sorted(av.items())])) for d, av in sorted(self._compiled.items())])
+        return hash(itemed)
+
+    def __eq__(self, other):
+        if not issubclass(other.__class__, self.__class__):
+            raise TypeError("can't compare %r to %r" % (self.__class__, other.__class__))
+        return self._compiled == other._compiled
+
+    def __ne__(self, other):
+        return not self == other
+
+    def __bool__(self):
+        return bool(self._compiled)
+
+    def __repr__(self):
+        words = []
+
+        def accum():
+            for dev, av in self._compiled.items():
+                for attr, val in sorted(av.items()):
+                    words.append('%s.%s=%s' % (dev.rsplit('/')[-1], attr.rsplit('/')[-1], val))
+                    if len(words) > 5:
+                        words.append('...')
+                        return
+
+        accum()
+        if not words:
+            words = ['(no settings)']
+        return '<%s %s>' % (self.__class__.__name__, ' '.join(words))
+
+    def getValue(self, dev: EntityType, attr: AttrType, defaultToZero=True):
+        x = self._compiled.get(dev, {})
+        if defaultToZero:
+            return x.get(attr, self._zeroForAttr(attr))
+        else:
+            return x[attr]
+
+    def _vectorKeys(self, deviceAttrFilter=None):
+        """stable order of all the dev,attr pairs for this type of settings"""
+        raise NotImplementedError
+
+    def asList(self) -> List[Tuple[EntityType, AttrType, VTUnion]]:
+        """old style list of (dev, attr, val) tuples"""
+        out = []
+        for dev, av in self._compiled.items():
+            for attr, val in av.items():
+                out.append((dev, attr, val))
+        return out
+
+    def devices(self) -> List[EntityType]:
+        return list(self._compiled.keys())
+
+    def toVector(self, deviceAttrFilter=None) -> List[float]:
+        out: List[float] = []
+        for dev, attr in self._vectorKeys(deviceAttrFilter):
+            v = self.getValue(dev, attr)
+            if attr == L9['color']:
+                out.extend(parseHexNorm(v))
+            else:
+                if not isinstance(v, float):
+                    raise TypeError(f'{attr=} value was {v=}')
+                out.append(v)
+        return out
+
+    def byDevice(self) -> Iterable[Tuple[EntityType, _Settings]]:
+        for dev, av in self._compiled.items():
+            yield dev, self.__class__._fromCompiled(self.graph, {dev: av})
+
+    def ofDevice(self, dev: EntityType) -> _Settings:
+        return self.__class__._fromCompiled(self.graph, {dev: self._compiled.get(dev, {})})
+
+    def distanceTo(self, other):
+        diff = numpy.array(self.toVector()) - other.toVector()
+        d = numpy.linalg.norm(diff, ord=None)
+        log.info('distanceTo %r - %r = %g', self, other, d)
+        return d
+
+    def statements(self, subj: EntityType, ctx: URIRef, settingRoot: URIRef, settingsSubgraphCache: Set):
+        """
+        settingRoot can be shared across images (or even wider if you want)
+        """
+        # ported from live.coffee
+        add = []
+        for i, (dev, attr, val) in enumerate(self.asList()):
+            # hopefully a unique number for the setting so repeated settings converge
+            settingHash = hash((dev, attr, val)) % 9999999
+            setting = URIRef('%sset%s' % (settingRoot, settingHash))
+            add.append((subj, L9['setting'], setting, ctx))
+            if setting in settingsSubgraphCache:
+                continue
+
+            scaledAttributeTypes = [L9['color'], L9['brightness'], L9['uv']]
+            settingType = L9['scaledValue'] if attr in scaledAttributeTypes else L9['value']
+            if not isinstance(val, URIRef):
+                val = Literal(val)
+            add.extend([
+                (setting, L9['device'], dev, ctx),
+                (setting, L9['deviceAttr'], attr, ctx),
+                (setting, settingType, val, ctx),
+            ])
+            settingsSubgraphCache.add(setting)
+
+        return add
+
+
+class DeviceSettings(_Settings):
+    EntityType = DeviceUri
+    AttrType = DeviceAttr
+
+    def _vectorKeys(self, deviceAttrFilter=None):
+        with self.graph.currentState() as g:
+            devs = set()  # devclass, dev
+            for dc in g.subjects(RDF.type, L9['DeviceClass']):
+                for dev in g.subjects(RDF.type, dc):
+                    devs.add((dc, dev))
+
+            keys = []
+            for dc, dev in sorted(devs):
+                for attr in sorted(g.objects(dc, L9['deviceAttr'])):
+                    key = (dev, attr)
+                    if deviceAttrFilter and key not in deviceAttrFilter:
+                        continue
+                    keys.append(key)
+        return keys
+
+    @classmethod
+    def fromResource(cls, graph: GraphType, subj: EntityType):
+        settingsList = []
+        with graph.currentState() as g:
+            for s in g.objects(subj, L9['setting']):
+                d = g.value(s, L9['device'])
+                da = g.value(s, L9['deviceAttr'])
+                v = getVal(g, s)
+                settingsList.append((d, da, v))
+        return cls(graph, settingsList)
+
+    @classmethod
+    def fromVector(cls, graph, vector, deviceAttrFilter=None):
+        compiled: Dict[DeviceSettings.EntityType, Dict[DeviceSettings.AttrType, VTUnion]] = {}
+        i = 0
+        for (d, a) in cls(graph, [])._vectorKeys(deviceAttrFilter):
+            if a == L9['color']:
+                v = toHex(vector[i:i + 3])
+                i += 3
+            else:
+                v = vector[i]
+                i += 1
+            compiled.setdefault(d, {})[a] = v
+        return cls._fromCompiled(graph, compiled)
+
+    @classmethod
+    def merge(cls, graph: SyncedGraph, others: List[DeviceSettings]) -> DeviceSettings:
+        return cls.fromList(graph, cast(List[_Settings], others))
+
+
+@dataclass
+class BareEffectSettings:
+    # settings for an already-selected EffectClass
+    s: Dict[EffectAttr, VTUnion]
+
+    def withStrength(self, strength: float) -> BareEffectSettings:
+        out = self.s.copy()
+        out[EffectAttr(L9['strength'])] = strength
+        return BareEffectSettings(s=out)
+
+
+class EffectSettings(_Settings):
+    pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effect/settings_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,151 @@
+import unittest
+from typing import cast
+
+from rdfdb.patch import Patch
+from rdflib import Literal
+
+from light9.effect.settings import DeviceSettings
+from light9.localsyncedgraph import LocalSyncedGraph
+from light9.namespaces import DEV, L9
+from light9.newtypes import DeviceAttr, DeviceUri, HexColor, VTUnion, decimalLiteral
+
+
+class TestDeviceSettings(unittest.TestCase):
+
+    def setUp(self):
+        self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+
+    def testToVectorZero(self):
+        ds = DeviceSettings(self.graph, [])
+        self.assertEqual([0] * 30, ds.toVector())
+
+    def testEq(self):
+        s1 = DeviceSettings(self.graph, [
+            (L9['light1'], L9['attr1'], 0.5),
+            (L9['light1'], L9['attr2'], 0.3),
+        ])
+        s2 = DeviceSettings(self.graph, [
+            (L9['light1'], L9['attr2'], 0.3),
+            (L9['light1'], L9['attr1'], 0.5),
+        ])
+        self.assertTrue(s1 == s2)
+        self.assertFalse(s1 != s2)
+
+    def testMissingFieldsEqZero(self):
+        self.assertEqual(DeviceSettings(self.graph, [
+            (L9['aura1'], L9['rx'], 0),
+        ]), DeviceSettings(self.graph, []))
+
+    def testFalseIfZero(self):
+        self.assertTrue(DeviceSettings(self.graph, [(L9['aura1'], L9['rx'], 0.1)]))
+        self.assertFalse(DeviceSettings(self.graph, []))
+
+    def testFromResource(self):
+        ctx = L9['']
+        self.graph.patch(
+            Patch(addQuads=[
+                (L9['foo'], L9['setting'], L9['foo_set0'], ctx),
+                (L9['foo_set0'], L9['device'], L9['light1'], ctx),
+                (L9['foo_set0'], L9['deviceAttr'], L9['brightness'], ctx),
+                (L9['foo_set0'], L9['value'], decimalLiteral(0.1), ctx),
+                (L9['foo'], L9['setting'], L9['foo_set1'], ctx),
+                (L9['foo_set1'], L9['device'], L9['light1'], ctx),
+                (L9['foo_set1'], L9['deviceAttr'], L9['speed'], ctx),
+                (L9['foo_set1'], L9['scaledValue'], decimalLiteral(0.2), ctx),
+            ]))
+        s = DeviceSettings.fromResource(self.graph, DeviceUri(L9['foo']))
+
+        self.assertEqual(DeviceSettings(self.graph, [
+            (L9['light1'], L9['brightness'], 0.1),
+            (L9['light1'], L9['speed'], 0.2),
+        ]), s)
+
+    def testToVector(self):
+        v = DeviceSettings(self.graph, [
+            (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
+            (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
+        ]).toVector()
+        self.assertEqual([0, 1, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], v)
+
+    def testFromVector(self):
+        s = DeviceSettings.fromVector(self.graph, [0, 1, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+
+        self.assertEqual(
+            DeviceSettings(self.graph, [
+                (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
+                (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
+            ]), s)
+
+    def testAsList(self):
+        sets = [
+            (DeviceUri(L9['light1']), DeviceAttr(L9['attr2']), cast(VTUnion, 0.3)),
+            (DeviceUri(L9['light1']), DeviceAttr(L9['attr1']), 0.5),
+        ]
+        self.assertCountEqual(sets, DeviceSettings(self.graph, sets).asList())
+
+    def testDevices(self):
+        s = DeviceSettings(self.graph, [
+            (DEV['aura1'], L9['rx'], 0),
+            (DEV['aura2'], L9['rx'], 0.1),
+        ])
+        # aura1 is all defaults (zeros), so it doesn't get listed
+        self.assertCountEqual([DEV['aura2']], s.devices())
+
+    def testAddStatements(self):
+        s = DeviceSettings(self.graph, [
+            (DEV['aura2'], L9['rx'], 0.1),
+        ])
+        stmts = s.statements(DeviceUri(L9['foo']), L9['ctx1'], L9['s_'], set())
+        self.maxDiff = None
+        setting = sorted(stmts)[-1][0]
+        self.assertCountEqual([
+            (L9['foo'], L9['setting'], setting, L9['ctx1']),
+            (setting, L9['device'], DEV['aura2'], L9['ctx1']),
+            (setting, L9['deviceAttr'], L9['rx'], L9['ctx1']),
+            (setting, L9['value'], Literal(0.1), L9['ctx1']),
+        ], stmts)
+
+    def testDistanceTo(self):
+        s1 = DeviceSettings(self.graph, [
+            (DEV['aura1'], L9['rx'], 0.1),
+            (DEV['aura1'], L9['ry'], 0.6),
+        ])
+        s2 = DeviceSettings(self.graph, [
+            (DEV['aura1'], L9['rx'], 0.3),
+            (DEV['aura1'], L9['ry'], 0.3),
+        ])
+        self.assertEqual(0.36055512754639896, s1.distanceTo(s2))
+
+
+L1 = L9['light1']
+ZOOM = L9['zoom']
+
+
+class TestFromBlend(unittest.TestCase):
+
+    def setUp(self):
+        self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+
+    def testSingle(self):
+        self.assertEqual(DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]),
+                         DeviceSettings.fromBlend(self.graph, [(1, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))]))
+
+    def testScale(self):
+        self.assertEqual(DeviceSettings(self.graph, [(L1, ZOOM, 0.1)]),
+                         DeviceSettings.fromBlend(self.graph, [(.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))]))
+
+    def testMixFloats(self):
+        self.assertEqual(
+            DeviceSettings(self.graph, [(L1, ZOOM, 0.4)]),
+            DeviceSettings.fromBlend(self.graph, [
+                (.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)])),
+                (.3, DeviceSettings(self.graph, [(L1, ZOOM, 1.0)])),
+            ]))
+
+    def testMixColors(self):
+        self.assertEqual(
+            DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#503000'))]),
+            DeviceSettings.fromBlend(self.graph, [
+                (.25, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#800000'))])),
+                (.5, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#606000'))])),
+            ]))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/__init__.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/effect-components.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,109 @@
+<link rel="import" href="/lib/polymer/polymer.html">
+<script src="/websocket.js"></script>
+<script src="/lib/jquery/dist/jquery.min.js"></script>
+
+<dom-module id="song-effect-list">
+  <template>
+    <template is="dom-repeat" items="{{songs}}" as="song">
+      <li>
+        <a class="song"
+           href="{{song.uri}}"
+           on-click="focusSong">Song <span>{{song.label}}</span></a>
+        <ul>
+          <template is="dom-repeat" items="{{song.effects}}" as="effect">
+            <li>
+              <l9-effect uri="{{effect.uri}}"
+                         label="{{effect.label}}"></l9-effect>
+            </li>
+          </template>
+       <!--    <li>
+          <effect-drop-target song-uri="{{song.uri}}"></effect-drop-target>
+      </li>
+      -->
+        </ul>
+      </li>
+    </template>
+  </template>
+</dom-module>
+<script>
+ Polymer({
+   is: "song-effect-list",
+   properties: {
+     songs: Object
+   },
+   ready: function() {
+     reconnectingWebSocket("songEffectsUpdates", function(msg) {
+       var m, s;
+       m = window.location.search.match(/song=(http[^&]+)/);
+       if (m) {
+         var match = decodeURIComponent(m[1]);
+         this.songs = msg.songs.filter(function(s) { return s.uri == match; });
+       } else {
+         this.songs = msg.songs;
+       }
+     }.bind(this));
+   },
+   focusSong: function(ev) {
+     ev.preventDefault()
+     window.location.search = '?' + $.param({song: ev.model.song.uri});
+   }
+ });
+</script>
+
+<dom-module id="l9-effect">
+  <template>
+    <a class="effect" href="{{href}}">{{label}}</a>
+    
+  </template>
+</dom-module>
+<script>
+ Polymer({
+   is: "l9-effect",
+   properties: {
+     uri: String,
+     label: String,
+     href: {
+       type: String,
+       computed: 'computeHref(uri)'
+     }
+   },
+   computeHref: function(uri) {
+     return 'effect?'+jQuery.param({uri: uri});
+   },
+   deleteEffect: function() {
+     $.ajax({
+       type: 'DELETE',
+       url: 'effect?' + $.param({uri: this.uri})
+     });
+     console.log("del", this.uri);
+   }
+ });
+</script>
+
+<dom-module id="effect-drop-target">
+  <template>
+    <div class="dropTarget"
+        on-dragenter="dragover"
+        on-dragover="dragover"
+        on-drop="drop">Add another (drop a sub or effect class)</div>
+  </template>
+</dom-module>
+<script>
+ Polymer({
+   is: "effect-drop-target",
+   properties: {
+     songUri: String
+   },
+   dragover: function(event) {
+     event.preventDefault()
+     event.dataTransfer.dropEffect = 'copy'
+   },
+   drop: function(event) {
+     event.preventDefault()
+     $.post('songEffects', {
+       uri: this.songUri,
+       drop: event.dataTransfer.getData('text/uri-list')
+     });
+   }
+ });
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/effect.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+qs = new QueryString()
+model =
+  toSave: 
+    uri: ko.observable(qs.value('uri'))
+    codeLines: ko.observableArray([])
+  
+socket = reconnectingWebSocket "../effectUpdates" + window.location.search, (msg) ->
+  console.log('effectData ' + JSON.stringify(msg))
+  model.toSave.codeLines(msg.codeLines.map((x) -> {text: ko.observable(x)})) if msg.codeLines?
+
+model.saveCode = ->
+  $.ajax
+    type: 'PUT'
+    url: 'code'
+    data: ko.toJS(model.toSave)
+
+writeBack = ko.computed(model.saveCode)
+  
+ko.applyBindings(model)
+  
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/effect.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,27 @@
+<!doctype html>
+<html>
+  <head>
+    <title>effect</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="/style.css">
+
+  </head>
+  <body>
+    <div id="status">starting...</div>
+
+    <a href="./">Effects</a> / <a class="effect" data-bind="attr: {href: toSave.uri}, text: toSave.uri"></a>
+
+    <div data-bind="foreach: toSave.codeLines">
+      <div>
+        code:
+        <input type="text" size="160" data-bind="value: text"></input>
+      </div>
+    </div>
+    
+    <script src="/lib/jquery/dist/jquery.min.js"></script>
+    <script src="/lib/knockout/dist/knockout.js"></script>
+    <script src="/websocket.js"></script>
+    <script src="/lib/QueryString/index.js"></script>
+    <script src="effect.js"></script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/effect.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,192 @@
+import re, logging
+import toposort
+from rdflib import URIRef
+from light9.namespaces import L9, RDF
+from light9.curvecalc.curve import CurveResource
+from light9 import prof
+from light9 import Submaster
+from light9 import Effects  # gets reload() later
+log = logging.getLogger('effect')
+
+# consider http://waxeye.org/ for a parser that can be used in py and js
+
+
+class CouldNotConvert(TypeError):
+    pass
+
+
+class CodeLine:
+    """code string is immutable"""
+
+    def __init__(self, graph, code):
+        self.graph, self.code = graph, code
+
+        self.outName, self.inExpr, self.expr, self.resources = self._asPython()
+        self.pyResources = self._resourcesAsPython(self.resources)
+        self.possibleVars = self.findVars(self.inExpr)
+
+    @prof.logTime
+    def _asPython(self):
+        """
+        out = sub(<uri1>, intensity=<curveuri2>)
+        becomes
+          'out',
+          'sub(_u1, intensity=curve(_u2, t))',
+          {'_u1': URIRef('uri1'), '_u2': URIRef('uri2')}
+        """
+        lname, expr = [s.strip() for s in self.code.split('=', 1)]
+        self.uriCounter = 0
+        resources = {}
+
+        def alreadyInFunc(prefix, s, i):
+            return i >= len(prefix) and s[i - len(prefix):i] == prefix
+
+        def repl(m):
+            v = '_res%s' % self.uriCounter
+            self.uriCounter += 1
+            r = resources[v] = URIRef(m.group(1))
+            for uriTypeMatches, wrapFuncName, addlArgs in [
+                (self._uriIsCurve(r), 'curve', ', t'),
+                    # I'm pretty sure this shouldn't be auto-applied: it's reasonable to refer to a sub and not want its current value
+                    #(self._uriIsSub(r), 'currentSubLevel', ''),
+            ]:
+                if uriTypeMatches:
+                    if not alreadyInFunc(wrapFuncName + '(', m.string,
+                                         m.start()):
+                        return '%s(%s%s)' % (wrapFuncName, v, addlArgs)
+            return v
+
+        outExpr = re.sub(r'<(http\S*?)>', repl, expr)
+        return lname, expr, outExpr, resources
+
+    def findVars(self, expr):
+        """may return some more strings than just the vars"""
+        withoutUris = re.sub(r'<(http\S*?)>', 'None', expr)
+        tokens = set(re.findall(r'\b([a-zA-Z_]\w*)\b', withoutUris))
+        tokens.discard('None')
+        return tokens
+
+    def _uriIsCurve(self, uri):
+        # this result could vary with graph changes (rare)
+        return self.graph.contains((uri, RDF.type, L9['Curve']))
+
+    def _uriIsSub(self, uri):
+        return self.graph.contains((uri, RDF.type, L9['Submaster']))
+
+    @prof.logTime
+    def _resourcesAsPython(self, resources):
+        """
+        mapping of the local names for uris in the code to high-level
+        objects (Submaster, Curve)
+        """
+        out = {}
+        subs = prof.logTime(Submaster.get_global_submasters)(self.graph)
+        for localVar, uri in list(resources.items()):
+
+            for rdfClass in self.graph.objects(uri, RDF.type):
+                if rdfClass == L9['Curve']:
+                    cr = CurveResource(self.graph, uri)
+                    # this is slow- pool these curves somewhere, maybe just with curveset
+                    prof.logTime(cr.loadCurve)()
+                    out[localVar] = cr.curve
+                    break
+                elif rdfClass == L9['Submaster']:
+                    out[localVar] = subs.get_sub_by_uri(uri)
+                    break
+                else:
+                    out[localVar] = CouldNotConvert(uri)
+                    break
+            else:
+                out[localVar] = CouldNotConvert(uri)
+
+        return out
+
+
+class EffectNode:
+
+    def __init__(self, graph, uri):
+        self.graph, self.uri = graph, uri
+        # this is not expiring at the right time, when an effect goes away
+        self.graph.addHandler(self.prepare)
+
+    @prof.logTime
+    def prepare(self):
+        log.info("prepare effect %s", self.uri)
+        # maybe there can be multiple lines of code as multiple
+        # objects here, and we sort them by dependencies
+        codeStrs = list(self.graph.objects(self.uri, L9['code']))
+        if not codeStrs:
+            raise ValueError("effect %s has no code" % self.uri)
+
+        self.codes = [CodeLine(self.graph, s) for s in codeStrs]
+
+        self.sortCodes()
+
+        #reload(Effects)
+        self.otherFuncs = prof.logTime(Effects.configExprGlobals)()
+
+    def sortCodes(self):
+        """put self.codes in a working evaluation order"""
+        codeFromOutput = dict((c.outName, c) for c in self.codes)
+        deps = {}
+        for c in self.codes:
+            outName = c.outName
+            inNames = c.possibleVars.intersection(list(codeFromOutput.keys()))
+            inNames.discard(outName)
+            deps[outName] = inNames
+        self.codes = [
+            codeFromOutput[n] for n in toposort.toposort_flatten(deps)
+        ]
+
+    def _currentSubSettingValues(self, sub):
+        """what KC subSettings are setting levels right now?"""
+        cs = self.graph.currentState
+        with cs(tripleFilter=(None, L9['sub'], sub)) as g1:
+            for subj in g1.subjects(L9['sub'], sub):
+                with cs(tripleFilter=(subj, None, None)) as g2:
+                    if (subj, RDF.type, L9['SubSetting']) in g2:
+                        v = g2.value(subj, L9['level']).toPython()
+                        yield v
+
+    def currentSubLevel(self, uri):
+        """what's the max level anyone (probably KC) is
+        holding this sub to right now?"""
+        if isinstance(uri, Submaster.Submaster):
+            # likely the uri was spotted and replaced
+            uri = uri.uri
+        if not isinstance(uri, URIRef):
+            raise TypeError("got %r" % uri)
+
+        foundLevels = list(self._currentSubSettingValues(uri))
+
+        if not foundLevels:
+            return 0
+
+        return max(foundLevels)
+
+    def eval(self, songTime):
+        ns = {'t': songTime}
+        ns.update(self.otherFuncs)
+
+        ns.update(
+            dict(
+                curve=lambda c, t: c.eval(t),
+                currentSubLevel=self.currentSubLevel,
+            ))
+
+        # I think this is slowing effecteval. Could we cache results
+        # that we know haven't changed, like if a curve returns 0
+        # again, we can skip an eval() call on the line that uses it
+
+        for c in self.codes:
+            codeNs = ns.copy()
+            codeNs.update(c.pyResources)
+            try:
+                lineOut = eval(c.expr, codeNs)
+            except Exception as e:
+                e.expr = c.expr
+                raise e
+            ns[c.outName] = lineOut
+        if 'out' not in ns:
+            log.error("ran code for %s, didn't make an 'out' value", self.uri)
+        return ns['out']
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/effectloop.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,322 @@
+import time, logging, traceback
+
+from rdflib import URIRef
+from twisted.internet import reactor
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed
+from twisted.internet.error import TimeoutError
+import numpy
+import serial
+import treq
+
+from light9 import Effects
+from light9 import Submaster
+from light9 import dmxclient
+from light9 import networking
+from light9.effecteval.effect import EffectNode
+from light9.namespaces import L9, RDF
+from light9.metrics import metrics
+
+log = logging.getLogger('effectloop')
+
+
+class EffectLoop:
+    """maintains a collection of the current EffectNodes, gets time from
+    music player, sends dmx"""
+
+    def __init__(self, graph):
+        self.graph = graph
+        self.currentSong = None
+        self.currentEffects = [
+        ]  # EffectNodes for the current song plus the submaster ones
+        self.lastLogTime = 0
+        self.lastLogMsg = ""
+        self.lastErrorLog = 0
+        self.graph.addHandler(self.setEffects)
+        self.period = 1 / 30
+        self.coastSecs = .3  # main reason to keep this low is to notice play/pause
+        self.songTimeFetch = 0
+        self.songIsPlaying = False
+        self.songTimeFromRequest = 0
+        self.requestTime = 0  # unix sec for when we fetched songTime
+        self.initOutput()
+
+    def initOutput(self):
+        pass
+
+    def startLoop(self):
+        log.info("startLoop")
+        self.lastSendLevelsTime = 0
+        reactor.callLater(self.period, self.sendLevels)
+        reactor.callLater(self.period, self.updateTimeFromMusic)
+
+    def setEffects(self):
+        self.currentEffects = []
+        log.info('setEffects currentSong=%s', self.currentSong)
+        if self.currentSong is None:
+            return
+
+        for effectUri in self.graph.objects(self.currentSong, L9['effect']):
+            self.currentEffects.append(EffectNode(self.graph, effectUri))
+
+        for sub in self.graph.subjects(RDF.type, L9['Submaster']):
+            for effectUri in self.graph.objects(sub, L9['drivesEffect']):
+                self.currentEffects.append(EffectNode(self.graph, effectUri))
+
+        log.info('now we have %s effects', len(self.currentEffects))
+
+    @inlineCallbacks
+    def getSongTime(self):
+        now = time.time()
+        old = now - self.requestTime
+        if old > self.coastSecs:
+            try:
+                r = yield treq.get(networking.musicPlayer.path('time'),
+                                   timeout=.5)
+                response = yield r.json_content()
+            except TimeoutError:
+                log.warning("TimeoutError: using stale time from %.1f ago", old)
+            else:
+                self.requestTime = now
+                self.currentPlaying = response['playing']
+                self.songTimeFromRequest = response['t']
+                returnValue((response['t'], (response['song'] and
+                                             URIRef(response['song']))))
+
+        estimated = self.songTimeFromRequest
+        if self.currentSong is not None and self.currentPlaying:
+            estimated += now - self.requestTime
+        returnValue((estimated, self.currentSong))
+
+    @inlineCallbacks
+    def updateTimeFromMusic(self):
+        t1 = time.time()
+        with metrics('get_music').time():
+            self.songTime, song = yield self.getSongTime()
+            self.songTimeFetch = time.time()
+
+        if song != self.currentSong:
+            self.currentSong = song
+            # this may be piling on the handlers
+            self.graph.addHandler(self.setEffects)
+
+        elapsed = time.time() - t1
+        reactor.callLater(max(0, self.period - elapsed),
+                          self.updateTimeFromMusic)
+
+    def estimatedSongTime(self):
+        now = time.time()
+        t = self.songTime
+        if self.currentPlaying:
+            t += max(0, now - self.songTimeFetch)
+        return t
+
+    @inlineCallbacks
+    def sendLevels(self):
+        t1 = time.time()
+        log.debug("time since last call: %.1f ms" %
+                  (1000 * (t1 - self.lastSendLevelsTime)))
+        self.lastSendLevelsTime = t1
+        try:
+            with metrics('send_levels').time():
+                if self.currentSong is not None:
+                    log.debug('allEffectOutputs')
+                    with metrics('evals').time():
+                        outputs = self.allEffectOutputs(
+                            self.estimatedSongTime())
+                    log.debug('combineOutputs')
+                    combined = self.combineOutputs(outputs)
+                    self.logLevels(t1, combined)
+                    log.debug('sendOutput')
+                    with metrics('send_output').time():
+                        yield self.sendOutput(combined)
+
+                elapsed = time.time() - t1
+                dt = max(0, self.period - elapsed)
+        except Exception:
+            metrics('errors').incr()
+            traceback.print_exc()
+            dt = .5
+
+        reactor.callLater(dt, self.sendLevels)
+
+    def combineOutputs(self, outputs):
+        """pick usable effect outputs and reduce them into one for sendOutput"""
+        outputs = [x for x in outputs if isinstance(x, Submaster.Submaster)]
+        out = Submaster.sub_maxes(*outputs)
+
+        return out
+
+    @inlineCallbacks
+    def sendOutput(self, combined):
+        dmx = combined.get_dmx_list()
+        yield dmxclient.outputlevels(dmx, twisted=True)
+
+    def allEffectOutputs(self, songTime):
+        outputs = []
+        for e in self.currentEffects:
+            try:
+                out = e.eval(songTime)
+                if isinstance(out, (list, tuple)):
+                    outputs.extend(out)
+                else:
+                    outputs.append(out)
+            except Exception as exc:
+                now = time.time()
+                if now > self.lastErrorLog + 5:
+                    if hasattr(exc, 'expr'):
+                        log.exception('in expression %r', exc.expr)
+                    log.error("effect %s: %s" % (e.uri, exc))
+                    self.lastErrorLog = now
+        log.debug('eval %s effects, got %s outputs', len(self.currentEffects),
+                  len(outputs))
+
+        return outputs
+
+    def logLevels(self, now, out):
+        # this would look nice on the top of the effecteval web pages too
+        if log.isEnabledFor(logging.DEBUG):
+            log.debug(self.logMessage(out))
+        else:
+            if now > self.lastLogTime + 5:
+                msg = self.logMessage(out)
+                if msg != self.lastLogMsg:
+                    log.info(msg)
+                    self.lastLogMsg = msg
+                self.lastLogTime = now
+
+    def logMessage(self, out):
+        return ("send dmx: {%s}" %
+                ", ".join("%r: %.3g" % (str(k), v)
+                          for k, v in list(out.get_levels().items())))
+
+
+Z = numpy.zeros((50, 3), dtype=numpy.float16)
+
+
+class ControlBoard:
+
+    def __init__(
+            self,
+            dev='/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A7027NYX-if00-port0'
+    ):
+        log.info('opening %s', dev)
+        self._dev = serial.Serial(dev, baudrate=115200)
+
+    def _8bitMessage(self, floatArray):
+        px255 = (numpy.clip(floatArray, 0, 1) * 255).astype(numpy.uint8)
+        return px255.reshape((-1,)).tostring()
+
+    def setStrip(self, which, pixels):
+        """
+        which: 0 or 1 to pick the strip
+        pixels: (50, 3) array of 0..1 floats
+        """
+        command = {0: '\x00', 1: '\x01'}[which]
+        if pixels.shape != (50, 3):
+            raise ValueError("pixels was %s" % pixels.shape)
+        self._dev.write('\x60' + command + self._8bitMessage(pixels))
+        self._dev.flush()
+
+    def setUv(self, which, level):
+        """
+        which: 0 or 1
+        level: 0 to 1
+        """
+        command = {0: '\x02', 1: '\x03'}[which]
+        self._dev.write('\x60' + command +
+                        chr(int(max(0, min(1, level)) * 255)))
+        self._dev.flush()
+
+    def setRgb(self, color):
+        """
+        color: (1, 3) array of 0..1 floats
+        """
+        if color.shape != (1, 3):
+            raise ValueError("color was %s" % color.shape)
+        self._dev.write('\x60\x04' + self._8bitMessage(color))
+        self._dev.flush()
+
+
+class LedLoop(EffectLoop):
+
+    def initOutput(self):
+        self.board = ControlBoard()
+        self.lastSent = {}  # what's in arduino's memory
+
+    def combineOutputs(self, outputs):
+        combined = {
+            'L': Z,
+            'R': Z,
+            'blacklight0': 0,
+            'blacklight1': 0,
+            'W': numpy.zeros((1, 3), dtype=numpy.float16)
+        }
+
+        for out in outputs:
+            log.debug('combine output %r', out)
+
+            # workaround- somehow these subs that drive fx aren't
+            # sending their fx during playback (KC only), so we react
+            # to the sub itself
+            if isinstance(out, Submaster.Submaster) and '*' in out.name:
+                level = float(out.name.split('*')[1])
+                n = out.name.split('*')[0]
+                if n == 'widered':
+                    out = Effects.Strip.solid('LRW', (1, 0, 0)) * level
+                if n == 'widegreen':
+                    out = Effects.Strip.solid('LRW', (0, 1, 0)) * level
+                if n == 'wideblue':
+                    out = Effects.Strip.solid('LRW', (0, 0, 1)) * level
+                if n == 'whiteled':
+                    out = Effects.Strip.solid('LRW', (1, .7, .7)) * level
+                if n == 'blacklight':
+                    out = Effects.Blacklight(level)  # missing blues!
+
+            if isinstance(out, Effects.Blacklight):
+                # no picking yet
+                #key = 'blacklight%s' % out.which
+                for key in ['blacklight0', 'blacklight1']:
+                    combined[key] = max(combined[key], out)
+            elif isinstance(out, Effects.Strip):
+                pixels = numpy.array(out.pixels, dtype=numpy.float16)
+                for w in out.which:
+                    combined[w] = numpy.maximum(
+                        combined[w], pixels[:1, :] if w == 'W' else pixels)
+
+        return combined
+
+    @inlineCallbacks
+    def sendOutput(self, combined):
+        for meth, selectArgs, value in [
+            ('setStrip', (0,), combined['L']),
+            ('setStrip', (1,), combined['R']),
+            ('setUv', (0,), combined['blacklight0']),
+            ('setUv', (1,), combined['blacklight1']),
+            ('setRgb', (), combined['W']),
+        ]:
+            key = (meth, selectArgs)
+            compValue = value.tolist() if isinstance(value,
+                                                     numpy.ndarray) else value
+
+            if self.lastSent.get(key) == compValue:
+                continue
+
+            log.debug('value changed: %s(%s %s)', meth, selectArgs, value)
+
+            getattr(self.board, meth)(*(selectArgs + (value,)))
+            self.lastSent[key] = compValue
+
+        yield succeed(None)  # there was an attempt at an async send
+
+    def logMessage(self, out):
+        return str([(w, p.tolist() if isinstance(p, numpy.ndarray) else p)
+                    for w, p in list(out.items())])
+
+
+def makeEffectLoop(graph, outputWhere):
+    if outputWhere == 'dmx':
+        return EffectLoop(graph)
+    elif outputWhere == 'leds':
+        return LedLoop(graph)
+    else:
+        raise NotImplementedError("unknown output system %r" % outputWhere)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,16 @@
+<!doctype html>
+<html>
+  <head>
+    <title>effecteval</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="/style.css">
+    <link rel="import" href="effect-components.html">
+  </head>
+  <body>
+    <div id="status">starting...</div>
+    <h1>Effect instances <a href="stats/">[stats]</a></h1>
+    <div><a href=".">View all songs</a></div>
+    <!-- subscribe to a query of all effects and their songs -->
+    <song-effect-list></song-effect-list>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/effecteval/test_effect.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+import unittest
+import mock
+import sys
+sys.path.insert(0, 'bin')  # for run_local
+
+from .effect import CodeLine
+from rdflib import URIRef
+
+
+def isCurve(self, uri):
+    return 'curve' in uri
+
+
+def isSub(self, uri):
+    return 'sub' in uri
+
+
+@mock.patch('light9.effecteval.effect.CodeLine._uriIsCurve', new=isCurve)
+@mock.patch('light9.effecteval.effect.CodeLine._uriIsSub', new=isSub)
+@mock.patch('light9.effecteval.effect.CodeLine._resourcesAsPython',
+            new=lambda self, r: self.expr)
+class TestAsPython(unittest.TestCase):
+
+    def test_gets_lname(self):
+        ec = CodeLine(graph=None, code='x = y+1')
+        self.assertEqual('x', ec.outName)
+
+    def test_gets_simple_code(self):
+        ec = CodeLine(graph=None, code='x = y+1')
+        self.assertEqual('y+1', ec._asPython()[2])
+        self.assertEqual({}, ec._asPython()[3])
+
+    def test_converts_uri_to_var(self):
+        ec = CodeLine(graph=None, code='x = <http://example.com/>')
+        _, inExpr, expr, uris = ec._asPython()
+        self.assertEqual('_res0', expr)
+        self.assertEqual({'_res0': URIRef('http://example.com/')}, uris)
+
+    def test_converts_multiple_uris(self):
+        ec = CodeLine(graph=None,
+                      code='x = <http://example.com/> + <http://other>')
+        _, inExpr, expr, uris = ec._asPython()
+        self.assertEqual('_res0 + _res1', expr)
+        self.assertEqual(
+            {
+                '_res0': URIRef('http://example.com/'),
+                '_res1': URIRef('http://other')
+            }, uris)
+
+    def test_doesnt_fall_for_brackets(self):
+        ec = CodeLine(graph=None, code='x = 1<2>3< h')
+        _, inExpr, expr, uris = ec._asPython()
+        self.assertEqual('1<2>3< h', expr)
+        self.assertEqual({}, uris)
+
+    def test_curve_uri_expands_to_curve_eval_func(self):
+        ec = CodeLine(graph=None, code='x = <http://example/curve1>')
+        _, inExpr, expr, uris = ec._asPython()
+        self.assertEqual('curve(_res0, t)', expr)
+        self.assertEqual({'_res0': URIRef('http://example/curve1')}, uris)
+
+    def test_curve_doesnt_double_wrap(self):
+        ec = CodeLine(graph=None,
+                      code='x = curve(<http://example/curve1>, t+.01)')
+        _, inExpr, expr, uris = ec._asPython()
+        self.assertEqual('curve(_res0, t+.01)', expr)
+        self.assertEqual({'_res0': URIRef('http://example/curve1')}, uris)
+
+
+@mock.patch('light9.effecteval.effect.CodeLine._uriIsCurve', new=isCurve)
+@mock.patch('light9.effecteval.effect.CodeLine._resourcesAsPython',
+            new=lambda self, r: self.expr)
+class TestPossibleVars(unittest.TestCase):
+
+    def test1(self):
+        self.assertEqual(set([]), CodeLine(None, 'a1 = 1').possibleVars)
+
+    def test2(self):
+        self.assertEqual({'a2'}, CodeLine(None, 'a1 = a2').possibleVars)
+
+    def test3(self):
+        self.assertEqual({'a2', 'a3'},
+                         CodeLine(None, 'a1 = a2 + a3').possibleVars)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/gtkpyconsole.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,38 @@
+from lib.ipython_view import IPythonView
+import gi  # noqa
+from gi.repository import Gtk
+from gi.repository import Pango
+
+
+def togglePyConsole(self, item, user_ns):
+    """
+    toggles a toplevel window with an ipython console inside.
+
+    self is an object we can stick the pythonWindow attribute on
+
+    item is a checkedmenuitem
+
+    user_ns is a dict you want to appear as locals in the console
+    """
+    if item.get_active():
+        if not hasattr(self, 'pythonWindow'):
+            self.pythonWindow = Gtk.Window()
+            S = Gtk.ScrolledWindow()
+            S.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+            V = IPythonView(user_ns=user_ns)
+            V.modify_font(Pango.FontDescription("luxi mono 8"))
+            V.set_wrap_mode(Gtk.WrapMode.CHAR)
+            S.add(V)
+            self.pythonWindow.add(S)
+            self.pythonWindow.show_all()
+            self.pythonWindow.set_size_request(750, 550)
+            self.pythonWindow.set_resizable(True)
+
+            def onDestroy(*args):
+                item.set_active(False)
+                del self.pythonWindow
+
+            self.pythonWindow.connect("destroy", onDestroy)
+    else:
+        if hasattr(self, 'pythonWindow'):
+            self.pythonWindow.destroy()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/homepage/write_config.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,89 @@
+'''
+
+ goal (everything under localhost:8200):
+   /                          light9/web/index.html
+   /effects                   light9/web/effects/index.html
+   /collector/                light9/web/collector/index.html
+   /show/dance2023/URI        light9/show/dance2023/URI
+   /service/collector/        localhost:8302
+   /service/collector/metrics localhost:8302/metrics
+'''
+import sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+from light9 import showconfig
+from light9.namespaces import L9
+from light9.run_local import log
+
+
+def main():
+    [outPath] = sys.argv[1:]
+
+    log.info(f'writing nginx config to {outPath}')
+    graph = showconfig.getGraph()
+    netHome = graph.value(showconfig.showUri(), L9['networking'])
+    webServer = graph.value(netHome, L9['webServer'])
+    if not webServer:
+        raise ValueError('no %r :webServer' % netHome)
+    mime_types = Path(__file__).parent.parent / 'web/mime.types'
+    nginx_port = urlparse(str(webServer)).port
+    with open(outPath, 'wt') as out:
+        print(f'''
+worker_processes 1;
+
+daemon off;
+error_log /tmp/light9_homepage.err;
+pid /dev/null;
+
+events {{
+  worker_connections 1024;
+}}
+
+http {{
+ include {mime_types};
+
+  proxy_buffering off;
+  proxy_http_version 1.1;
+
+  # for websocket
+  proxy_set_header Upgrade $http_upgrade;
+  proxy_set_header Connection "upgrade";
+
+ server {{
+  listen {nginx_port};
+  access_log off;
+  autoindex on;''',
+              file=out)
+
+        for role, server in sorted(graph.predicate_objects(netHome)):
+            if not str(server).startswith('http') or role == L9['webServer']:
+                continue
+            path = graph.value(role, L9['urlPath'])
+            if not path:
+                continue
+            server = str(server).rstrip('/')
+            print(f'''
+  location = /{path} {{ rewrite (.*) $1/ permanent; }}
+  location /service/{path}/ {{
+    rewrite ^/service/{path}(/.*) $1 break;
+    proxy_pass {server};
+  }}''',
+                  file=out)
+
+        showPath = showconfig.showUri().split('/', 3)[-1]
+        root = showconfig.root()[:-len(showPath)].decode('ascii')
+        print(f'''
+  location /show/ {{
+    root {root};
+  }}
+
+  location / {{
+    proxy_pass http://localhost:8300;
+  }}
+ }}
+}}''', file=out)
+
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/homepage/write_config_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,3 @@
+def test_import():
+    import write_config
+    # no crash
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/Makefile	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+LIB=/usr/local/lib
+INC=-I/usr/local/include/python2.3
+
+go: _parport.so _serport.so
+	result="your modules and links are now up to date"
+
+_parport.so: parport_wrap.c
+	gcc -shared -g ${INC} parport_wrap.c parport.c -o _parport.so 
+
+parport_wrap.c: parport.c parport.i
+	swig -python parport.i
+
+_serport.so: serport_wrap.c
+	gcc -shared -O ${INC} serport_wrap.c -o _serport.so 
+
+serport_wrap.c: serport.i
+	swig -python serport.i
+
+clean:
+	rm -f parport_wrap.c serport_wrap.c *.o *.so
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/__init__.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,89 @@
+import sys
+
+
+class BaseIO(object):
+
+    def __init__(self):
+        self.dummy = 1
+        self.__name__ = 'BaseIO'
+        # please override and set __name__ to your class name
+
+    def golive(self):
+        """call this if you want to promote the dummy object becomes a live object"""
+        print("IO: %s is going live" % self.__name__)
+        self.dummy = 0
+        # you'd override with additional startup stuff here,
+        # perhaps even loading a module and saving it to a class
+        # attr so the subclass-specific functions can use it
+
+    def godummy(self):
+        print("IO: %s is going dummy" % self.__name__)
+        self.dummy = 1
+        # you might override this to close ports, etc
+
+    def isdummy(self):
+        return self.dummy
+
+    def __repr__(self):
+        if self.dummy:
+            return "<dummy %s instance>" % self.__name__
+        else:
+            return "<live %s instance>" % self.__name__
+
+    # the derived class will have more methods to do whatever it does,
+    # and they should return dummy values if self.dummy==1.
+
+
+class ParportDMX(BaseIO):
+
+    def __init__(self, dimmers=68):
+        BaseIO.__init__(self)
+        self.__name__ = 'ParportDMX'
+        self.dimmers = dimmers
+
+    def golive(self):
+        BaseIO.golive(self)
+        from . import parport
+        self.parport = parport
+        self.parport.getparport()
+
+    def sendlevels(self, levels):
+        if self.dummy:
+            return
+
+        levels = list(levels) + [0]
+        # if levels[14] > 0: levels[14] = 100 # non-dim
+        self.parport.outstart()
+        for p in range(1, self.dimmers + 2):
+            self.parport.outbyte(levels[p - 1] * 255 / 100)
+
+
+class UsbDMX(BaseIO):
+
+    def __init__(self, dimmers=72, port='/dev/dmx0'):
+        BaseIO.__init__(self)
+        self.__name__ = "UsbDMX"
+        self.port = port
+        self.out = None
+        self.dimmers = dimmers
+
+    def _dmx(self):
+        if self.out is None:
+            if self.port == 'udmx':
+                from .udmx import Udmx
+                self.out = Udmx()
+                self.out.write = self.out.SendDMX
+            else:
+                sys.path.append("dmx_usb_module")
+                from dmx import Dmx
+                self.out = Dmx(self.port)
+        return self.out
+
+    def sendlevels(self, levels):
+        if self.dummy:
+            return
+        # I was outputting on 76 and it was turning on the light at
+        # dmx75. So I added the 0 byte.
+        packet = '\x00' + ''.join([chr(int(lev * 255 / 100)) for lev in levels
+                                  ]) + "\x55"
+        self._dmx().write(packet)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/motordrive	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,144 @@
+#!/usr/bin/python
+
+from __future__ import division
+from twisted.internet import reactor, tksupport
+import Tkinter as tk
+import time, atexit
+from louie import dispatcher
+import parport
+parport.getparport()
+
+
+class Ctl:
+    def __init__(self):
+        self.blade = False
+        self.xpos = 0
+        self.ypos = 0
+
+        dispatcher.connect(self.dragTo, "dragto")
+        self.path = [] # future points to walk to
+        self.lastByteTime = 0
+
+    def dragTo(self, x, y):
+        self.path.append((x,y))
+        #print "drag to", x, y, len(self.path)
+        dispatcher.send("new path", path=self.path)
+
+    def step(self):
+        start = time.time()
+        while time.time() - start < .05:
+            self._step()
+
+    def _step(self):
+        if not self.path:
+            return
+        goal = self.path[0]
+        if (self.xpos, self.ypos) == goal:
+            self.path.pop(0)
+            dispatcher.send("new path", path=self.path)
+            return
+        self.move(cmp(goal[0], self.xpos),
+                  cmp(goal[1], self.ypos))
+
+    def move(self, dx, dy):
+        self.xpos += dx
+        self.ypos += dy
+        dispatcher.send("coords", x=self.xpos, y=self.ypos)
+        #print "x=%d y=%d" % (self.xpos, self.ypos)
+        self.update()
+
+    def update(self):
+        byte = 0
+        if self.blade:
+            byte |= 0x80
+
+        byte |= (0x01, 0x03, 0x02, 0x00)[self.xpos % 4] * 0x20
+        byte |= (0x01, 0x03, 0x02, 0x00)[self.ypos % 4] * 0x04
+
+        byte |= 0x01 # power pin
+        byte |= 0x02 | 0x10 # enable dirs
+
+        now = time.time()
+        print "%.1fms delay between bytes" % ((now - self.lastByteTime) * 1000)
+        self.out(byte)
+        self.lastByteTime = now
+        
+    def out(self, byte):
+        #print hex(byte)
+        parport.outdata(byte)
+        time.sleep(.003)
+
+    def toggleBlade(self):
+        self.blade = not self.blade
+        if self.blade:
+            # blade needs full power to go down
+            self.out(0x80)
+            time.sleep(.05)
+        self.update()
+
+class Canv(tk.Canvas):
+    def __init__(self, master, **kw):
+        tk.Canvas.__init__(self, master, **kw)
+        self.create_line(0,0,0,0, tags='cursorx')
+        self.create_line(0,0,0,0, tags='cursory')
+        dispatcher.connect(self.updateCursor, "coords")
+        dispatcher.connect(self.drawPath, "new path")
+        self.bind("<B1-Motion>", self.b1motion)
+
+    def canFromWorld(self, wx, wy):
+        return -wx / 5 + 300, wy / 5 + 300
+
+    def worldFromCan(self, cx, cy):
+        return -(cx - 300) * 5, (cy - 300) * 5
+
+    def updateCursor(self, x, y):
+        x,y = self.canFromWorld(x, y)
+        self.coords('cursorx', x-10, y, x+10, y)
+        self.coords('cursory', x, y-10, x, y+10)
+
+    def b1motion(self, ev):
+        wx,wy = self.worldFromCan(ev.x, ev.y)
+        dispatcher.send("dragto", x=wx, y=wy)
+
+    def drawPath(self, path):
+        self.delete('path')
+        pts = []
+        for pt in path:
+            pts.extend(self.canFromWorld(*pt))
+        if len(pts) >= 4:
+            self.create_line(*pts, **dict(tag='path'))
+        
+root = tk.Tk()
+
+
+ctl = Ctl()
+
+can = Canv(root, width=900, height=900)
+can.pack()
+
+for key, byte in [
+    ('0', 0),
+    ]:
+    root.bind("<Key-%s>" % key, lambda ev, byte=byte: ctl.out(byte))
+
+for key, xy in [('Left', (-1, 0)),
+                ('Right', (1, 0)),
+                ('Up', (0, -1)),
+                ('Down', (0, 1))]:
+    root.bind("<Key-%s>" % key, lambda ev, xy=xy: ctl.move(*xy))
+
+root.bind("<Key-space>", lambda ev: ctl.toggleBlade())
+
+ctl.move(0,0)
+
+atexit.register(lambda: ctl.out(0))
+
+def loop():
+    ctl.step()
+    root.after(10, loop)
+loop()
+
+tksupport.install(root, ms=5)
+root.protocol('WM_DELETE_WINDOW', reactor.stop)
+reactor.run()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/parport.c	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,58 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+#include <asm/io.h>
+#include <fcntl.h>
+#include <Python.h>
+
+int getparport() {
+  printf("parport - ver 4\n");
+    if( ioperm(888,3,1) ) {
+      printf("Couldn't get parallel port at 888-890\n");
+
+      // the following doesn't have any effect!
+      PyErr_SetString(PyExc_IOError,"Couldn't get parallel port at 888-890");
+      return 0;
+    } 
+    return 1;
+}
+
+void outdata(unsigned char val) {
+  outb(val,888);
+}
+
+void outcontrol( unsigned char val ) {
+  outb(val,890);
+}
+
+void outbyte( unsigned char val ) {
+  int i;
+  // set data, raise clock, lower clock
+  outdata(val);
+
+  /* this was originally 26 outcontrol calls, but on new dash that
+     leads to screwed up dmx about once a minute. I tried doing 26*4
+     outcontrol calls, but it still screwed up. I suspect the athlon64
+     or my new kernel version is sending the parport really fast,
+     sometimes faster than the pic sees the bits. Then I put a 1ms
+     sleep after the outcontrol(2)'s and that didn't help either, so
+     I'm not sure what's going on. Putting the parallel cable on miles
+     seems to work. 
+
+     todo:
+     try a little pause after outcontrol(3) to make sure pic sees that
+  */
+
+  for (i=0; i<26*4; i++) {
+    outcontrol(2);
+  }
+  outcontrol(3);
+}
+void outstart() {
+  // send start code: pin 14 high, 5ms to let a dmx cycle finish,
+  // then pin14 low (pin1 stays low)
+  outcontrol(1);
+  usleep(5000);
+  outcontrol(3);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/parport.i	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,11 @@
+%module parport
+
+
+extern void getparport();
+extern void outdata( unsigned char val);
+extern void outcontrol( unsigned char val );
+extern void outbyte( unsigned char val );
+extern void outstart();
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/serport.i	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,50 @@
+%module serport
+
+%{
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/ioctl.h>
+#include <fcntl.h>
+#include <linux/i2c.h>
+#include <linux/i2c-dev.h>
+#include <unistd.h>
+%}
+
+
+%typemap(python,in) __u8 {
+  if( !PyInt_Check($input)) {
+    PyErr_SetString(PyExc_TypeError,"not an integer");
+    return NULL;
+  }
+  $1 = ($type) PyInt_AsLong($input);
+}
+
+%typemap(python,out) __s32 {
+  $result = Py_BuildValue("i", ($type) $1);
+}
+
+%inline %{
+
+  __s32 i2c_smbus_write_byte(int file, __u8 value);
+  __s32 i2c_smbus_read_byte(int file);
+
+  PyObject *read_all_adc(int file) {
+    PyObject *t=PyTuple_New(4);
+    
+    #define CHAN_TO_TUPLE_POS(chan,idx) i2c_smbus_write_byte(file, chan);\
+    PyTuple_SetItem(t,idx,PyInt_FromLong(i2c_smbus_read_byte(file)));
+
+    /*
+      these are shuffled here to match the way the pots read in. in
+      the returned tuple, 0=left pot..3=right pot.
+    */
+    CHAN_TO_TUPLE_POS(1,0)
+    CHAN_TO_TUPLE_POS(2,1)
+    CHAN_TO_TUPLE_POS(3,2)
+    CHAN_TO_TUPLE_POS(0,3)
+      
+    return t;
+
+  }
+
+%}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/io/udmx.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,62 @@
+import logging
+import usb.core
+from usb.util import CTRL_TYPE_VENDOR, CTRL_RECIPIENT_DEVICE, CTRL_OUT
+
+log = logging.getLogger('udmx')
+"""
+Send dmx to one of these:
+http://www.amazon.com/Interface-Adapter-Controller-Lighting-Freestyler/dp/B00W52VIOS
+
+[4520784.059479] usb 1-2.3: new low-speed USB device number 6 using xhci_hcd
+[4520784.157410] usb 1-2.3: New USB device found, idVendor=16c0, idProduct=05dc
+[4520784.157416] usb 1-2.3: New USB device strings: Mfr=1, Product=2, SerialNumber=3
+[4520784.157419] usb 1-2.3: Product: uDMX
+[4520784.157422] usb 1-2.3: Manufacturer: www.anyma.ch
+[4520784.157424] usb 1-2.3: SerialNumber: ilLUTZminator001
+
+See https://www.illutzmination.de/udmxfirmware.html?&L=1
+    sources/commandline/uDMX.c
+or https://github.com/markusb/uDMX-linux/blob/master/uDMX.c
+"""
+
+cmd_SetChannelRange = 0x0002
+
+
+class Udmx:
+
+    def __init__(self, bus):
+        self.dev = None
+        for dev in usb.core.find(idVendor=0x16c0,
+                                 idProduct=0x05dc,
+                                 find_all=True):
+            print("udmx device at %r" % dev.bus)
+            if bus is None or bus == dev.bus:
+                self.dev = dev
+        if not self.dev:
+            raise IOError('no matching udmx device found for requested bus %r' %
+                          bus)
+        log.info('found udmx at %r', self.dev)
+
+    def SendDMX(self, buf):
+        ret = self.dev.ctrl_transfer(bmRequestType=CTRL_TYPE_VENDOR |
+                                     CTRL_RECIPIENT_DEVICE | CTRL_OUT,
+                                     bRequest=cmd_SetChannelRange,
+                                     wValue=len(buf),
+                                     wIndex=0,
+                                     data_or_wLength=buf)
+        if ret < 0:
+            raise ValueError("ctrl_transfer returned %r" % ret)
+
+
+def demo(chan, fps=44):
+    import time, math
+    u = Udmx()
+    while True:
+        nsin = math.sin(time.time() * 6.28) / 2.0 + 0.5
+        nsin8 = int(255 * nsin)
+        try:
+            u.SendDMX('\x00' * (chan - 1) + chr(210) + chr(nsin8) + chr(nsin8) +
+                      chr(nsin8))
+        except usb.core.USBError as e:
+            print("err", time.time(), repr(e))
+        time.sleep(1 / fps)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/localsyncedgraph.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+from rdflib import ConjunctiveGraph
+
+from rdfdb.syncedgraph.currentstategraphapi import CurrentStateGraphApi
+from rdfdb.syncedgraph.autodepgraphapi import AutoDepGraphApi
+from rdfdb.syncedgraph.grapheditapi import GraphEditApi
+from rdfdb.rdflibpatch import patchQuads
+
+
+class LocalSyncedGraph(AutoDepGraphApi, GraphEditApi):
+    """for tests"""
+
+    def __init__(self, files=None):
+        self._graph = ConjunctiveGraph()
+        for f in files or []:
+            self._graph.parse(f, format='n3')
+
+    def patch(self, p):
+        patchQuads(self._graph, deleteQuads=p.delQuads, addQuads=p.addQuads, perfect=True)
+        # no deps
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/metrics.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,120 @@
+"""for easier porting, and less boilerplate, allow these styles using the
+form of the call to set up the right type of metric automatically:
+
+  from metrics import metrics
+  metrics.setProcess('pretty_name')
+
+  @metrics('loop').time()              # a common one to get the fps of each service. Gets us qty and time
+  def frame():
+      if err:
+         metrics('foo_errors').incr()  # if you incr it, it's a counter
+
+  @metrics('foo_calls').time()         # qty & time because it's a decorator
+  def foo(): 
+
+  metrics('goal_fps').set(f)           # a gauge because we called set()
+
+  with metrics('recompute'): ...       # ctxmgr also makes a timer
+     time_this_part()
+
+I don't see a need for labels yet, but maybe some code will want like
+metrics('foo', label1=one). Need histogram? Info?
+
+"""
+from typing import Dict, Tuple, Callable, Type, TypeVar, cast
+from prometheus_client import Counter, Gauge, Metric, Summary
+from prometheus_client.exposition import generate_latest
+from prometheus_client.registry import REGISTRY
+
+_created: Dict[str, Metric] = {}
+
+# _process=sys.argv[0]
+# def setProcess(name: str):
+#   global _process
+#   _process = name
+
+MT = TypeVar("MT")
+
+
+class _MetricsRequest:
+
+    def __init__(self, name: str, **labels):
+        self.name = name
+        self.labels = labels
+
+    def _ensure(self, cls: Type[MT]) -> MT:
+        if self.name not in _created:
+            _created[self.name] = cls(name=self.name, documentation=self.name, labelnames=self.labels.keys())
+        m = _created[self.name]
+        if self.labels:
+            m = m.labels(**self.labels)
+        return m
+
+    def __call__(self, fn) -> Callable:
+        return timed_fn
+
+    def set(self, v: float):
+        self._ensure(Gauge).set(v)
+
+    def inc(self):
+        self._ensure(Counter).inc()
+
+    def offset(self, amount: float):
+        self._ensure(Gauge).inc(amount)
+
+    def time(self):
+        return self._ensure(Summary).time()
+
+    def observe(self, x: float):
+        return self._ensure(Summary).observe(x)
+
+    def __enter__(self):
+        return self._ensure(Summary).__enter__()
+
+
+def metrics(name: str, **labels):
+    return _MetricsRequest(name, **labels)
+
+
+
+
+"""
+stuff we used to have in greplin. Might be nice to get (client-side-computed) min/max/stddev back.
+
+class PmfStat(Stat):
+  A stat that stores min, max, mean, standard deviation, and some
+  percentiles for arbitrary floating-point data. This is potentially a
+  bit expensive, so its child values are only updated once every
+  twenty seconds.
+
+
+
+
+
+i think prometheus covers this one:
+
+import psutil
+def gatherProcessStats():
+    procStats = scales.collection('/process',
+                                  scales.DoubleStat('time'),
+                                  scales.DoubleStat('cpuPercent'),
+                                  scales.DoubleStat('memMb'),
+    )
+    proc = psutil.Process()
+    lastCpu = [0.]
+    def updateTimeStat():
+        now = time.time()
+        procStats.time = round(now, 3)
+        if now - lastCpu[0] > 3:
+            procStats.cpuPercent = round(proc.cpu_percent(), 6) # (since last call)
+            lastCpu[0] = now
+        procStats.memMb = round(proc.memory_info().rss / 1024 / 1024, 6)
+    task.LoopingCall(updateTimeStat).start(.1)
+
+"""
+
+
+class M:
+
+    def __call__(self, name):
+        return
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/midifade/midifade.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,240 @@
+#!bin/python
+"""
+Read midi events, write fade levels to graph
+"""
+import asyncio
+import logging
+import traceback
+from typing import Dict, List, cast
+from light9.effect.edit import clamp
+
+import mido
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDF, ConjunctiveGraph, Literal, URIRef
+from rdfdb.syncedgraph.readonly_graph import ReadOnlyConjunctiveGraph
+from light9 import networking
+from light9.namespaces import L9
+from light9.newtypes import decimalLiteral
+from light9.run_local import log
+from light9.showconfig import showUri
+
+mido.set_backend('alsa_midi.mido_backend')
+MAX_SEND_RATE = 30
+
+_lastSet = {}  #midictlchannel:value7bit
+
+currentFaders = {}  # midi control channel num : FaderUri
+ctx = URIRef(showUri() + '/fade')
+
+
+def compileCurrents(graph):
+    currentFaders.clear()
+    try:
+        new = getChansToFaders(graph)
+    except ValueError:
+        return  # e.g. empty-graph startup
+    currentFaders.update(new)
+
+
+def getGraphMappingNode(g: ReadOnlyConjunctiveGraph | SyncedGraph) -> URIRef:
+    mapping = g.value(L9['midiControl'], L9['map'])
+    if mapping is None:
+        raise ValueError('no :midiControl :map ?mapping')
+    midiDev = g.value(mapping, L9['midiDev'])
+    ourDev = 'bcf2000'
+    if midiDev != Literal(ourDev):
+        raise NotImplementedError(f'need {mapping} to have :midiDev {ourDev!r}')
+    return mapping
+
+
+def getCurMappedPage(g: SyncedGraph):
+    mapping = getGraphMappingNode(g)
+    return g.value(mapping, L9['outputs'])
+
+
+def setCurMappedPage(g: SyncedGraph, mapping: URIRef, newPage: URIRef):
+    g.patchObject(ctx, mapping, L9.outputs, newPage)
+
+
+def getChansToFaders(g: SyncedGraph) -> Dict[int, URIRef]:
+    fadePage = getCurMappedPage(g)
+    ret = []
+    for f in g.objects(fadePage, L9.fader):
+        columnLit = cast(Literal, g.value(f, L9['column']))
+        col = int(columnLit.toPython())
+        ret.append((col, f))
+
+    ret.sort()
+    ctl_channels = list(range(81, 88 + 1))
+    out = {}
+    for chan, (col, f) in zip(ctl_channels, ret):
+        out[chan] = f
+    return out
+
+
+def changePage(g: SyncedGraph, dp: int):
+    """dp==-1, make the previous page active, etc. Writes to graph"""
+
+    with g.currentState() as current:
+        allPages = sorted(current.subjects(RDF.type, L9.FadePage), key=lambda fp: str(fp))
+        mapping = getGraphMappingNode(current)
+        curPage = current.value(mapping, L9.outputs)
+    if curPage is None:
+        curPage = allPages[0]
+    idx = allPages.index(curPage)
+    newIdx = clamp(idx + dp, 0, len(allPages) - 1)
+    print('change from ', idx, newIdx)
+    newPage = allPages[newIdx]
+    setCurMappedPage(g, mapping, newPage)
+
+
+def writeHwValueToGraph(graph: SyncedGraph, ctx, fader: URIRef, strength: float):
+    log.info(f'setFader(fader={fader}, strength={strength:.03f}')
+    valueLit = decimalLiteral(round(strength, 3))
+    with graph.currentState() as g:
+        fadeSet = g.value(fader, L9['setting'])
+    if fadeSet is None:
+        raise ValueError(f'fader {fader} has no :setting')
+    graph.patchObject(ctx, fadeSet, L9['value'], valueLit)
+
+
+def changeGrandMaster(graph: SyncedGraph, newValue: float):
+    graph.patchObject(ctx, L9.grandMaster, L9['value'], decimalLiteral(newValue))
+
+
+def onMessage(graph: SyncedGraph, ctx: URIRef, m: Dict):
+    if m['type'] == 'active_sensing':
+        return
+    if m['type'] == 'control_change':
+        if m['dev'] == 'bcf2000' and m['control'] == 91:
+            changePage(graph, -1)
+            return
+        if m['dev'] == 'bcf2000' and m['control'] == 92:
+            changePage(graph, 1)
+            return
+        if m['dev'] == 'bcf2000' and m['control'] == 8:
+            changeGrandMaster(graph, clamp(m['value'] / 127 * 1.5, 0, 1))
+            return
+
+        try:
+            fader = {
+                'quneo': {
+                    44: L9['show/dance2023/fadePage1f0'],
+                    45: L9['show/dance2023/fadePage1f0'],
+                    46: L9['show/dance2023/fadePage1f0'],
+                },
+                'bcf2000': currentFaders,
+            }[m['dev']][m['control']]
+        except KeyError:
+            log.info(f'unknown control {m}')
+            return
+        try:
+            writeHwValueToGraph(graph, ctx, fader, m['value'] / 127)
+            _lastSet[m['control']] = m['value']
+        except ValueError as e:
+            log.warning(f'{e!r} - ignoring')
+    else:
+        log.info(f'unhandled message {m}')
+
+
+def reduceToLatestValue(ms: List[Dict]) -> List[Dict]:
+    merge = {}
+    for m in ms:
+        normal_key = tuple(sorted(dict((k, v) for k, v in m.items() if k != 'value')))
+        merge[normal_key] = m
+    return merge.values()
+
+
+class WriteBackFaders:
+
+    def __init__(self, graph: SyncedGraph, bcf_out, getCurrentValue):
+        self.graph = graph
+        self.bcf_out = bcf_out
+        self.getCurrentValue = getCurrentValue
+
+    def update(self):
+        try:
+            self._update()
+        except ValueError as e:
+            log.warning(repr(e))
+
+    def _update(self):
+        g = self.graph
+        nupdated = 0
+        m = getChansToFaders(g)
+        for midi_ctl_addr, f in m.items():
+            fset = g.value(f, L9.setting)
+            # could split this to a separate handler per fader
+            value = g.value(fset, L9.value).toPython()
+            hwcurrent = self.getCurrentValue(midi_ctl_addr)
+            hwgoal = int(value * 127)
+            print(f'{f} {hwcurrent=} {hwgoal=}')
+            if abs(hwcurrent - hwgoal) > 2:
+                self.sendToBcf(midi_ctl_addr, hwgoal)
+                nupdated += 1
+        log.info(f'wrote to {nupdated} of {len(m)} mapped faders')
+
+    def sendToBcf(self, control, value):
+        _lastSet[control] = value
+        msg = mido.Message('control_change', control=control, value=value)
+        self.bcf_out.send(msg)
+
+
+async def main():
+    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
+    logging.getLogger('syncedgraph').setLevel(logging.INFO)
+    logging.getLogger('graphedit').setLevel(logging.INFO)
+
+    graph = SyncedGraph(networking.rdfdb.url, "midifade")
+    ctx = URIRef(showUri() + '/fade')
+
+    msgs = asyncio.Queue()
+    loop = asyncio.get_event_loop()
+
+    def onMessageMidoThread(dev, message):
+        loop.call_soon_threadsafe(msgs.put_nowait, message.dict() | {'dev': dev})
+
+    async def reader():
+        while True:
+            recents = [await msgs.get()]
+            while not msgs.empty():
+                recents.append(msgs.get_nowait())
+            try:
+                for msg in reduceToLatestValue(recents):
+                    onMessage(graph, ctx, msg)
+            except Exception as e:
+                traceback.print_exc()
+                log.warning("error in onMessage- continuing anyway")
+            await asyncio.sleep(1 / MAX_SEND_RATE)
+
+    asyncio.create_task(reader())
+    openPorts = []
+    for inputName in mido.get_input_names():  # type: ignore
+        if inputName.startswith('Keystation'):
+            dev = "keystation"
+        elif inputName.startswith('BCF2000'):
+            dev = 'bcf2000'
+        elif inputName.startswith('QUNEO'):
+            dev = 'quneo'
+        else:
+            continue
+        log.info(f'listening on input {inputName} {dev=}')
+        openPorts.append(mido.open_input(  # type: ignore
+            inputName,  #
+            callback=lambda message, dev=dev: onMessageMidoThread(dev, message)))
+
+    graph.addHandler(lambda: compileCurrents(graph))
+
+    for outputName in mido.get_output_names():  # type: ignore
+        if outputName.startswith('BCF2000'):
+            bcf_out = mido.open_output(outputName)  # type: ignore
+            wb = WriteBackFaders(graph, bcf_out, getCurrentValue=lambda f: _lastSet.get(f, 0))
+            graph.addHandler(wb.update)
+            break
+
+    while True:
+        await asyncio.sleep(1)
+
+
+if __name__ == '__main__':
+    asyncio.run(main())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/midifade/midifade_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,5 @@
+from light9.run_local import log
+
+
+def test_import():
+    import light9.midifade.midifade
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/mock_syncedgraph.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,59 @@
+from rdflib import Graph, RDF, RDFS
+from rdflib.parser import StringInputSource
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+
+
+class MockSyncedGraph(SyncedGraph):
+    """
+    Lets users of SyncedGraph mostly work. Doesn't yet help with any
+    testing of the rerun-upon-graph-change behavior.
+    """
+
+    def __init__(self, n3Content):
+        self._graph = Graph()
+        self._graph.parse(StringInputSource(n3Content), format='n3')
+
+    def addHandler(self, func):
+        func()
+
+    def value(self,
+              subject=None,
+              predicate=RDF.value,
+              object=None,
+              default=None,
+              any=True):
+        if object is not None:
+            raise NotImplementedError()
+        return self._graph.value(subject,
+                                 predicate,
+                                 object=object,
+                                 default=default,
+                                 any=any)
+
+    def objects(self, subject=None, predicate=None):
+        return self._graph.objects(subject, predicate)
+
+    def label(self, uri):
+        return self.value(uri, RDFS.label)
+
+    def subjects(self, predicate=None, object=None):
+        return self._graph.subjects(predicate, object)
+
+    def predicate_objects(self, subject):
+        return self._graph.predicate_objects(subject)
+
+    def items(self, listUri):
+        """generator. Having a chain of watchers on the results is not
+        well-tested yet"""
+        chain = set([listUri])
+        while listUri:
+            item = self.value(listUri, RDF.first)
+            if item:
+                yield item
+            listUri = self.value(listUri, RDF.rest)
+            if listUri in chain:
+                raise ValueError("List contains a recursive rdf:rest reference")
+            chain.add(listUri)
+
+    def contains(self, triple):
+        return triple in self._graph
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/namespaces.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,25 @@
+from rdflib import URIRef, Namespace, RDF, RDFS  # noqa
+from typing import Dict
+
+
+# Namespace was showing up in profiles
+class FastNs:
+
+    def __init__(self, base):
+        self.ns = Namespace(base)
+        self.cache: Dict[str, URIRef] = {}
+
+    def __getitem__(self, term) -> URIRef:
+        if term not in self.cache:
+            self.cache[term] = self.ns[term]
+        return self.cache[term]
+
+    __getattr__ = __getitem__
+
+
+L9 = FastNs("http://light9.bigasterisk.com/")
+FUNC = FastNs("http://light9.bigasterisk.com/effectFunction/")
+MUS = Namespace("http://light9.bigasterisk.com/music/")
+XSD = Namespace("http://www.w3.org/2001/XMLSchema#")
+DCTERMS = Namespace("http://purl.org/dc/terms/")
+DEV = Namespace("http://light9.bigasterisk.com/device/")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/networking.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,61 @@
+from urllib.parse import urlparse
+
+from rdflib import URIRef
+
+from .showconfig import getGraph, showUri
+from .namespaces import L9
+
+
+class ServiceAddress:
+
+    def __init__(self, service):
+        self.service = service
+
+    def _url(self) -> URIRef:
+        graph = getGraph()
+        net = graph.value(showUri(), L9['networking'])
+        ret = graph.value(net, self.service)
+        if ret is None:
+            raise ValueError("no url for %s -> %s -> %s" %
+                             (showUri(), L9['networking'], self.service))
+        assert isinstance(ret, URIRef)
+        return ret
+
+    @property
+    def port(self):
+        return urlparse(self._url()).port
+
+    @property
+    def host(self):
+        return urlparse(self._url()).hostname
+
+    @property
+    def url(self) -> URIRef:
+        return self._url()
+
+    value = url
+
+    def path(self, more: str) -> URIRef:
+        return URIRef(self.url + more)
+
+
+captureDevice = ServiceAddress(L9['captureDevice'])
+curveCalc = ServiceAddress(L9['curveCalc'])
+dmxServer = ServiceAddress(L9['dmxServer'])
+dmxServerZmq = ServiceAddress(L9['dmxServerZmq'])
+collector = ServiceAddress(L9['collector'])
+collectorZmq = ServiceAddress(L9['collectorZmq'])
+effectEval = ServiceAddress(L9['effectEval'])
+effectSequencer = ServiceAddress(L9['effectSequencer'])
+keyboardComposer = ServiceAddress(L9['keyboardComposer'])
+musicPlayer = ServiceAddress(L9['musicPlayer'])
+oscDmxServer = ServiceAddress(L9['oscDmxServer'])
+paintServer = ServiceAddress(L9['paintServer'])
+picamserve = ServiceAddress(L9['picamserve'])
+rdfdb = ServiceAddress(L9['rdfdb'])
+subComposer = ServiceAddress(L9['subComposer'])
+subServer = ServiceAddress(L9['subServer'])
+vidref = ServiceAddress(L9['vidref'])
+timeline = ServiceAddress(L9['timeline'])
+
+patchReceiverUpdateHost = ServiceAddress(L9['patchReceiverUpdateHost'])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/newtypes.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,43 @@
+from typing import NewType, Tuple, TypeVar, Union
+
+from rdflib import Literal, URIRef
+
+ClientType = NewType('ClientType', str)
+ClientSessionType = NewType('ClientSessionType', str)
+Curve = NewType('Curve', URIRef)
+OutputUri = NewType('OutputUri', URIRef)  # e.g. dmxA
+DeviceUri = NewType('DeviceUri', URIRef)  # e.g. :aura2
+DeviceClass = NewType('DeviceClass', URIRef)  # e.g. :Aura
+DmxIndex = NewType('DmxIndex', int)  # 1..512
+DmxMessageIndex = NewType('DmxMessageIndex', int)  # 0..511
+DeviceAttr = NewType('DeviceAttr', URIRef)  # e.g. :rx
+EffectFunction = NewType('EffectFunction', URIRef)  # e.g. func:strobe
+EffectUri = NewType('EffectUri', URIRef)  # unclear when to use this vs EffectClass
+EffectAttr = NewType('EffectAttr', URIRef)  # e.g. :chaseSpeed
+NoteUri = NewType('NoteUri', URIRef)
+OutputAttr = NewType('OutputAttr', URIRef)  # e.g. :xFine
+OutputValue = NewType('OutputValue', int)  # byte in dmx message
+Song = NewType('Song', URIRef)
+UnixTime = NewType('UnixTime', float)
+
+VT = TypeVar('VT', float, int, str)  # remove
+HexColor = NewType('HexColor', str)
+VTUnion = Union[float, int, HexColor]  # rename to ValueType
+DeviceSetting = Tuple[DeviceUri, DeviceAttr,
+                      # currently, floats and hex color strings
+                      VTUnion]
+
+# Alternate output range for a device. Instead of outputting 0.0 to
+# 1.0, you can map that range into, say, 0.2 to 0.7
+OutputRange = NewType('OutputRange', Tuple[float, float])
+
+
+def uriTail(u: URIRef) -> str:
+    tail = u.rstrip('/').rsplit('/', 1)[1]
+    if not tail:
+        tail = str(u)
+    return tail
+
+
+def decimalLiteral(value):
+    return Literal(value, datatype='http://www.w3.org/2001/XMLSchema#decimal')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/observable.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,38 @@
+import logging
+log = logging.getLogger('observable')
+
+
+class _NoNewVal:
+    pass
+
+
+class Observable:
+    """
+    like knockout's observable. Hopefully this can be replaced by a
+    better python one
+
+    compare with:
+    http://knockoutjs.com/documentation/observables.html
+    https://github.com/drpancake/python-observable/blob/master/observable/observable.py
+    """
+
+    def __init__(self, val):
+        self.val = val
+        self.subscribers = set()
+
+    def __call__(self, newVal=_NoNewVal):
+        if newVal is _NoNewVal:
+            return self.val
+        if newVal == self.val:
+            log.debug("%r unchanged from %r", newVal, self.val)
+            return
+        self.val = newVal
+        for s in self.subscribers:
+            s(newVal)
+
+    def subscribe(self, cb, callNow=True):
+        """cb is called with new values, and also right now with the
+        current value unless you opt out"""
+        self.subscribers.add(cb)
+        if callNow:
+            cb(self.val)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/paint/capture.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,50 @@
+import os
+from rdflib import URIRef
+from light9 import showconfig
+from rdfdb.patch import Patch
+from light9.namespaces import L9, RDF
+from light9.paint.solve import loadNumpy
+
+
+def writeCaptureDescription(graph, ctx, session, uri, dev, outPath,
+                            settingsSubgraphCache, settings):
+    graph.patch(
+        Patch(addQuads=settings.statements(
+            uri,
+            ctx=ctx,
+            settingRoot=URIRef('/'.join(
+                [showconfig.showUri(), 'capture',
+                 dev.rsplit('/')[1]])),
+            settingsSubgraphCache=settingsSubgraphCache)))
+    graph.patch(
+        Patch(addQuads=[
+            (dev, L9['capture'], uri, ctx),
+            (session, L9['capture'], uri, ctx),
+            (uri, RDF.type, L9['LightSample'], ctx),
+            (uri, L9['imagePath'],
+             URIRef('/'.join([showconfig.showUri(), outPath])), ctx),
+        ]))
+    graph.suggestPrefixes(
+        ctx, {
+            'cap': uri.rsplit('/', 1)[0] + '/',
+            'showcap': showconfig.showUri() + '/capture/'
+        })
+
+
+class CaptureLoader(object):
+
+    def __init__(self, graph):
+        self.graph = graph
+
+    def loadImage(self, pic, thumb=(100, 100)):
+        ip = self.graph.value(pic, L9['imagePath'])
+        if not ip.startswith(showconfig.show()):
+            raise ValueError(repr(ip))
+        diskPath = os.path.join(showconfig.root(), ip[len(self.show):])
+        return loadNumpy(diskPath, thumb)
+
+    def devices(self):
+        """devices for which we have any captured data"""
+
+    def capturedSettings(self, device):
+        """list of (pic, settings) we know for this device"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/paint/solve.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,307 @@
+from typing import List
+
+from rdflib import URIRef
+import imageio
+from light9.namespaces import L9, DEV
+from PIL import Image
+import numpy
+import scipy.misc, scipy.ndimage, scipy.optimize
+import cairo
+import logging
+
+from light9.effect.settings import DeviceSettings, parseHex
+
+log = logging.getLogger('solve')
+
+# numpy images in this file are (x, y, c) layout.
+
+
+def numpyFromCairo(surface):
+    w, h = surface.get_width(), surface.get_height()
+    a = numpy.frombuffer(surface.get_data(), numpy.uint8)
+    a.shape = h, w, 4
+    a = a.transpose((1, 0, 2))
+    return a[:w, :h, :3]
+
+
+def numpyFromPil(img: Image.Image):
+    return numpy.asarray(img).transpose((1, 0, 2))
+
+
+def loadNumpy(path, thumb=(100, 100)):
+    img = Image.open(path)
+    img.thumbnail(thumb)
+    return numpyFromPil(img)
+
+
+def saveNumpy(path, img):
+    # maybe this should only run if log level is debug?
+    imageio.imwrite(path, img.transpose((1, 0, 2)))
+
+
+def scaledHex(h, scale):
+    rgb = parseHex(h)
+    rgb8 = (rgb * scale).astype(numpy.uint8)
+    return '#%02x%02x%02x' % tuple(rgb8)
+
+
+def colorRatio(col1, col2):
+    rgb1 = parseHex(col1)
+    rgb2 = parseHex(col2)
+
+    def div(x, y):
+        if y == 0:
+            return 0
+        return round(x / y, 3)
+
+    return tuple([div(a, b) for a, b in zip(rgb1, rgb2)])
+
+
+def brightest(img):
+    return numpy.amax(img, axis=(0, 1))
+
+
+class ImageDist(object):
+
+    def __init__(self, img1):
+        self.a = img1.reshape((-1,))
+        self.d = 255 * 255 * self.a.shape[0]
+
+    def distanceTo(self, img2):
+        b = img2.reshape((-1,))
+        return 1 - numpy.dot(self.a, b) / self.d
+
+
+class ImageDistAbs(object):
+
+    def __init__(self, img1):
+        self.a = img1
+        self.maxDist = img1.shape[0] * img1.shape[1] * img1.shape[2] * 255
+
+    def distanceTo(self, img2):
+        return numpy.sum(numpy.absolute(self.a - img2), axis=None) / self.maxDist
+
+
+class Solver(object):
+
+    def __init__(self, graph, sessions:List[URIRef]|None=None, imgSize=(100, 53)):
+        self.graph = graph
+        self.sessions = sessions  # URIs of capture sessions to load
+        self.imgSize = imgSize
+        self.samples = {}  # uri: Image array (float 0-255)
+        self.fromPath = {}  # imagePath: image array
+        self.path = {}  # sample: path
+        self.blurredSamples = {}
+        self.sampleSettings = {}  # sample: DeviceSettings
+        self.samplesForDevice = {}  # dev : [(sample, img)]
+
+    def loadSamples(self):
+        """learn what lights do from images"""
+
+        log.info('loading...')
+
+        with self.graph.currentState() as g:
+            for sess in self.sessions or []:
+                for cap in g.objects(sess, L9['capture']):
+                    self._loadSample(g, cap)
+        log.info('loaded %s samples', len(self.samples))
+
+    def _loadSample(self, g, samp):
+        pathUri = g.value(samp, L9['imagePath'])
+        img = loadNumpy(pathUri.replace(L9[''], '')).astype(float)
+        settings = DeviceSettings.fromResource(self.graph, samp)
+
+        self.samples[samp] = img
+        self.fromPath[pathUri] = img
+        self.blurredSamples[samp] = self._blur(img)
+
+        self.path[samp] = pathUri
+        assert samp not in self.sampleSettings
+        self.sampleSettings[samp] = settings
+        devs = settings.devices()
+        if len(devs) == 1:
+            self.samplesForDevice.setdefault(devs[0], []).append((samp, img))
+
+    def _blur(self, img):
+        return scipy.ndimage.gaussian_filter(img, 10, 0, mode='nearest')
+
+    def draw(self, painting):
+        return self._draw(painting, self.imgSize[0], self.imgSize[1])
+
+    def _draw(self, painting, w, h):
+        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
+        ctx = cairo.Context(surface)
+        ctx.rectangle(0, 0, w, h)
+        ctx.fill()
+
+        ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+        ctx.set_line_width(w / 15)  # ?
+        for stroke in painting['strokes']:
+            for pt in stroke['pts']:
+                op = ctx.move_to if pt is stroke['pts'][0] else ctx.line_to
+                op(pt[0] * w, pt[1] * h)
+
+            r, g, b = parseHex(stroke['color'])
+            ctx.set_source_rgb(r / 255, g / 255, b / 255)
+            ctx.stroke()
+
+        #surface.write_to_png('/tmp/surf.png')
+        return numpyFromCairo(surface)
+
+    def bestMatch(self, img, device=None):
+        """the one sample that best matches this image"""
+        #img = self._blur(img)
+        results = []
+        dist = ImageDist(img)
+        if device is None:
+            items = self.samples.items()
+        else:
+            items = self.samplesForDevice[device]
+        for uri, img2 in sorted(items):
+            if img.shape != img2.shape:
+                log.warn("mismatch %s %s", img.shape, img2.shape)
+                continue
+            results.append((dist.distanceTo(img2), uri, img2))
+        results.sort()
+        topDist, topUri, topImg = results[0]
+        print('tops2')
+        for row in results[:4]:
+            print('%.5f' % row[0], row[1][-20:], self.sampleSettings[row[1]])
+
+        #saveNumpy('/tmp/best_in.png', img)
+        #saveNumpy('/tmp/best_out.png', topImg)
+        #saveNumpy('/tmp/mult.png', topImg / 255 * img)
+        return topUri, topDist
+
+    def bestMatches(self, img, devices:List[URIRef]|None=None):
+        """settings for the given devices that point them each
+        at the input image"""
+        dist = ImageDist(img)
+        devSettings = []
+        for dev in devices or []:
+            results = []
+            for samp, img2 in self.samplesForDevice[dev]:
+                results.append((dist.distanceTo(img2), samp))
+            results.sort()
+
+            s = self.blendResults([(d, self.sampleSettings[samp]) for d, samp in results[:8]])
+            devSettings.append(s)
+        return DeviceSettings.fromList(self.graph, devSettings)
+
+    def blendResults(self, results):
+        """list of (dist, settings)"""
+
+        dists = [d for d, sets in results]
+        hi = max(dists)
+        lo = min(dists)
+        n = len(results)
+        remappedDists = [1 - (d - lo) / (hi - lo) * n / (n + 1) for d in dists]
+        total = sum(remappedDists)
+
+        #print 'blend'
+        #for o,n in zip(dists, remappedDists):
+        #    print o,n, n / total
+        blend = DeviceSettings.fromBlend(self.graph, [(d / total, sets) for d, (_, sets) in zip(remappedDists, results)])
+        return blend
+
+    def solve(self, painting):
+        """
+        given strokes of colors on a photo of the stage, figure out the
+        best light DeviceSettings to match the image
+        """
+        pic0 = self.draw(painting).astype(numpy.float64)
+        pic0Blur = self._blur(pic0)
+        saveNumpy('/tmp/sample_paint_%s.png' % len(painting['strokes']), pic0Blur)
+        sampleDist = {}
+        dist = ImageDist(pic0Blur)
+        for sample, picSample in sorted(self.blurredSamples.items()):
+            #saveNumpy('/tmp/sample_%s.png' % sample.split('/')[-1],
+            #          f(picSample))
+            sampleDist[sample] = dist.distanceTo(picSample)
+        results = sorted([(d, uri) for uri, d in sampleDist.items()])
+
+        sample = results[0][1]
+
+        # this is wrong; some wrong-alignments ought to be dimmer than full
+        brightest0 = brightest(pic0)
+        #brightestSample = brightest(self.samples[sample])
+
+        if max(brightest0) < 1 / 255:
+            return DeviceSettings(self.graph, [])
+
+        #scale = brightest0 / brightestSample
+
+        s = DeviceSettings.fromResource(self.graph, sample)
+        # missing color scale, but it was wrong to operate on all devs at once
+        return s
+
+    def solveBrute(self, painting):
+        pic0 = self.draw(painting).astype(numpy.float64)
+
+        colorSteps = 2
+        colorStep = 1. / colorSteps
+
+        # use toVector then add ranges
+        dims = [
+            (DEV['aura1'], L9['rx'], [slice(.2, .7 + .1, .2)]),
+            (DEV['aura1'], L9['ry'], [slice(.573, .573 + 1, 1)]),
+            (DEV['aura1'], L9['color'], [slice(0, 1 + colorStep, colorStep),
+                                         slice(0, 1 + colorStep, colorStep),
+                                         slice(0, 1 + colorStep, colorStep)]),
+        ]
+        deviceAttrFilter = [(d, a) for d, a, s in dims]
+
+        dist = ImageDist(pic0)
+
+        def drawError(x):
+            settings = DeviceSettings.fromVector(self.graph, x, deviceAttrFilter=deviceAttrFilter)
+            preview = self.combineImages(self.simulationLayers(settings))
+            #saveNumpy('/tmp/x_%s.png' % abs(hash(settings)), preview)
+
+            out = dist.distanceTo(preview)
+
+            #print 'measure at', x, 'drawError=', out
+            return out
+
+        x0, fval, grid, Jout = scipy.optimize.brute(func=drawError, ranges=sum([s for dev, da, s in dims], []), finish=None, disp=True, full_output=True)
+        if fval > 30000:
+            raise ValueError('solution has error of %s' % fval)
+        return DeviceSettings.fromVector(self.graph, x0, deviceAttrFilter=deviceAttrFilter)
+
+    def combineImages(self, layers):
+        """make a result image from our self.samples images"""
+        out = (next(iter(self.fromPath.values())) * 0).astype(numpy.uint16)
+        for layer in layers:
+            colorScaled = self.fromPath[layer['path']] * layer['color']
+            out += colorScaled.astype(numpy.uint16)
+        numpy.clip(out, 0, 255, out)
+        return out.astype(numpy.uint8)
+
+    def simulationLayers(self, settings):
+        """
+        how should a simulation preview approximate the light settings
+        (device attribute values) by combining photos we have?
+        """
+        assert isinstance(settings, DeviceSettings)
+        layers = []
+
+        for dev, devSettings in settings.byDevice():
+            requestedColor = devSettings.getValue(dev, L9['color'])
+            candidatePics = []  # (distance, path, picColor)
+            for sample, s in self.sampleSettings.items():
+                path = self.path[sample]
+                otherDevSettings = s.ofDevice(dev)
+                if not otherDevSettings:
+                    continue
+                dist = devSettings.distanceTo(otherDevSettings)
+                log.info('  candidate pic %s %s dist=%s', sample, path, dist)
+                candidatePics.append((dist, path, s.getValue(dev, L9['color'])))
+            candidatePics.sort()
+            # we could even blend multiple top candidates, or omit all
+            # of them if they're too far
+            bestDist, bestPath, bestPicColor = candidatePics[0]
+            log.info('  device best d=%g path=%s color=%s', bestDist, bestPath, bestPicColor)
+
+            layers.append({'path': bestPath, 'color': colorRatio(requestedColor, bestPicColor)})
+
+        return layers
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/paint/solve_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,157 @@
+import unittest
+import numpy.testing
+from . import solve
+from rdflib import Namespace
+from light9.namespaces import L9, DEV
+from light9.localsyncedgraph import LocalSyncedGraph
+from light9.effect.settings import DeviceSettings
+
+
+class TestSolve(unittest.TestCase):
+
+    def setUp(self):
+        self.graph = LocalSyncedGraph(
+            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+        self.solver = solve.Solver(self.graph,
+                                   imgSize=(100, 48),
+                                   sessions=[L9['session0']])
+        self.solver.loadSamples()
+        self.solveMethod = self.solver.solve
+
+    @unittest.skip('solveBrute unfinished')
+    def testBlack(self):
+        devAttrs = self.solveMethod({'strokes': []})
+        self.assertEqual(DeviceSettings(self.graph, []), devAttrs)
+
+    @unittest.skip("unfinished")
+    def testSingleLightCloseMatch(self):
+        devAttrs = self.solveMethod({
+            'strokes': [{
+                'pts': [[224, 141], [223, 159]],
+                'color': '#ffffff'
+            }]
+        })
+        self.assertEqual(
+            DeviceSettings(self.graph, [
+                (DEV['aura1'], L9['color'], "#ffffff"),
+                (DEV['aura1'], L9['rx'], 0.5),
+                (DEV['aura1'], L9['ry'], 0.573),
+            ]), devAttrs)
+
+
+class TestSolveBrute(TestSolve):
+
+    def setUp(self):
+        super(TestSolveBrute, self).setUp()
+        self.solveMethod = self.solver.solveBrute
+
+
+CAM_TEST = Namespace('http://light9.bigasterisk.com/test/cam/')
+
+
+class TestSimulationLayers(unittest.TestCase):
+
+    def setUp(self):
+        self.graph = LocalSyncedGraph(
+            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+        self.solver = solve.Solver(self.graph,
+                                   imgSize=(100, 48),
+                                   sessions=[L9['session0']])
+        self.solver.loadSamples()
+
+    def testBlack(self):
+        self.assertEqual([],
+                         self.solver.simulationLayers(
+                             settings=DeviceSettings(self.graph, [])))
+
+    def testPerfect1Match(self):
+        layers = self.solver.simulationLayers(
+            settings=DeviceSettings(self.graph, [(
+                DEV['aura1'], L9['color'],
+                "#ffffff"), (DEV['aura1'], L9['rx'],
+                             0.5), (DEV['aura1'], L9['ry'], 0.573)]))
+        self.assertEqual([{
+            'path': CAM_TEST['bg2-d.jpg'],
+            'color': (1., 1., 1.)
+        }], layers)
+
+    def testPerfect1MatchTinted(self):
+        layers = self.solver.simulationLayers(
+            settings=DeviceSettings(self.graph, [(
+                DEV['aura1'], L9['color'],
+                "#304050"), (DEV['aura1'], L9['rx'],
+                             0.5), (DEV['aura1'], L9['ry'], 0.573)]))
+        self.assertEqual([{
+            'path': CAM_TEST['bg2-d.jpg'],
+            'color': (.188, .251, .314)
+        }], layers)
+
+    def testPerfect2Matches(self):
+        layers = self.solver.simulationLayers(
+            settings=DeviceSettings(self.graph, [
+                (DEV['aura1'], L9['color'], "#ffffff"),
+                (DEV['aura1'], L9['rx'], 0.5),
+                (DEV['aura1'], L9['ry'], 0.573),
+                (DEV['aura2'], L9['color'], "#ffffff"),
+                (DEV['aura2'], L9['rx'], 0.7),
+                (DEV['aura2'], L9['ry'], 0.573),
+            ]))
+        self.assertEqual([
+            {
+                'path': CAM_TEST['bg2-d.jpg'],
+                'color': (1, 1, 1)
+            },
+            {
+                'path': CAM_TEST['bg2-f.jpg'],
+                'color': (1, 1, 1)
+            },
+        ], layers)
+
+
+class TestCombineImages(unittest.TestCase):
+
+    def setUp(self):
+        graph = LocalSyncedGraph(
+            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+        self.solver = solve.Solver(graph,
+                                   imgSize=(100, 48),
+                                   sessions=[L9['session0']])
+        self.solver.loadSamples()
+
+    def fixme_test(self):
+        out = self.solver.combineImages(layers=[
+            {
+                'path': CAM_TEST['bg2-d.jpg'],
+                'color': (.2, .2, .3)
+            },
+            {
+                'path': CAM_TEST['bg2-a.jpg'],
+                'color': (.888, 0, .3)
+            },
+        ])
+        solve.saveNumpy('/tmp/t.png', out)
+        golden = solve.loadNumpy('test/cam/layers_out1.png')
+        numpy.testing.assert_array_equal(golden, out)
+
+
+class TestBestMatch(unittest.TestCase):
+
+    def setUp(self):
+        graph = LocalSyncedGraph(
+            files=['test/cam/lightConfig.n3', 'test/cam/bg.n3'])
+        self.solver = solve.Solver(graph,
+                                   imgSize=(100, 48),
+                                   sessions=[L9['session0']])
+        self.solver.loadSamples()
+
+    def testRightSide(self):
+        drawingOnRight = {
+            "strokes": [{
+                "pts": [[0.875, 0.64], [0.854, 0.644]],
+                "color": "#aaaaaa"
+            }]
+        }
+        drawImg = self.solver.draw(drawingOnRight)
+        match, dist = self.solver.bestMatch(drawImg)
+        self.assertEqual(L9['sample5'], match)
+        self.assertAlmostEqual(0.983855965, dist, places=1)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/prof.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,65 @@
+import sys, traceback, time, logging
+from typing import Any, Dict
+log = logging.getLogger()
+
+
+def run(main, profile=None):
+    if not profile:
+        main()
+        return
+
+    if profile == 'hotshot':
+        import hotshot, hotshot.stats
+        p = hotshot.Profile("/tmp/pro")
+        p.runcall(main)
+        p.close()
+        hotshot.stats.load("/tmp/pro").sort_stats('cumulative').print_stats()
+    elif profile == 'stat':
+        import statprof
+        statprof.start()
+        try:
+            main()
+        finally:
+            statprof.stop()
+            statprof.display()
+
+
+def watchPoint(filename, lineno, event="call"):
+    """whenever we hit this line, print a stack trace. event='call'
+    for lines that are function definitions, like what a profiler
+    gives you.
+
+    Switch to 'line' to match lines inside functions. Execution speed
+    will be much slower."""
+    seenTraces: Dict[Any, int] = {}  # trace contents : count
+
+    def trace(frame, ev, arg):
+        if ev == event:
+            if (frame.f_code.co_filename, frame.f_lineno) == (filename, lineno):
+                stack = ''.join(traceback.format_stack(frame))
+                if stack not in seenTraces:
+                    print("watchPoint hit")
+                    print(stack)
+                    seenTraces[stack] = 1
+                else:
+                    seenTraces[stack] += 1
+
+        return trace
+
+    sys.settrace(trace)
+
+    # atexit, print the frequencies?
+
+
+def logTime(func):
+
+    def inner(*args, **kw):
+        t1 = time.time()
+        try:
+            ret = func(*args, **kw)
+        finally:
+            log.info("Call to %s took %.1f ms" % (func.__name__, 1000 *
+                                                  (time.time() - t1)))
+        return ret
+
+    return inner
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/rdfdb/service.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,28 @@
+import logging
+import os
+from pathlib import Path
+
+from light9.run_local import log
+
+import rdfdb.service
+from rdflib import URIRef
+
+from light9 import showconfig
+logging.getLogger('rdfdb').setLevel(logging.INFO)
+logging.getLogger('rdfdb.file').setLevel(logging.INFO)
+logging.getLogger('rdfdb.graph').setLevel(logging.INFO)
+logging.getLogger('rdfdb.net').setLevel(logging.INFO)
+rdfRoot = Path(os.environ['LIGHT9_SHOW'].rstrip('/') + '/')
+showUri = URIRef(showconfig.showUri() + '/')
+
+app = rdfdb.service.makeApp(  #
+    dirUriMap={rdfRoot: showUri},
+    prefixes={
+        'show': showUri,
+        '': URIRef('http://light9.bigasterisk.com/'),
+        'rdf': URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#'),
+        'rdfs': URIRef('http://www.w3.org/2000/01/rdf-schema#'),
+        'xsd': URIRef('http://www.w3.org/2001/XMLSchema#'),
+        'effect': URIRef('http://light9.bigasterisk.com/effect/'),
+        'dev': URIRef('http://light9.bigasterisk.com/theater/skyline/device/'),
+    })
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/rdfdb/service_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,11 @@
+import asyncio
+from light9.run_local import log
+
+
+def test_import():
+
+    async def go():
+        # this sets up some watcher tasks
+        from light9.rdfdb.service import app
+
+    asyncio.run(go(), debug=True)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/recentfps.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,30 @@
+# server side version of what the metrics consumer does with changing counts
+import time
+
+class RecentFps:
+  def __init__(self, window=20):
+    self.window = window
+    self.recentTimes = []
+
+  def mark(self):
+    now = time.time()
+    self.recentTimes.append(now)
+    self.recentTimes = self.recentTimes[-self.window:]
+
+  def rate(self):
+    def dec(innerFunc):
+      def f(*a, **kw):
+        self.mark()
+        return innerFunc(*a, **kw)
+      return f
+    return dec
+
+  def __call__(self):
+    if len(self.recentTimes) < 2:
+      return {}
+    recents = sorted(round(1 / (b - a), 3)
+                      for a, b in zip(self.recentTimes[:-1],
+                                      self.recentTimes[1:]))
+    avg = (len(self.recentTimes) - 1) / (
+      self.recentTimes[-1] - self.recentTimes[0])
+    return {'average': round(avg, 5), 'recents': recents}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/run_local.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,63 @@
+# bootstrap
+
+import re
+import logging
+import os
+import socket
+import sys
+from colorsys import hsv_to_rgb
+
+import coloredlogs
+
+try:
+    import faulthandler
+    faulthandler.enable()
+except ImportError:
+    pass
+
+if 0:
+    from IPython.core import ultratb
+    sys.excepthook = ultratb.FormattedTB(mode='Verbose', color_scheme='Linux', call_pdb=1)
+
+progName = sys.argv[0].split('/')[-1]
+log = logging.getLogger()  # this has to get the root logger
+log.name = progName  # but we can rename it for clarity
+
+coloredlogs.install(
+    level='DEBUG',
+    milliseconds=True,
+    datefmt='t=%H:%M:%S',
+    fmt='%(asctime)s.%(msecs)03d %(levelname)1.1s [%(filename)s:%(lineno)s] %(name)s %(message)s',
+    # try `pdm run humanfriendly --demo`
+    field_styles=dict(
+        asctime=dict(color=30),
+        levelname=dict(color=247),
+        name=dict(color='blue'),
+    ),
+    level_styles={
+        'debug': dict(color=115),
+        'info': dict(color=250),
+        'warning': dict(color=208),
+        'error': dict(color=161),
+        'critical': dict(color=196),
+    },
+)
+
+
+def setTerminalTitle(s):
+    if os.environ.get('TERM', '') in ['xterm', 'rxvt', 'rxvt-unicode-256color']:
+        m = re.search(r'(light9\..*):', s)
+        if m is not None:
+            s = m.group(1)
+        s = s.replace('/home/drewp/own/proj/','')
+        print("\033]0;%s\007" % s)  # not escaped/protected correctly
+        hue = (hash(progName) % 255) / 255
+        r, g, b = [int(x * 255) for x in hsv_to_rgb(hue, s=.4, v=.1)]
+        print(f"\033]11;#{r:02x}{g:02x}{b:02x}\007")
+
+
+if 'listsongs' not in sys.argv[0] and 'homepageConfig' not in sys.argv[0]:
+    setTerminalTitle('[%s] %s' % (socket.gethostname(), ' '.join(sys.argv).replace('bin/', '')))
+
+# see http://www.youtube.com/watch?v=3cIOT9kM--g for commands that make
+# profiles and set background images
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/showconfig.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,97 @@
+import logging, warnings
+from twisted.python.filepath import FilePath
+from os import path, getenv
+from rdflib import Graph
+from rdflib import URIRef, Literal
+from .namespaces import L9
+from typing import List, cast
+log = logging.getLogger('showconfig')
+
+_config = None  # graph
+
+
+def getGraph() -> Graph:
+    warnings.warn(
+        "code that's using showconfig.getGraph should be "
+        "converted to use the sync graph",
+        stacklevel=2)
+    global _config
+    if _config is None:
+        graph = Graph()
+        # note that logging is probably not configured the first time
+        # we're in here
+        warnings.warn("reading n3 files around %r" % root())
+        for f in FilePath(root()).globChildren("*.n3") + FilePath(
+                root()).globChildren("build/*.n3"):
+            graph.parse(location=f.path, format='n3')
+        _config = graph
+    return _config
+
+
+def root() -> bytes:
+    r = getenv("LIGHT9_SHOW")
+    if r is None:
+        raise OSError(
+            "LIGHT9_SHOW env variable has not been set to the show root")
+    return r.encode('ascii')
+
+
+_showUri = None
+
+
+def showUri() -> URIRef:
+    """Return the show URI associated with $LIGHT9_SHOW."""
+    global _showUri
+    if _showUri is None:
+        _showUri = URIRef(open(path.join(root(), b'URI')).read().strip())
+    return _showUri
+
+
+def songOnDisk(song: URIRef) -> bytes:
+    """given a song URI, where's the on-disk file that mpd would read?"""
+    graph = getGraph()
+    root = graph.value(showUri(), L9['musicRoot'])
+    if not root:
+        raise ValueError("%s has no :musicRoot" % showUri())
+
+    name = graph.value(song, L9['songFilename'])
+    if not name:
+        raise ValueError("Song %r has no :songFilename" % song)
+
+    return path.abspath(
+        path.join(
+            cast(Literal, root).toPython(),
+            cast(Literal, name).toPython()))
+
+
+def songFilenameFromURI(uri: URIRef) -> bytes:
+    """
+    'http://light9.bigasterisk.com/show/dance2007/song8' -> 'song8'
+
+    everything that uses this should be deprecated for real URIs
+    everywhere"""
+    assert isinstance(uri, URIRef)
+    return str(uri).split('/')[-1].encode('ascii')
+
+
+def getSongsFromShow(graph: Graph, show: URIRef) -> List[URIRef]:
+    playList = graph.value(show, L9['playList'])
+    if not playList:
+        raise ValueError("%r has no l9:playList" % show)
+    # The patch in https://github.com/RDFLib/rdflib/issues/305 fixed a
+    # serious bug here.
+    songs = list(graph.items(playList))
+
+    return songs
+
+
+def curvesDir():
+    return path.join(root(), b"curves")
+
+
+def subFile(subname):
+    return path.join(root(), b"subs", subname)
+
+
+def subsDir():
+    return path.join(root(), b'subs')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/subclient.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,55 @@
+from light9.collector.collector_client import sendToCollector
+from twisted.internet import reactor
+from twisted.internet.defer import Deferred
+import traceback
+import time
+import logging
+from rdflib import URIRef
+from rdfdb.syncedgraph import SyncedGraph
+log = logging.getLogger()
+
+
+class SubClient:
+    graph: SyncedGraph
+    session: URIRef
+
+    def __init__(self):
+        """assumed that your init saves self.graph"""
+        pass  # we may later need init code for network setup
+
+    def get_levels_as_sub(self):
+        """Subclasses must implement this method and return a Submaster
+        object."""
+
+    def send_levels_loop(self, periodSec=1.) -> None:
+        sendStartTime = time.time()
+
+        def done(sec):
+            delay = max(0, (sendStartTime + periodSec) - time.time())
+            reactor.callLater(delay, self.send_levels_loop, periodSec)
+
+        def err(e):
+            log.warn('subclient loop: %r', e)
+            reactor.callLater(2, self.send_levels_loop, periodSec)
+
+        d = self._send_sub()
+        d.addCallbacks(done, err)
+
+    def _send_sub(self) -> Deferred:
+        try:
+            with self.graph.currentState() as g:
+                outputSettings = self.get_output_settings(_graph=g)
+        except Exception:
+            traceback.print_exc()
+            raise
+
+        return sendToCollector(
+            'subclient',
+            self.session,
+            outputSettings,
+            # when KC uses zmq, we get message
+            # pileups and delays on collector (even
+            # at 20fps). When sequencer uses zmp,
+            # it runs great at 40fps. Not sure the
+            # difference- maybe Tk main loop?
+            useZmq=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/subcomposer/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,65 @@
+<!doctype html>
+<html>
+  <head>
+    <title>subcomposer</title>
+    <meta charset="utf-8" />
+    <style>
+     button {
+       min-width: 200px;
+       min-height: 50px;
+       display: block;
+     }
+    </style>
+  </head>
+  <body>
+    <div>Toggle channel in current sub</div>
+
+
+    
+
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b11">b11</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b12">b12</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b13">b13</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b14">b14</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b15">b15</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b16">b16</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b21">b21</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b22">b22</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b23">b23</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b24">b24</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b25">b25</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b26">b26</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b31">b31</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b32">b32</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b33">b33</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b34">b34</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b35">b35</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/b36">b36</button>
+    <hr>
+    
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f1">f1-l</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f2-out">f2-c</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f3">f3-r</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f4">f4-purp</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f5">f5-rose-x2</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/sharlyn">sharlyn</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f7">f7-c</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f8">f8-blue-x2</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f9">f9-purp</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f10">f10-l</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f11">f11-c</button>
+    <button data-chan="http://light9.bigasterisk.com/theater/piedmont/channel/f12-out">f12-r</button>
+
+    <script src="/lib/jquery/dist/jquery.min.js"></script>
+    <script>
+      $(document).on("click", "button", function (ev) {
+          var chan = ev.target.getAttribute('data-chan');
+          $.ajax({
+              type: 'POST',
+              url: 'toggle',
+              data: {chan: chan}
+          });
+      });
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/subcomposer/subcomposerweb.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,38 @@
+import logging
+
+from rdflib import URIRef, Literal
+from twisted.internet import reactor
+import cyclone.web
+
+from cycloneerr import PrettyErrorHandler
+from light9 import networking
+
+log = logging.getLogger('web')
+
+
+def init(graph, session, currentSub):
+    SFH = cyclone.web.StaticFileHandler
+    app = cyclone.web.Application(handlers=[
+        (r'/()', SFH, {
+            'path': 'light9/subcomposer',
+            'default_filename': 'index.html'
+        }),
+        (r'/toggle', Toggle),
+    ],
+                                  debug=True,
+                                  graph=graph,
+                                  currentSub=currentSub)
+    reactor.listenTCP(networking.subComposer.port, app)
+    log.info("listening on %s" % networking.subComposer.port)
+
+
+class Toggle(PrettyErrorHandler, cyclone.web.RequestHandler):
+
+    def post(self):
+        chan = URIRef(self.get_argument('chan'))
+        sub = self.settings.currentSub()
+
+        chanKey = Literal(chan.rsplit('/', 1)[1])
+        old = sub.get_levels().get(chanKey, 0)
+
+        sub.editLevel(chan, 0 if old else 1)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/tkdnd.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,156 @@
+from glob import glob
+from os.path import join, basename
+from typing import Dict, Any
+
+
+class TkdndEvent(object):
+    """
+    see http://www.ellogon.org/petasis/tcltk-projects/tkdnd/tkdnd-man-page
+    for details on the fields
+
+    The longer attribute names (action instead of %A) were made up for
+    this API.
+
+    Not all attributes are visible yet, since I have not thought
+    through what conversions they should receive and I don't want to
+    unnecessarily change their types later.
+    """
+    substitutions = {
+        "%A": "action",
+        "%b": "button",
+        "%D": "data",
+        "%m": "modifiers",
+        "%T": "type",
+        "%W": "targetWindow",
+        "%X": "mouseX",
+        "%Y": "mouseY",
+    }
+
+    @classmethod
+    def makeEvent(cls, *args):
+        ev = cls()
+        for (k, v), arg in zip(sorted(TkdndEvent.substitutions.items()), args):
+            setattr(ev, v, arg)
+        # it would be cool for this to decode text data according to the charset in the type
+        for attr in ['button', 'mouseX', 'mouseY']:
+            setattr(ev, attr, int(getattr(ev, attr)))
+        return (ev,)
+
+    tclSubstitutions = ' '.join(sorted(substitutions.keys()))
+
+    def __repr__(self):
+        return "<TkdndEvent %r>" % self.__dict__
+
+
+class Hover(object):
+
+    def __init__(self, widget, style):
+        self.widget, self.style = widget, style
+        self.oldStyle: Dict[Any, Any] = {}
+
+    def set(self, ev):
+        for k, v in list(self.style.items()):
+            self.oldStyle[k] = self.widget.cget(k)
+        self.widget.configure(**self.style)
+        return ev.action
+
+    def restore(self, ev):
+        self.widget.configure(**self.oldStyle)
+
+
+def initTkdnd(tk, tkdndBuildDir):
+    """
+    pass the 'tk' attribute of any Tkinter object, and the top dir of
+    your built tkdnd package
+    """
+    tk.call('source', join(tkdndBuildDir, 'library/tkdnd.tcl'))
+    for dll in glob(
+            join(tkdndBuildDir,
+                 '*tkdnd*' + tk.call('info', 'sharedlibextension'))):
+        tk.call('tkdnd::initialise', join(tkdndBuildDir, 'library'),
+                join('..', basename(dll)), 'tkdnd')
+
+
+def dragSourceRegister(widget, action='copy', datatype='text/uri-list',
+                       data=''):
+    """
+    if the 'data' param is callable, it will be called every time to
+    look up the current data.
+
+    If the callable returns None (or data is None to begin with), the drag
+    """
+    widget.tk.call('tkdnd::drag_source', 'register', widget._w)
+
+    # with normal Tkinter bind(), the result of your handler isn't
+    # actually returned so the drag doesn't get launched. This is a
+    # corrected version of what bind() does when you pass a function,
+    # but I don't block my tuple from getting returned (as a tcl list)
+
+    def init():
+        dataValue = data() if callable(data) else data
+        if dataValue is None:
+            return
+        return (action, datatype, dataValue)
+
+    funcId = widget._register(
+        init,
+        widget._substitute,
+        1  # needscleanup
+    )
+    widget.bind("<<DragInitCmd>>", funcId)
+
+
+def dropTargetRegister(
+        widget,
+        typeList=None,
+        onDropEnter=None,
+        onDropPosition=None,
+        onDropLeave=None,
+        onDrop=None,
+        hoverStyle=None,
+):
+    """
+    the optional callbacks will be called with a TkdndEvent
+    argument.
+
+    onDropEnter, onDropPosition, and onDrop are supposed to return an
+    action (perhaps the value in event.action). The return value seems
+    to have no effect, but it might just be that errors are getting
+    silenced.
+
+    Passing hoverStyle sets onDropEnter to call
+    widget.configure(**hoverStyle) and onDropLeave to restore the
+    widget's style. onDrop is also wrapped to do a restore.
+    """
+
+    if hoverStyle is not None:
+        hover = Hover(widget, hoverStyle)
+
+        def wrappedDrop(ev):
+            hover.restore(ev)
+            if onDrop:
+                return onDrop(ev)
+
+        return dropTargetRegister(widget,
+                                  typeList=typeList,
+                                  onDropEnter=hover.set,
+                                  onDropLeave=hover.restore,
+                                  onDropPosition=onDropPosition,
+                                  onDrop=wrappedDrop)
+
+    if typeList is None:
+        typeList = ['*']
+    widget.tk.call(*(['tkdnd::drop_target', 'register', widget._w] + typeList))
+
+    for sequence, handler in [
+        ('<<DropEnter>>', onDropEnter),
+        ('<<DropPosition>>', onDropPosition),
+        ('<<DropLeave>>', onDropLeave),
+        ('<<Drop>>', onDrop),
+    ]:
+        if not handler:
+            continue
+        func = widget._register(handler,
+                                subst=TkdndEvent.makeEvent,
+                                needcleanup=1)
+        widget.bind(sequence, func + " " + TkdndEvent.tclSubstitutions)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/typedgraph.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,87 @@
+from typing import List, Type, TypeVar, cast, get_args
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import XSD, BNode, Graph, Literal, URIRef
+from rdflib.term import Node
+
+# todo: this ought to just require a suitable graph.value method
+EitherGraph = Graph | SyncedGraph
+
+_ObjType = TypeVar('_ObjType')
+
+
+class ConversionError(ValueError):
+    """graph had a value, but it does not safely convert to any of the requested types"""
+
+
+def _expandUnion(t: Type) -> List[Type]:
+    if hasattr(t, '__args__'):
+        return list(get_args(t))
+    return [t]
+
+
+def _typeIncludes(t1: Type, t2: Type) -> bool:
+    """same as issubclass but t1 can be a NewType"""
+    if t2 is None:
+        t2 = type(None)
+    if t1 == t2:
+        return True
+
+    if getattr(t1, '__supertype__', None) == t2:
+        return True
+
+    ts = _expandUnion(t1)
+    if len(ts) > 1:
+        return any(_typeIncludes(t, t2) for t in ts)
+
+    return False
+
+
+def _convLiteral(objType: Type[_ObjType], x: Literal) -> _ObjType:
+    if _typeIncludes(objType, Literal):
+        return cast(objType, x)
+
+    for outType, dtypes in [
+        (float, (XSD['integer'], XSD['double'], XSD['decimal'])),
+        (int, (XSD['integer'],)),
+        (str, ()),
+    ]:
+        for t in _expandUnion(objType):
+            if _typeIncludes(t, outType) and (not dtypes or x.datatype in dtypes):
+                # e.g. user wants float and we have xsd:double
+                return cast(objType, outType(x.toPython()))
+    raise ConversionError
+
+
+def typedValue(objType: Type[_ObjType], graph: EitherGraph, subj: Node, pred: URIRef) -> _ObjType:
+    """graph.value(subj, pred) with a given return type.
+    If objType is not an rdflib.Node, we toPython() the value.
+
+    Allow objType to include None if you want a None return for not-found.
+    """
+    if objType is None:
+        raise TypeError('must allow non-None result type')
+    obj = graph.value(subj, pred)
+    if obj is None:
+        if _typeIncludes(objType, None):
+            return cast(objType, None)
+        raise ValueError(f'No obj for {subj=} {pred=}')
+
+    ConvFrom: Type[Node] = type(obj)
+    ConvTo = objType
+    try:
+        if ConvFrom == URIRef and _typeIncludes(ConvTo, URIRef):
+            conv = obj
+        elif ConvFrom == URIRef and issubclass(URIRef, ConvTo) and not issubclass(str, ConvTo):  # rewrite please
+            conv = obj
+        elif ConvFrom == BNode and issubclass(BNode, ConvTo):
+            conv = obj
+        elif ConvFrom == Literal:
+            conv = _convLiteral(objType, cast(Literal, obj))
+        else:
+            raise ConversionError
+    except ConversionError:
+        raise ConversionError(f'graph contains {type(obj)}, caller requesting {objType}')
+    # if objType is float and isinstance(conv, decimal.Decimal):
+    #     conv = float(conv)
+    return cast(objType, conv)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/typedgraph_test.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,161 @@
+from typing import NewType, Optional, cast
+
+import pytest
+from rdflib import BNode, Graph, Literal, URIRef
+from rdflib.term import Node
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9, XSD
+from light9.typedgraph import ConversionError, _typeIncludes, typedValue
+
+g = cast(
+    Graph,
+    MockSyncedGraph('''
+    @prefix : <http://light9.bigasterisk.com/> .
+    :subj
+        :uri :c;
+        :bnode [];
+        # see https://w3c.github.io/N3/spec/#literals for syntaxes.
+        :int 0;
+        :float1 0.0;
+        :float2 1.0e1;
+        :float3 0.5;
+        :color "#ffffff"^^:hexColor;
+        :definitelyAString "hello" .
+'''))
+
+subj = L9['subj']
+
+
+class TestTypeIncludes:
+
+    def test_includesItself(self):
+        assert _typeIncludes(str, str)
+
+    def test_includesUnionMember(self):
+        assert _typeIncludes(int | str, str)
+
+    def test_notIncludes(self):
+        assert not _typeIncludes(int | str, None)
+
+    def test_explicitOptionalWorks(self):
+        assert _typeIncludes(Optional[int], None)
+
+    def test_3WayUnionWorks(self):
+        assert _typeIncludes(int | str | float, int)
+
+
+class TestTypedValueReturnsBasicTypes:
+
+    def test_getsUri(self):
+        assert typedValue(URIRef, g, subj, L9['uri']) == L9['c']
+
+    def test_getsAsNode(self):
+        assert typedValue(Node, g, subj, L9['uri']) == L9['c']
+
+    def test_getsBNode(self):
+        # this is unusual usage since users ought to always be able to replace BNode with URIRef
+        assert typedValue(BNode, g, subj, L9['bnode']) == g.value(subj,  L9['bnode'])
+
+    def test_getsBNodeAsNode(self):
+        assert typedValue(Node, g, subj, L9['bnode']) == g.value(subj,  L9['bnode'])
+
+
+    def test_getsNumerics(self):
+        assert typedValue(float, g, subj, L9['int']) == 0
+        assert typedValue(float, g, subj, L9['float1']) == 0
+        assert typedValue(float, g, subj, L9['float2']) == 10
+        assert typedValue(float, g, subj, L9['float3']) == 0.5
+
+        assert typedValue(int, g, subj, L9['int']) == 0
+        # These retrieve rdf floats that happen to equal
+        # ints, but no one should be relying on that.
+        with pytest.raises(ConversionError):
+            typedValue(int, g, subj, L9['float1'])
+        with pytest.raises(ConversionError):
+            typedValue(int, g, subj, L9['float2'])
+        with pytest.raises(ConversionError):
+            typedValue(int, g, subj, L9['float3'])
+
+    def test_getsString(self):
+        tv = typedValue(str, g, subj, L9['color'])
+        assert tv == '#ffffff'
+
+    def test_getsLiteral(self):
+        tv = typedValue(Literal, g, subj, L9['float2'])
+        assert type(tv) == Literal
+        assert tv.datatype == XSD['double']
+
+        tv = typedValue(Literal, g, subj, L9['color'])
+        assert type(tv) == Literal
+        assert tv.datatype == L9['hexColor']
+
+
+class TestTypedValueDoesntDoInappropriateUriStringConversions:
+
+    def test_noUriToString(self):
+        with pytest.raises(ConversionError):
+            typedValue(str, g, subj, L9['uri'])
+
+    def test_noUriToLiteral(self):
+        with pytest.raises(ConversionError):
+            typedValue(Literal, g, subj, L9['uri'])
+
+    def test_noStringToUri(self):
+        with pytest.raises(ConversionError):
+            typedValue(URIRef, g, subj, L9['definitelyAString'])
+
+
+class TestTypedValueOnMissingValues:
+
+    def test_errorsOnMissingValue(self):
+        with pytest.raises(ValueError):
+            typedValue(float, g, subj, L9['missing'])
+
+    def test_returnsNoneForMissingValueIfCallerPermits(self):
+        assert (float | None) == Optional[float]
+        assert typedValue(float | None, g, subj, L9['float1']) == 0
+        assert typedValue(float | None, g, subj, L9['missing']) == None
+        assert typedValue(str | float | None, g, subj, L9['missing']) == None
+
+    def test_cantJustPassNone(self):
+        with pytest.raises(TypeError):
+            typedValue(None, g, subj, L9['float1'])  # type: ignore
+
+
+class TestTypedValueConvertsToNewTypes:
+
+    def test_castsUri(self):
+        DeviceUri = NewType('DeviceUri', URIRef)
+        assert typedValue(DeviceUri, g, subj, L9['uri']) == DeviceUri(L9['c'])
+
+    def test_castsLiteralToNewType(self):
+        HexColor = NewType('HexColor', str)
+        assert typedValue(HexColor, g, subj, L9['color']) == HexColor('#ffffff')
+
+
+class TestTypedValueAcceptsUnionTypes:
+
+    def test_getsMemberTypeOfUnion(self):
+        tv1 = typedValue(float | str, g, subj, L9['float1'])
+        assert type(tv1) == float
+        assert tv1 == 0.0
+
+        tv2 = typedValue(float | str, g, subj, L9['color'])
+        assert type(tv2) == str
+        assert tv2 == '#ffffff'
+
+    def test_failsIfNoUnionTypeMatches(self):
+        with pytest.raises(ConversionError):
+            typedValue(float | URIRef, g, subj, L9['color'])
+
+    def test_combinesWithNone(self):
+        assert typedValue(float | URIRef | None, g, subj, L9['uri']) == L9['c']
+
+    def test_combinedWithNewType(self):
+        HexColor = NewType('HexColor', str)
+        assert typedValue(float | HexColor, g, subj, L9['float1']) == 0
+        assert typedValue(float | HexColor, g, subj, L9['color']) == HexColor('#ffffff')
+
+    def test_whenOneIsUri(self):
+        assert typedValue(str | URIRef, g, subj, L9['color']) == '#ffffff'
+        assert typedValue(str | URIRef, g, subj, L9['uri']) == L9['c']
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/uihelpers.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,298 @@
+"""all the tiny tk helper functions"""
+
+#from Tkinter import Button
+import logging, time
+from rdflib import Literal
+from tkinter.tix import Button, Toplevel, Tk, IntVar, Entry, DoubleVar
+import tkinter
+from light9.namespaces import L9
+from typing import Dict
+
+log = logging.getLogger("toplevel")
+
+windowlocations = {
+    'sub': '425x738+00+00',
+    'console': '168x24+848+000',
+    'leveldisplay': '144x340+870+400',
+    'cuefader': '314x212+546+741',
+    'effect': '24x24+0963+338',
+    'stage': '823x683+37+030',
+    'scenes': '504x198+462+12',
+}
+
+
+def bindkeys(root, key, func):
+    root.bind(key, func)
+    for w in root.winfo_children():
+        w.bind(key, func)
+
+
+def toplevel_savegeometry(tl, name):
+    try:
+        geo = tl.geometry()
+        if not geo.startswith("1x1"):
+            f = open(".light9-window-geometry-%s" % name.replace(' ', '_'), 'w')
+            f.write(tl.geometry())
+        # else the window never got mapped
+    except Exception:
+        # it's ok if there's no saved geometry
+        pass
+
+
+def toplevelat(name, existingtoplevel=None, graph=None, session=None):
+    tl = existingtoplevel or Toplevel()
+    tl.title(name)
+
+    lastSaved = [None]
+    setOnce = [False]
+    graphSetTime = [0.0]
+
+    def setPosFromGraphOnce():
+        """
+        the graph is probably initially empty, but as soon as it gives
+        us one window position, we stop reading them
+        """
+        if setOnce[0]:
+            return
+        geo = graph.value(session, L9['windowGeometry'])
+        log.debug("setPosFromGraphOnce %s", geo)
+
+        setOnce[0] = True
+        graphSetTime[0] = time.time()
+        if geo is not None and geo != lastSaved[0]:
+            tl.geometry(geo)
+            lastSaved[0] = geo
+
+    def savePos(ev):
+        geo = tl.geometry()
+        if not isinstance(ev.widget, (Tk, tkinter.Tk)):
+            # I think these are due to internal widget size changes,
+            # not the toplevel changing
+            return
+        # this is trying to not save all the startup automatic window
+        # sizes. I don't have a better plan for this yet.
+        if graphSetTime[0] == 0 or time.time() < graphSetTime[0] + 3:
+            return
+        if not setOnce[0]:
+            return
+        lastSaved[0] = geo
+        log.debug("saving position %s", geo)
+        graph.patchObject(session, session, L9['windowGeometry'], Literal(geo))
+
+    if graph is not None and session is not None:
+        graph.addHandler(setPosFromGraphOnce)
+
+    if name in windowlocations:
+        tl.geometry(positionOnCurrentDesktop(windowlocations[name]))
+
+    if graph is not None:
+        tl._toplevelat_funcid = tl.bind(
+            "<Configure>", lambda ev, tl=tl, name=name: savePos(ev))
+
+    return tl
+
+
+def positionOnCurrentDesktop(xform, screenWidth=1920, screenHeight=1440):
+    size, x, y = xform.split('+')
+    x = int(x) % screenWidth
+    y = int(y) % screenHeight
+    return "%s+%s+%s" % (size, x, y)
+
+
+def toggle_slider(s):
+    if s.get() == 0:
+        s.set(100)
+    else:
+        s.set(0)
+
+
+# for lambda callbacks
+def printout(t):
+    print('printout', t)
+
+
+def printevent(ev):
+    for k in dir(ev):
+        if not k.startswith('__'):
+            print('ev', k, getattr(ev, k))
+
+
+def eventtoparent(ev, sequence):
+    "passes an event to the parent, screws up TixComboBoxes"
+
+    wid_class = str(ev.widget.__class__)
+    if wid_class == 'Tix.ComboBox' or wid_class == 'Tix.TixSubWidget':
+        return
+
+    evdict = {}
+    for x in ['state', 'time', 'y', 'x', 'serial']:
+        evdict[x] = getattr(ev, x)
+
+
+#    evdict['button']=ev.num
+    par = ev.widget.winfo_parent()
+    if par != ".":
+        ev.widget.nametowidget(par).event_generate(sequence, **evdict)
+    #else the event made it all the way to the top, unhandled
+
+
+def colorlabel(label):
+    """color a label based on its own text"""
+    txt = label['text'] or "0"
+    lev = float(txt) / 100
+    low = (80, 80, 180)
+    high = (255, 55, 0o50)
+    out = [int(l + lev * (h - l)) for h, l in zip(high, low)]
+    col = "#%02X%02X%02X" % tuple(out)  # type: ignore
+    label.config(bg=col)
+
+
+# TODO: get everyone to use this
+def colorfade(low, high, percent):
+    '''not foolproof.  make sure 0 < percent < 1'''
+    out = [int(l + percent * (h - l)) for h, l in zip(high, low)]
+    col = "#%02X%02X%02X" % tuple(out)  # type: ignore
+    return col
+
+
+def colortotuple(anytkobj, colorname):
+    'pass any tk object and a color name, like "yellow"'
+    rgb = anytkobj.winfo_rgb(colorname)
+    return [v / 256 for v in rgb]
+
+
+class Togglebutton(Button):
+    """works like a single radiobutton, but it's a button so the
+    label's on the button face, not to the side. the optional command
+    callback is called on button set, not on unset. takes a variable
+    just like a checkbutton"""
+
+    def __init__(self,
+                 parent,
+                 variable=None,
+                 command=None,
+                 downcolor='red',
+                 **kw):
+
+        self.oldcommand = command
+        Button.__init__(self, parent, command=self.invoke, **kw)
+
+        self._origbkg = self.cget('bg')
+        self.downcolor = downcolor
+
+        self._variable = variable
+        if self._variable:
+            self._variable.trace('w', self._varchanged)
+            self._setstate(self._variable.get())
+        else:
+            self._setstate(0)
+
+        self.bind("<Return>", self.invoke)
+        self.bind("<1>", self.invoke)
+        self.bind("<space>", self.invoke)
+
+    def _varchanged(self, *args):
+        self._setstate(self._variable.get())
+
+    def invoke(self, *ev):
+        if self._variable:
+            self._variable.set(not self.state)
+        else:
+            self._setstate(not self.state)
+
+        if self.oldcommand and self.state:  # call command only when state goes to 1
+            self.oldcommand()
+        return "break"
+
+    def _setstate(self, newstate):
+        self.state = newstate
+        if newstate:  # set
+            self.config(bg=self.downcolor, relief='sunken')
+        else:  # unset
+            self.config(bg=self._origbkg, relief='raised')
+        return "break"
+
+
+class FancyDoubleVar(DoubleVar):
+
+    def __init__(self, master=None):
+        DoubleVar.__init__(self, master)
+        self.callbacklist: Dict[str, str] = {}  # cbname : mode
+        self.namedtraces: Dict[str, str] = {}  # name : cbname
+
+    def trace_variable(self, mode, callback):
+        """Define a trace callback for the variable.
+
+        MODE is one of "r", "w", "u" for read, write, undefine.
+        CALLBACK must be a function which is called when
+        the variable is read, written or undefined.
+
+        Return the name of the callback.
+        """
+        cbname = self._master._register(callback)
+        self._tk.call("trace", "variable", self._name, mode, cbname)
+
+        # we build a list of the trace callbacks (the py functrions and the tcl functionnames)
+        self.callbacklist[cbname] = mode
+        #        print "added trace:",callback,cbname
+
+        return cbname
+
+    trace = trace_variable
+
+    def disable_traces(self):
+        for cb, mode in list(self.callbacklist.items()):
+            #            DoubleVar.trace_vdelete(self,v[0],k)
+            self._tk.call("trace", "vdelete", self._name, mode, cb)
+            # but no master delete!
+
+    def recreate_traces(self):
+        for cb, mode in list(self.callbacklist.items()):
+            #            self.trace_variable(v[0],v[1])
+            self._tk.call("trace", "variable", self._name, mode, cb)
+
+    def trace_named(self, name, callback):
+        if name in self.namedtraces:
+            print(
+                "FancyDoubleVar: already had a trace named %s - replacing it" %
+                name)
+            self.delete_named(name)
+
+        cbname = self.trace_variable(
+            'w', callback)  # this will register in self.callbacklist too
+
+        self.namedtraces[name] = cbname
+        return cbname
+
+    def delete_named(self, name):
+        if name in self.namedtraces:
+
+            cbname = self.namedtraces[name]
+
+            self.trace_vdelete('w', cbname)
+            #self._tk.call("trace","vdelete",self._name,'w',cbname)
+            print("FancyDoubleVar: successfully deleted trace named %s" % name)
+        else:
+            print(
+                "FancyDoubleVar: attempted to delete named %s which wasn't set to any function"
+                % name)
+
+
+def get_selection(listbox):
+    'Given a listbox, returns first selection as integer'
+    selection = int(listbox.curselection()[0])  # blech
+    return selection
+
+
+if __name__ == '__main__':
+    root = Tk()
+    root.tk_focusFollowsMouse()
+    iv = IntVar()
+
+    def cb():
+        print("cb!")
+
+    t = Togglebutton(root, text="testbutton", command=cb, variable=iv)
+    t.pack()
+    Entry(root, textvariable=iv).pack()
+    root.mainloop()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/updatefreq.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,33 @@
+"""calculates your updates-per-second"""
+
+import time
+
+
+class Updatefreq:
+    """make one of these, call update() on it as much as you want,
+    and then float() or str() the object to learn the updates per second.
+
+    the samples param to __init__ specifies how many past updates will
+    be stored.  """
+
+    def __init__(self, samples=20):
+        self.times = [0]
+        self.samples = samples
+
+    def update(self):
+        """call this every time you do an update"""
+        self.times = self.times[-self.samples:]
+        self.times.append(time.time())
+
+    def __float__(self):
+        """a cheap algorithm, for now, which looks at the first and
+        last times only"""
+
+        try:
+            hz = len(self.times) / (self.times[-1] - self.times[0])
+        except ZeroDivisionError:
+            return 0.0
+        return hz
+
+    def __str__(self):
+        return "%.2fHz" % float(self)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/gui.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,11 @@
+var model = {
+    shutters: [],
+};
+for (var s=0; s < 1; s+=.04) {
+    var micro = Math.floor(Math.pow(s, 3) * 100000)
+    if (micro == 0) {
+        continue;
+    }
+    model.shutters.push(micro);
+}
+ko.applyBindings(model)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,57 @@
+<!doctype html>
+<html>
+  <head>
+    <title>vidref</title>
+    <meta charset="utf-8" />
+     <link rel="stylesheet" href="/style.css">
+
+    <script src="/lib/debug/debug-build.js"></script>
+    <script>
+     debug.enable('*');
+    </script>
+    <script src="/websocket.js"></script>
+    <script type="module" src="/light9-vidref-live.js"></script>
+    <script type="module" src="/light9-vidref-replay-stack.js"></script>
+  </head>
+  <body>
+    <style>
+     #rs {
+         width: 100%;
+     }
+    </style>
+    <h1>vidref</h1>
+    <div>
+      <light9-vidref-live></light9-vidref-live>
+    </div>
+    <light9-vidref-replay-stack id="rs"></light9-vidref-replay-stack>
+    <div class="keys">Keys:
+      <span class="keyCap">s</span> stop,
+      <span class="keyCap">p</span> play,
+      <span class="keyCap">,/.</span> step
+    </div>
+      <script>
+       const log = debug('index');
+       document.addEventListener('keypress', (ev) => {
+         const nudge = (dt) => {
+           const newTime = document.querySelector('#rs').songTime + dt;
+           fetch('/ascoltami/seekPlayOrPause', {
+             method: 'POST',
+             body: JSON.stringify({scrub: newTime}),
+           });
+         };
+         
+         if (ev.code == 'KeyP') {
+           fetch('/ascoltami/seekPlayOrPause',
+                 {method: 'POST', body: JSON.stringify({action: 'play'})}); 
+         } else if (ev.code == 'KeyS') {
+           fetch('/ascoltami/seekPlayOrPause',
+                 {method: 'POST', body: JSON.stringify({action: 'pause'})});
+         } else if (ev.code == 'Comma') {
+           nudge(-.1);
+         } else if (ev.code == 'Period') {
+           nudge(.1);
+         }
+       });
+      </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/main.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,110 @@
+#!/usr/bin/python
+"""
+
+dvcam test
+gst-launch dv1394src ! dvdemux name=d ! dvdec ! ffmpegcolorspace ! hqdn3d ! xvimagesink
+
+"""
+import gobject, logging, traceback
+import gtk
+from twisted.python.util import sibpath
+from light9.vidref.replay import ReplayViews, framerate
+from light9.ascoltami.musictime_client import MusicTime
+from light9.vidref.videorecorder import Pipeline
+from light9.vidref import remotepivideo
+log = logging.getLogger()
+
+
+class Gui(object):
+
+    def __init__(self, graph):
+        wtree = gtk.Builder()
+        wtree.add_from_file(sibpath(__file__, "vidref.glade"))
+        mainwin = wtree.get_object("MainWindow")
+        mainwin.connect("destroy", gtk.main_quit)
+        wtree.connect_signals(self)
+        gtk.rc_parse("theme/marble-ice/gtk-2.0/gtkrc")
+
+        self.recordingTo = wtree.get_object('recordingTo')
+        self.musicScale = wtree.get_object("musicScale")
+        self.musicScale.connect("value-changed", self.onMusicScaleValue)
+        # tiny race here if onMusicScaleValue tries to use musicTime right away
+        self.musicTime = MusicTime(onChange=self.onMusicTimeChange,
+                                   pollCurvecalc=False)
+        self.ignoreScaleChanges = False
+        # self.attachLog(wtree.get_object("lastLog")) # disabled due to crashing
+
+        # wtree.get_object("replayPanel").show() # demo only
+        rp = wtree.get_object("replayVbox")
+        self.replayViews = ReplayViews(rp)
+
+        mainwin.show_all()
+        vid3 = wtree.get_object("vid3")
+
+        if 0:
+            self.pipeline = Pipeline(liveVideoXid=vid3.window.xid,
+                                     musicTime=self.musicTime,
+                                     recordingTo=self.recordingTo)
+        else:
+            self.pipeline = remotepivideo.Pipeline(liveVideo=vid3,
+                                                   musicTime=self.musicTime,
+                                                   recordingTo=self.recordingTo,
+                                                   graph=graph)
+
+        vid3.props.width_request = 360
+        vid3.props.height_request = 220
+        wtree.get_object("frame1").props.height_request = 220
+
+        self.pipeline.setInput('v4l')  # auto seems to not search for dv
+
+        gobject.timeout_add(1000 // framerate, self.updateLoop)
+
+    def snapshot(self):
+        return self.pipeline.snapshot()
+
+    def attachLog(self, textBuffer):
+        """write log lines to this gtk buffer"""
+
+        class ToBuffer(logging.Handler):
+
+            def emit(self, record):
+                textBuffer.set_text(record.getMessage())
+
+        h = ToBuffer()
+        h.setLevel(logging.INFO)
+        log.addHandler(h)
+
+    def updateLoop(self):
+        position = self.musicTime.getLatest()
+        try:
+            with gtk.gdk.lock:
+                self.replayViews.update(position)
+        except Exception:
+            traceback.print_exc()
+        return True
+
+    def getInputs(self):
+        return ['auto', 'dv', 'video0']
+
+    def on_liveVideoEnabled_toggled(self, widget):
+        self.pipeline.setLiveVideo(widget.get_active())
+
+    def on_liveFrameRate_value_changed(self, widget):
+        print(widget.get_value())
+
+    def onMusicTimeChange(self, position):
+        self.ignoreScaleChanges = True
+        try:
+            self.musicScale.set_range(0, position['duration'])
+            self.musicScale.set_value(position['t'])
+        finally:
+            self.ignoreScaleChanges = False
+
+    def onMusicScaleValue(self, scaleRange):
+        """the scale position has changed. if it was by the user, send
+        it back to music player"""
+        if not self.ignoreScaleChanges:
+            self.musicTime.sendTime(scaleRange.get_value())
+
+    def incomingTime(self, t, source):
+        self.musicTime.lastHoverTime = t
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/moviestore.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,106 @@
+import os
+from bisect import bisect_left
+from rdflib import URIRef
+import sys
+sys.path.append(
+    '/home/drewp/Downloads/moviepy/lib/python2.7/site-packages')  # for moviepy
+from moviepy.video.io.ffmpeg_writer import FFMPEG_VideoWriter
+from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader
+
+
+class _ResourceDir(object):
+    """the disk files for a resource"""
+
+    def __init__(self, root, uri):
+        self.root, self.uri = root, uri
+        u = self.uri.replace('http://', '').replace('/', '_')
+        self.topDir = os.path.join(self.root, u)
+        try:
+            os.makedirs(self.topDir)
+        except OSError:
+            pass
+
+    def videoPath(self):
+        return os.path.join(self.topDir, 'video.avi')
+
+    def indexPath(self):
+        return os.path.join(self.topDir, 'frame_times')
+
+
+class Writer(object):
+    """saves a video of a resource, receiving a frame at a time. Frame timing does not have to be regular."""
+
+    def __init__(self, root, uri):
+        self.rd = _ResourceDir(root, uri)
+        self.ffmpegWriter = None  # lazy since we don't know the size yet
+        self.index = open(self.rd.indexPath(), 'w')
+        self.framesWritten = 0
+
+    def save(self, t, img):
+        if self.ffmpegWriter is None:
+            self.ffmpegWriter = FFMPEG_VideoWriter(
+                filename=self.rd.videoPath(),
+                size=img.size,
+                fps=10,  # doesn't matter, just for debugging playbacks
+                codec='libx264')
+        self.ffmpegWriter.write_frame(img)
+        self.framesWritten = self.framesWritten + 1
+        self.index.write('%d %g\n' % (self.framesWritten, t))
+
+    def close(self):
+        if self.ffmpegWriter is not None:
+            self.ffmpegWriter.close()
+        self.index.close()
+
+
+class Reader(object):
+
+    def __init__(self, resourceDir):
+        self.timeFrame = []
+        for line in open(resourceDir.indexPath()):
+            f, t = line.strip().split()
+            self.timeFrame.append((float(t), int(f)))
+        self._reader = FFMPEG_VideoReader(resourceDir.videoPath())
+
+    def getFrame(self, t):
+        i = bisect_left(self.timeFrame, (t, None))
+        i = min(i, len(self.timeFrame) - 1)
+        f = self.timeFrame[i][1]
+        return self._reader.get_frame(f)
+
+
+class MultiReader(object):
+    """loads the nearest existing frame of a resource's video. Supports random access of multiple resources."""
+
+    def __init__(self, root):
+        self.root = root
+        # these should cleanup when they haven't been used in a while
+        self.readers = {}  # uri: Reader
+
+    def getFrame(self, uri, t):
+        if uri not in self.readers:
+            #self.readers.close all and pop them
+            self.readers[uri] = Reader(_ResourceDir(self.root, uri))
+        return self.readers[uri].getFrame(t)
+
+
+if __name__ == '__main__':
+    from PIL import Image
+    take = URIRef(
+        'http://light9.bigasterisk.com/show/dance2015/song10/1434249076/')
+    if 0:
+        w = Writer('/tmp/ms', take)
+        for fn in sorted(
+                os.listdir(
+                    '/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2015_song10/1434249076'
+                )):
+            t = float(fn.replace('.jpg', ''))
+            jpg = Image.open(
+                '/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2015_song10/1434249076/%08.03f.jpg'
+                % t)
+            jpg = jpg.resize((450, 176))
+            w.save(t, jpg)
+        w.close()
+    else:
+        r = MultiReader('/tmp/ms')
+        print((r.getFrame(take, 5.6)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/remotepivideo.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,143 @@
+"""
+like videorecorder.py, but talks to a bin/picamserve instance
+"""
+import os, time, logging
+import gtk
+import numpy
+import treq
+from twisted.internet import defer
+from light9.vidref.replay import songDir, takeDir, snapshotDir
+from light9 import showconfig
+from light9.namespaces import L9
+from PIL import Image
+from io import StringIO
+log = logging.getLogger('remotepi')
+
+
+class Pipeline(object):
+
+    def __init__(self, liveVideo, musicTime, recordingTo, graph):
+        self.musicTime = musicTime
+        self.recordingTo = recordingTo
+
+        self.liveVideo = self._replaceLiveVideoWidget(liveVideo)
+
+        self._snapshotRequests = []
+        self.graph = graph
+        self.graph.addHandler(self.updateCamUrl)
+
+    def updateCamUrl(self):
+        show = showconfig.showUri()
+        self.picsUrl = self.graph.value(show, L9['vidrefCamRequest'])
+        log.info("picsUrl now %r", self.picsUrl)
+        if not self.picsUrl:
+            return
+
+        # this cannot yet survive being called a second time
+        self._startRequest(str(self.picsUrl))
+
+    def _replaceLiveVideoWidget(self, liveVideo):
+        aspectFrame = liveVideo.get_parent()
+        liveVideo.destroy()
+        img = gtk.Image()
+        img.set_visible(True)
+        #img.set_size_request(320, 240)
+        aspectFrame.add(img)
+        return img
+
+    def _startRequest(self, url):
+        self._buffer = ''
+        log.info('start request to %r', url)
+        d = treq.get(url)
+        d.addCallback(treq.collect, self._dataReceived)
+        # not sure how to stop this
+        return d
+
+    def _dataReceived(self, chunk):
+        self._buffer += chunk
+        if len(self._buffer) < 100:
+            return
+        i = self._buffer.index('\n')
+        size, frameTime = self._buffer[:i].split()
+        size = int(size)
+        if len(self._buffer) - i - 1 < size:
+            return
+        jpg = self._buffer[i + 1:i + 1 + size]
+        self.onFrame(jpg, float(frameTime))
+        self._buffer = self._buffer[i + 1 + size:]
+
+    def snapshot(self):
+        """
+        returns deferred to the path (which is under snapshotDir()) where
+        we saved the image.
+        """
+        filename = "%s/%s.jpg" % (snapshotDir(), time.time())
+        d = defer.Deferred()
+        self._snapshotRequests.append((d, filename))
+        return d
+
+    def setInput(self, name):
+        pass
+
+    def setLiveVideo(self, on):
+        print("setLiveVideo", on)
+
+    def onFrame(self, jpg, frameTime):
+        # We could pass frameTime here to try to compensate for lag,
+        # but it ended up looking worse in a test. One suspect is the
+        # rpi clock drift might be worse than the lag. The value of
+        # (now - frameTime) stutters regularly between 40ms, 140ms,
+        # and 200ms.
+        position = self.musicTime.getLatest()
+
+        for d, filename in self._snapshotRequests:
+            with open(filename, 'w') as out:
+                out.write(jpg)
+            d.callback(filename)
+        self._snapshotRequests[:] = []
+
+        if not position['song']:
+            self.updateLiveFromTemp(jpg)
+            return
+        outDir = takeDir(songDir(position['song']), position['started'])
+        outFilename = "%s/%08.03f.jpg" % (outDir, position['t'])
+        if os.path.exists(outFilename):  # we're paused on one time
+            self.updateLiveFromTemp(jpg)
+            return
+        try:
+            os.makedirs(outDir)
+        except OSError:
+            pass
+        with open(outFilename, 'w') as out:
+            out.write(jpg)
+
+        self.updateLiveFromFile(outFilename)
+
+        # if you're selecting the text while gtk is updating it,
+        # you can get a crash in xcb_io
+        if getattr(self, '_lastRecText', None) != outDir:
+            with gtk.gdk.lock:
+                self.recordingTo.set_text(outDir)
+            self._lastRecText = outDir
+
+    def updateLiveFromFile(self, outFilename):
+        self.liveVideo.set_from_file(outFilename)
+
+    def updateLiveFromTemp(self, jpg):
+        try:
+            img = Image.open(StringIO(jpg))
+            if not hasattr(self, 'livePixBuf'):
+                self.livePixBuf = gtk.gdk.pixbuf_new_from_data(
+                    img.tobytes(), gtk.gdk.COLORSPACE_RGB, False, 8,
+                    img.size[0], img.size[1], img.size[0] * 3)
+                log.info("live images are %r", img.size)
+            else:
+                # don't leak pixbufs; update the one we have
+                a = self.livePixBuf.pixel_array
+                newImg = numpy.fromstring(img.tobytes(), dtype=numpy.uint8)
+                a[:, :, :] = newImg.reshape(a.shape)
+            self.liveVideo.set_from_pixbuf(self.livePixBuf)
+
+        except Exception:
+            import traceback
+            traceback.print_exc()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/setup.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+<!doctype html>
+<html>
+  <head>
+    <title>vidref setup</title>
+    <meta charset="utf-8" />
+    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+    <link rel="stylesheet" href="/style.css">
+    <script src="/lib/jquery/dist/jquery.slim.min.js"></script>
+
+    <script src="/websocket.js"></script>
+    <script type="module"  src="/light9-vidref-live.js"></script>
+
+  </head>
+  <body>
+    Live:
+    <light9-vidref-live></light9-vidref-live>
+
+    
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/videorecorder.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,338 @@
+from dataclasses import dataclass
+from io import BytesIO
+from typing import Optional
+import time, logging, os, traceback
+
+import gi
+gi.require_version('Gst', '1.0')
+gi.require_version('GstBase', '1.0')
+
+from gi.repository import Gst
+from rdflib import URIRef
+from rx.subject import BehaviorSubject
+from twisted.internet import threads
+import PIL.Image
+import moviepy.editor
+import numpy
+
+from light9 import showconfig
+from light9.ascoltami.musictime_client import MusicTime
+from light9.newtypes import Song
+from light9.metrics import metrics
+log = logging.getLogger()
+
+
+@dataclass
+class CaptureFrame:
+    img: PIL.Image
+    song: Song
+    t: float
+    isPlaying: bool
+    imgJpeg: Optional[bytes] = None
+
+    @metrics('jpeg_encode').time()
+    def asJpeg(self):
+        if not self.imgJpeg:
+            output = BytesIO()
+            self.img.save(output, 'jpeg', quality=80)
+            self.imgJpeg = output.getvalue()
+        return self.imgJpeg
+
+
+def songDir(song: Song) -> bytes:
+    return os.path.join(
+        showconfig.root(), b'video',
+        song.replace('http://', '').replace('/', '_').encode('ascii'))
+
+
+def takeUri(songPath: bytes) -> URIRef:
+    p = songPath.decode('ascii').split('/')
+    take = p[-1].replace('.mp4', '')
+    song = p[-2].split('_')
+    return URIRef('/'.join(
+        ['http://light9.bigasterisk.com/show', song[-2], song[-1], take]))
+
+
+def deleteClip(uri: URIRef):
+    # uri http://light9.bigasterisk.com/show/dance2019/song6/take_155
+    # path show/dance2019/video/light9.bigasterisk.com_show_dance2019_song6/take_155.*
+    w = uri.split('/')[-4:]
+    path = '/'.join([
+        w[0], w[1], 'video', f'light9.bigasterisk.com_{w[0]}_{w[1]}_{w[2]}',
+        w[3]
+    ])
+    log.info(f'deleting {uri} {path}')
+    metrics('deletes').incr()
+    for fn in [path + '.mp4', path + '.timing']:
+        os.remove(fn)
+
+
+class FramesToVideoFiles:
+    """
+
+    nextWriteAction: 'ignore'
+    currentOutputClip: None
+
+    (frames come in for new video)
+    nextWriteAction: 'saveFrame'
+    currentOutputClip: new VideoClip
+    (many frames)
+
+    (music stops or song changes)
+    nextWriteAction: 'close'
+    currentOutputClip: None
+    nextWriteAction: 'ignore'
+    
+    """
+
+    def __init__(self, frames: BehaviorSubject, root: bytes):
+        self.frames = frames
+        self.root = root
+        self.nextImg: Optional[CaptureFrame] = None
+
+        self.currentOutputClip: Optional[moviepy.editor.VideoClip] = None
+        self.currentOutputSong: Optional[Song] = None
+        self.nextWriteAction = 'ignore'
+        self.frames.subscribe(on_next=self.onFrame)
+
+    def onFrame(self, cf: Optional[CaptureFrame]):
+        if cf is None:
+            return
+        self.nextImg = cf
+
+        if self.currentOutputClip is None and cf.isPlaying:
+            # start up
+            self.nextWriteAction = 'saveFrames'
+            self.currentOutputSong = cf.song
+            self.save(
+                os.path.join(songDir(cf.song), b'take_%d' % int(time.time())))
+        elif self.currentOutputClip and cf.isPlaying:
+            self.nextWriteAction = 'saveFrames'
+            # continue recording this
+        elif self.currentOutputClip is None and not cf.isPlaying:
+            self.nextWriteAction = 'notWritingClip'
+            pass  # continue waiting
+        elif self.currentOutputClip and not cf.isPlaying or self.currentOutputSong != cf.song:
+            # stop
+            self.nextWriteAction = 'close'
+        else:
+            raise NotImplementedError(str(vars()))
+
+    def save(self, outBase):
+        """
+        receive frames (infinite) and wall-to-song times (stream ends with
+        the song), and write a video file and a frame map
+        """
+        return threads.deferToThread(self._bg_save, outBase)
+
+    def _bg_save(self, outBase: bytes):
+        os.makedirs(os.path.dirname(outBase), exist_ok=True)
+        self.frameMap = open(outBase + b'.timing', 'wt')
+
+        # todo: see moviestore.py for a better-looking version where
+        # we get to call write_frame on a FFMPEG_VideoWriter instead
+        # of it calling us back.
+
+        self.currentClipFrameCount = 0
+
+        # (immediately calls make_frame)
+        self.currentOutputClip = moviepy.editor.VideoClip(self._bg_make_frame,
+                                                          duration=999.)
+        # The fps recorded in the file doesn't matter much; we'll play
+        # it back in sync with the music regardless.
+        self.currentOutputClip.fps = 10
+        log.info(f'write_videofile {outBase} start')
+        try:
+            self.outMp4 = outBase.decode('ascii') + '.mp4'
+            self.currentOutputClip.write_videofile(self.outMp4,
+                                                   codec='libx264',
+                                                   audio=False,
+                                                   preset='ultrafast',
+                                                   logger=None,
+                                                   ffmpeg_params=['-g', '10'],
+                                                   bitrate='150000')
+        except (StopIteration, RuntimeError):
+            self.frameMap.close()
+
+        log.info('write_videofile done')
+        self.currentOutputClip = None
+
+        if self.currentClipFrameCount < 400:
+            log.info('too small- deleting')
+            deleteClip(takeUri(self.outMp4.encode('ascii')))
+
+    def _bg_make_frame(self, video_time_secs):
+        metrics('encodeFrameFps').incr()
+        if self.nextWriteAction == 'close':
+            raise StopIteration  # the one in write_videofile
+        elif self.nextWriteAction == 'notWritingClip':
+            raise NotImplementedError
+        elif self.nextWriteAction == 'saveFrames':
+            pass
+        else:
+            raise NotImplementedError(self.nextWriteAction)
+
+        # should be a little queue to miss fewer frames
+        t1 = time.time()
+        while self.nextImg is None:
+            time.sleep(.015)
+        metrics('wait_for_next_img').observe(time.time() - t1)
+        cf, self.nextImg = self.nextImg, None
+
+        self.frameMap.write(f'video {video_time_secs:g} = song {cf.t:g}\n')
+        self.currentClipFrameCount += 1
+        return numpy.asarray(cf.img)
+
+
+class GstSource:
+
+    def __init__(self, dev):
+        """
+        make new gst pipeline
+        """
+        Gst.init(None)
+        self.musicTime = MusicTime(pollCurvecalc=False)
+        self.liveImages: BehaviorSubject = BehaviorSubject(
+            None)  # stream of Optional[CaptureFrame]
+
+        # need to use 640,480 on some webcams or they fail mysteriously
+        size = [800, 600]
+
+        log.info("new pipeline using device=%s" % dev)
+
+        # using videocrop breaks the pipeline, may be this issue
+        # https://gitlab.freedesktop.org/gstreamer/gst-plugins-bad/issues/732
+        pipeStr = (
+            f"v4l2src device=\"{dev}\""
+            #            f'autovideosrc'
+            f" ! videoconvert"
+            f" ! appsink emit-signals=true max-buffers=1 drop=true name=end0 caps=video/x-raw,format=RGB,width={size[0]},height={size[1]}"
+        )
+        log.info("pipeline: %s" % pipeStr)
+
+        self.pipe = Gst.parse_launch(pipeStr)
+
+        self.setupPipelineError(self.pipe, self.onError)
+
+        self.appsink = self.pipe.get_by_name('end0')
+        self.appsink.connect('new-sample', self.new_sample)
+
+        self.pipe.set_state(Gst.State.PLAYING)
+        log.info('gst pipeline is recording video')
+
+    def new_sample(self, appsink):
+        try:
+            sample = appsink.emit('pull-sample')
+            caps = sample.get_caps()
+            buf = sample.get_buffer()
+            (result, mapinfo) = buf.map(Gst.MapFlags.READ)
+            try:
+                img = PIL.Image.frombytes(
+                    'RGB', (caps.get_structure(0).get_value('width'),
+                            caps.get_structure(0).get_value('height')),
+                    mapinfo.data)
+                img = self.crop(img)
+            finally:
+                buf.unmap(mapinfo)
+            # could get gst's frame time and pass it to getLatest
+            latest = self.musicTime.getLatest()
+            if 'song' in latest:
+                metrics('queue_gst_frame_fps').incr()
+                self.liveImages.on_next(
+                    CaptureFrame(img=img,
+                                 song=Song(latest['song']),
+                                 t=latest['t'],
+                                 isPlaying=latest['playing']))
+        except Exception:
+            traceback.print_exc()
+        return Gst.FlowReturn.OK
+
+    @metrics('crop').time()
+    def crop(self, img):
+        return img.crop((40, 100, 790, 310))
+
+    def setupPipelineError(self, pipe, cb):
+        bus = pipe.get_bus()
+
+        def onBusMessage(bus, msg):
+
+            print('nusmsg', msg)
+            if msg.type == Gst.MessageType.ERROR:
+                _, txt = msg.parse_error()
+                cb(txt)
+            return True
+
+        # not working; use GST_DEBUG=4 to see errors
+        bus.add_watch(0, onBusMessage)
+        bus.connect('message', onBusMessage)
+
+    def onError(self, messageText):
+        if ('v4l2src' in messageText and
+            ('No such file or directory' in messageText or
+             'Resource temporarily unavailable' in messageText or
+             'No such device' in messageText)):
+            log.error(messageText)
+            os.abort()
+        else:
+            log.error("ignoring error: %r" % messageText)
+
+
+'''
+class oldPipeline(object):
+
+    def __init__(self):
+        self.snapshotRequests = Queue()
+
+    def snapshot(self):
+        """
+        returns deferred to the path (which is under snapshotDir()) where
+        we saved the image. This callback comes from another thread,
+        but I haven't noticed that being a problem yet.
+        """
+        d = defer.Deferred()
+
+        def req(frame):
+            filename = "%s/%s.jpg" % ('todo', time.time())
+            log.debug("received snapshot; saving in %s", filename)
+            frame.save(filename)
+            d.callback(filename)
+
+        log.debug("requesting snapshot")
+        self.snapshotRequests.put(req)
+        return d
+
+
+        self.imagesToSave = Queue()
+        self.startBackgroundImageSaver(self.imagesToSave)
+
+    def startBackgroundImageSaver(self, imagesToSave):
+        """do image saves in another thread to not block gst"""
+
+        def imageSaver():
+            while True:
+                args = imagesToSave.get()
+                self.saveImg(*args)
+                imagesToSave.task_done()
+
+                # this is not an ideal place for snapshotRequests
+                # since imagesToSave is allowed to get backed up with
+                # image writes, yet we would still want the next new
+                # image to be used for the snapshot. chainfunc should
+                # put snapshot images in a separate-but-similar queue
+                # to imagesToSave, and then another watcher could use
+                # those to satisfy snapshot requests
+                try:
+                    req = self.snapshotRequests.get(block=False)
+                except Empty:
+                    pass
+                else:
+                    req(args[1])
+                    self.snapshotRequests.task_done()
+
+        t = Thread(target=imageSaver)
+        t.setDaemon(True)
+        t.start()
+
+    def chainfunc(self, pad, buffer):
+        position = self.musicTime.getLatest()
+'''
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/vidref.glade	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,487 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <requires lib="gtk+" version="2.16"/>
+  <!-- interface-naming-policy project-wide -->
+  <object class="GtkWindow" id="MainWindow">
+    <property name="can_focus">False</property>
+    <property name="title" translatable="yes">vidref</property>
+    <property name="default_width">690</property>
+    <property name="default_height">500</property>
+    <child>
+      <object class="GtkVBox" id="vbox1">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <child>
+          <object class="GtkHBox" id="hbox3">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkVBox" id="vbox3">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <child>
+                  <object class="GtkFrame" id="frame1">
+                    <property name="width_request">450</property>
+                    <property name="height_request">277</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label_xalign">0</property>
+                    <property name="shadow_type">out</property>
+                    <child>
+                      <object class="GtkAspectFrame" id="aspectframe2">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label_xalign">0</property>
+                        <property name="shadow_type">none</property>
+                        <property name="ratio">1.3300000429153442</property>
+                        <child>
+                          <object class="GtkDrawingArea" id="vid3">
+                            <property name="width_request">320</property>
+                            <property name="height_request">240</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                          </object>
+                        </child>
+                      </object>
+                    </child>
+                    <child type="label">
+                      <object class="GtkLabel" id="label2">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label" translatable="yes">&lt;b&gt;Live view&lt;/b&gt;</property>
+                        <property name="use_markup">True</property>
+                      </object>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkVBox" id="vbox4">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <child>
+                      <object class="GtkToggleButton" id="liveVideoEnabled">
+                        <property name="label" translatable="yes">Enabled</property>
+                        <property name="width_request">110</property>
+                        <property name="height_request">36</property>
+                        <property name="visible">True</property>
+                        <property name="can_focus">True</property>
+                        <property name="receives_default">True</property>
+                        <property name="use_action_appearance">False</property>
+                        <property name="active">True</property>
+                        <signal name="toggled" handler="on_liveVideoEnabled_toggled" swapped="no"/>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">0</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkHBox" id="hbox4">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <child>
+                          <object class="GtkLabel" id="label1">
+                            <property name="width_request">75</property>
+                            <property name="height_request">20</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">Frame rate:</property>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">True</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <object class="GtkSpinButton" id="liveFrameRate">
+                            <property name="width_request">52</property>
+                            <property name="height_request">25</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="invisible_char">●</property>
+                            <property name="primary_icon_activatable">False</property>
+                            <property name="secondary_icon_activatable">False</property>
+                            <property name="primary_icon_sensitive">True</property>
+                            <property name="secondary_icon_sensitive">True</property>
+                            <property name="numeric">True</property>
+                          </object>
+                          <packing>
+                            <property name="expand">True</property>
+                            <property name="fill">True</property>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">1</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkHBox" id="hbox5">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <child>
+                          <object class="GtkLabel" id="label4">
+                            <property name="width_request">85</property>
+                            <property name="height_request">20</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">Input source:</property>
+                          </object>
+                          <packing>
+                            <property name="expand">False</property>
+                            <property name="fill">True</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <object class="GtkComboBox" id="videoSource">
+                            <property name="width_request">100</property>
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                          </object>
+                          <packing>
+                            <property name="expand">True</property>
+                            <property name="fill">True</property>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">False</property>
+                        <property name="fill">True</property>
+                        <property name="position">2</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkHBox" id="hbox1">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <child>
+                          <object class="GtkLabel" id="label6">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">Recording
+to:</property>
+                          </object>
+                          <packing>
+                            <property name="expand">True</property>
+                            <property name="fill">True</property>
+                            <property name="position">0</property>
+                          </packing>
+                        </child>
+                        <child>
+                          <object class="GtkTextView" id="recordingToView">
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="editable">False</property>
+                            <property name="wrap_mode">char</property>
+                            <property name="buffer">recordingTo</property>
+                          </object>
+                          <packing>
+                            <property name="expand">True</property>
+                            <property name="fill">True</property>
+                            <property name="position">1</property>
+                          </packing>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">True</property>
+                        <property name="fill">True</property>
+                        <property name="position">3</property>
+                      </packing>
+                    </child>
+                    <child>
+                      <object class="GtkFrame" id="frame2">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="label_xalign">0</property>
+                        <property name="shadow_type">none</property>
+                        <child>
+                          <object class="GtkTextView" id="logView">
+                            <property name="visible">True</property>
+                            <property name="can_focus">True</property>
+                            <property name="wrap_mode">char</property>
+                            <property name="buffer">lastLog</property>
+                          </object>
+                        </child>
+                        <child type="label">
+                          <object class="GtkLabel" id="label8">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">&lt;b&gt;Last log&lt;/b&gt;</property>
+                            <property name="use_markup">True</property>
+                          </object>
+                        </child>
+                      </object>
+                      <packing>
+                        <property name="expand">True</property>
+                        <property name="fill">True</property>
+                        <property name="position">4</property>
+                      </packing>
+                    </child>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkFrame" id="replayHalf">
+                <property name="width_request">336</property>
+                <property name="height_request">259</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label_xalign">0</property>
+                <property name="shadow_type">out</property>
+                <child>
+                  <object class="GtkScrolledWindow" id="replayScrollWin">
+                    <property name="width_request">571</property>
+                    <property name="height_request">367</property>
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="hscrollbar_policy">automatic</property>
+                    <property name="vscrollbar_policy">automatic</property>
+                    <property name="shadow_type">out</property>
+                    <child>
+                      <object class="GtkViewport" id="replayScroll">
+                        <property name="visible">True</property>
+                        <property name="can_focus">False</property>
+                        <property name="resize_mode">queue</property>
+                        <child>
+                          <object class="GtkVBox" id="replayVbox">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <child>
+                              <placeholder/>
+                            </child>
+                            <child>
+                              <placeholder/>
+                            </child>
+                            <child>
+                              <placeholder/>
+                            </child>
+                          </object>
+                        </child>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+                <child type="label">
+                  <object class="GtkLabel" id="label3">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">&lt;b&gt;Playback 1&lt;/b&gt;</property>
+                    <property name="use_markup">True</property>
+                  </object>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkFrame" id="musicPosition">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label_xalign">0</property>
+            <property name="shadow_type">none</property>
+            <child>
+              <object class="GtkAlignment" id="alignment1">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="left_padding">12</property>
+                <child>
+                  <object class="GtkHScale" id="musicScale">
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="digits">2</property>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child type="label">
+              <object class="GtkLabel" id="label7">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">&lt;b&gt;Music position&lt;/b&gt;</property>
+                <property name="use_markup">True</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </object>
+  <object class="GtkImage" id="image2">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="stock">gtk-delete</property>
+  </object>
+  <object class="GtkTextBuffer" id="lastLog"/>
+  <object class="GtkTextBuffer" id="recordingTo">
+    <property name="text" translatable="yes">/home/drewp/light9-vidref/play-light9.bigasterisk.com_show_dance2010_song6/1276582699</property>
+  </object>
+  <object class="GtkWindow" id="replayPanel">
+    <property name="can_focus">False</property>
+    <child>
+      <object class="GtkHBox" id="replayPanel2">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <child>
+          <object class="GtkAspectFrame" id="aspectframe1">
+            <property name="width_request">320</property>
+            <property name="height_request">240</property>
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="label_xalign">0</property>
+            <property name="shadow_type">out</property>
+            <property name="ratio">1.3300000429153442</property>
+            <child>
+              <object class="GtkImage" id="image1">
+                <property name="width_request">320</property>
+                <property name="height_request">240</property>
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="stock">gtk-missing-image</property>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkVBox" id="vbox2">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <child>
+              <object class="GtkHBox" id="hbox2">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <child>
+                  <object class="GtkLabel" id="label5">
+                    <property name="visible">True</property>
+                    <property name="can_focus">False</property>
+                    <property name="label" translatable="yes">Started:</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">False</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkEntry" id="entry1">
+                    <property name="visible">True</property>
+                    <property name="can_focus">True</property>
+                    <property name="editable">False</property>
+                    <property name="invisible_char">●</property>
+                    <property name="width_chars">12</property>
+                    <property name="text" translatable="yes">Sat 14:22:25</property>
+                    <property name="primary_icon_activatable">False</property>
+                    <property name="secondary_icon_activatable">False</property>
+                    <property name="primary_icon_sensitive">True</property>
+                    <property name="secondary_icon_sensitive">True</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">False</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkToggleButton" id="togglebutton1">
+                <property name="label" translatable="yes">Enabled</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_action_appearance">False</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="button1">
+                <property name="label" translatable="yes">Delete</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">True</property>
+                <property name="use_action_appearance">False</property>
+                <property name="image">image2</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkCheckButton" id="checkbutton1">
+                <property name="label" translatable="yes">Pin to top</property>
+                <property name="visible">True</property>
+                <property name="can_focus">True</property>
+                <property name="receives_default">False</property>
+                <property name="use_action_appearance">False</property>
+                <property name="draw_indicator">True</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">3</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+      </object>
+    </child>
+  </object>
+</interface>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/vidref.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,189 @@
+<!doctype html>
+<html>
+  <head>
+    <title>vidref</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css" type="text/css">
+    <style>
+     body {
+       background: black;
+       color: rgb(170, 170, 170);
+       font-family: sans-serif; 
+     }
+     a {
+       color: rgb(163, 163, 255);
+     }
+     input[type=range] { width: 400px; }
+     .smallUrl { font-size: 60%; }
+
+     .jcrop-holder {
+       position: absolute !important;
+       top: 0 !important;
+       background-color: initial !important;
+     }
+    </style>
+  </head>
+  <body>
+    <h1>video setup</h1>
+    
+    <div>Camera view</div>
+    <div>
+      <div style="position: relative; display: inline-block">
+        <img id="cam" src="/picamserve/pic?resize=500&awb_mode=auto&exposure_mode=auto&shutter=100000">
+        <div id="cover" style="position: absolute; left: 0; top: 0; right: 0; bottom: 0;"></div>
+      </div>
+    </div>
+   
+    <fieldset>
+      <legend>set these</legend>
+      <div><label>shutter <input type="range" min="1" max="100000" data-bind="value: params.shutter, valueUpdate: 'input'"></label></div>
+      <div><label>brightness <span data-bind="text: params.brightness"></span> <input type="range" min="0" max="100" step="1" data-bind="value: params.brightness, valueUpdate: 'input'"></label></div>
+      <div><label>exposure_mode
+        <select data-bind="value: params.exposure_mode">
+          <option>auto</option>
+          <option>fireworks</option>
+          <option>verylong</option>
+          <option>fixedfps</option>
+          <option>backlight</option>
+          <option>antishake</option>
+          <option>snow</option>
+          <option>sports</option>
+          <option>nightpreview</option>
+          <option>night</option>
+          <option>beach</option>
+          <option>spotlight</option>
+        </select>           
+      </label></div>
+      <div><label>exposure_compensation <span data-bind="text: params.exposure_compensation"></span> <input type="range" min="-25" max="25" step="1" data-bind="value: params.exposure_compensation, valueUpdate: 'input'"></label></div>
+      <div><label>awb_mode
+        <select data-bind="value: params.awb_mode">
+          <option>horizon</option>
+          <option>off</option>
+          <option>cloudy</option>
+          <option>shade</option>
+          <option>fluorescent</option>
+          <option>tungsten</option>
+          <option>auto</option>
+          <option>flash</option>
+          <option>sunlight</option>
+          <option>incandescent</option>
+        </select>
+      </label></div>
+      <div><label>redgain <input type="range" min="0" max="8" step=".1" data-bind="value: params.redgain, valueUpdate: 'input'"></label></div>
+      <div><label>bluegain <input type="range" min="0" max="8" step=".1" data-bind="value: params.bluegain, valueUpdate: 'input'"></label></div>
+      <div><label>iso <input type="range" min="100" max="800" step="20" list="isos" data-bind="value: params.iso, valueUpdate: 'input'"></label></div>
+      <datalist id="isos">
+        <option>100</option>
+        <option>200</option>
+        <option>320</option>
+        <option>400</option>
+        <option>500</option>
+        <option>640</option>
+        <option>800</option>
+      </datalist>
+      <div><label>rotation
+        <select data-bind="value: params.rotation">
+          <option>0</option>
+          <option>90</option>
+          <option>180</option>
+          <option>270</option>
+        </select>
+      </label></div>
+      <div>See <a href="http://picamera.readthedocs.org/en/release-1.4/api.html#picamera.PiCamera.ISO">picamera attribute docs</a></div>
+    </fieldset>
+
+    <div>Resulting url: <a class="smallUrl" data-bind="attr: {href: currentUrl}, text: currentUrl"></a></div>
+
+    <div>Resulting crop image:</div>
+    <div><img id="cropped"></div>
+
+    
+    <script src="/lib/knockout/dist/knockout.js"></script>
+    <script src="/lib/jquery/dist/jquery.min.js"></script>
+    <script src="/lib/underscore/underscore-min.js"></script>
+    <script src="/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js"></script>
+    <script>
+     jQuery(function () {
+       var model = {
+         baseUrl: ko.observable(),
+         crop: ko.observable({x: 0, y: 0, w: 1, h: 1}),
+         params: {
+           shutter: ko.observable(50000),
+           exposure_mode: ko.observable('auto'),
+           awb_mode: ko.observable('auto'),
+           brightness: ko.observable(50),
+           redgain: ko.observable(1),
+           bluegain: ko.observable(1),
+           iso: ko.observable(250),
+           exposure_compensation: ko.observable(0),
+           rotation: ko.observable(0),
+         }
+       };
+       model.currentUrl = ko.computed(assembleCamUrlWithCrop);
+
+       function getBaseUrl() {
+         $.ajax({
+           url: 'picUrl',
+           success: model.baseUrl
+         });
+       }
+       
+       function imageUpdatesForever(model, img, onFirstLoad) {
+         var everLoaded = false;
+         function onLoad(ev) {
+           if (ev.type == 'load' && !everLoaded) {
+             everLoaded = true;
+             onFirstLoad();
+           }
+           
+           var src = assembleCamUrl() + '&t=' + (+new Date());
+           img.src = src;
+
+           $("#cropped").attr({src: assembleCamUrlWithCrop()});
+         }
+         img.addEventListener('load', onLoad);
+         img.addEventListener('error', onLoad);
+         
+         onLoad({type: '<startup>'})
+       }
+       
+       function assembleCamUrl() {
+         if (!model.baseUrl()) {
+           return '#';
+         }
+         return model.baseUrl() + '?resize=1080&' + $.param(ko.toJS(model.params));
+       }
+       
+       function assembleCamUrlWithCrop() {
+         return assembleCamUrl() + '&' + $.param(model.crop());
+       }
+       
+       getBaseUrl();
+       
+       imageUpdatesForever(model, document.getElementById('cam'), function onFirstLoad() {
+         var crop = $('#cover').Jcrop({onChange: function (c) {
+           var size = this.getBounds();
+           model.crop({x: c.x / size[0], y: c.y / size[1], w: c.w / size[0], h: c.h / size[1]});
+         }}, function() {
+           this.setSelect([50, 50, 100, 100]);
+         });
+       });
+
+       var putVidrefCamRequest = _.debounce(
+           function(uri) {
+             $.ajax({
+               type: 'PUT',
+               url: 'vidrefCamRequest',
+               data: {uri: uri}
+             });
+           }, 1000);
+       ko.computed(function saver() {
+         var uri = assembleCamUrlWithCrop();
+         putVidrefCamRequest(uri);
+       });
+
+       ko.applyBindings(model);
+     });
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/vidref/vidref.ui	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>863</width>
+    <height>728</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>MainWindow</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <widget class="QLabel" name="label">
+    <property name="geometry">
+     <rect>
+      <x>20</x>
+      <y>260</y>
+      <width>251</width>
+      <height>16</height>
+     </rect>
+    </property>
+    <property name="text">
+     <string>Live view</string>
+    </property>
+   </widget>
+   <widget class="QWidget" name="liveView" native="true">
+    <property name="geometry">
+     <rect>
+      <x>20</x>
+      <y>10</y>
+      <width>320</width>
+      <height>240</height>
+     </rect>
+    </property>
+   </widget>
+   <widget class="QCheckBox" name="checkBox">
+    <property name="geometry">
+     <rect>
+      <x>50</x>
+      <y>280</y>
+      <width>121</width>
+      <height>19</height>
+     </rect>
+    </property>
+    <property name="text">
+     <string>enabled</string>
+    </property>
+    <property name="checked">
+     <bool>true</bool>
+    </property>
+   </widget>
+   <widget class="QTableWidget" name="tableWidget">
+    <property name="geometry">
+     <rect>
+      <x>50</x>
+      <y>470</y>
+      <width>171</width>
+      <height>121</height>
+     </rect>
+    </property>
+    <row>
+     <property name="text">
+      <string>song</string>
+     </property>
+    </row>
+    <row>
+     <property name="text">
+      <string>time</string>
+     </property>
+    </row>
+    <column>
+     <property name="text">
+      <string>value</string>
+     </property>
+    </column>
+    <item row="0" column="0">
+     <property name="text">
+      <string>whatever</string>
+     </property>
+    </item>
+   </widget>
+   <widget class="QLabel" name="label_3">
+    <property name="geometry">
+     <rect>
+      <x>40</x>
+      <y>340</y>
+      <width>52</width>
+      <height>13</height>
+     </rect>
+    </property>
+    <property name="text">
+     <string>Song</string>
+    </property>
+   </widget>
+   <widget class="QLabel" name="label_4">
+    <property name="geometry">
+     <rect>
+      <x>40</x>
+      <y>360</y>
+      <width>52</width>
+      <height>13</height>
+     </rect>
+    </property>
+    <property name="text">
+     <string>Time</string>
+    </property>
+   </widget>
+   <widget class="QLineEdit" name="lineEdit">
+    <property name="geometry">
+     <rect>
+      <x>90</x>
+      <y>330</y>
+      <width>113</width>
+      <height>23</height>
+     </rect>
+    </property>
+   </widget>
+   <widget class="QLineEdit" name="lineEdit_2">
+    <property name="geometry">
+     <rect>
+      <x>90</x>
+      <y>360</y>
+      <width>113</width>
+      <height>23</height>
+     </rect>
+    </property>
+   </widget>
+   <widget class="QScrollArea" name="scrollArea">
+    <property name="geometry">
+     <rect>
+      <x>570</x>
+      <y>330</y>
+      <width>191</width>
+      <height>191</height>
+     </rect>
+    </property>
+    <property name="widgetResizable">
+     <bool>true</bool>
+    </property>
+    <widget class="QWidget" name="scrollAreaWidgetContents">
+     <property name="geometry">
+      <rect>
+       <x>0</x>
+       <y>0</y>
+       <width>189</width>
+       <height>189</height>
+      </rect>
+     </property>
+    </widget>
+   </widget>
+   <widget class="QGroupBox" name="groupBox">
+    <property name="geometry">
+     <rect>
+      <x>270</x>
+      <y>310</y>
+      <width>411</width>
+      <height>331</height>
+     </rect>
+    </property>
+    <property name="title">
+     <string>Replay from 16:10</string>
+    </property>
+    <widget class="QGraphicsView" name="graphicsView_2">
+     <property name="geometry">
+      <rect>
+       <x>20</x>
+       <y>30</y>
+       <width>311</width>
+       <height>231</height>
+      </rect>
+     </property>
+    </widget>
+    <widget class="QCheckBox" name="checkBox_2">
+     <property name="geometry">
+      <rect>
+       <x>60</x>
+       <y>270</y>
+       <width>191</width>
+       <height>19</height>
+      </rect>
+     </property>
+     <property name="text">
+      <string>follow current time</string>
+     </property>
+    </widget>
+    <zorder>graphicsView_2</zorder>
+    <zorder>graphicsView_2</zorder>
+    <zorder>checkBox_2</zorder>
+   </widget>
+  </widget>
+  <widget class="QMenuBar" name="menubar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>863</width>
+     <height>21</height>
+    </rect>
+   </property>
+  </widget>
+  <widget class="QStatusBar" name="statusbar"/>
+  <widget class="QToolBar" name="toolBar">
+   <property name="windowTitle">
+    <string>toolBar</string>
+   </property>
+   <attribute name="toolBarArea">
+    <enum>TopToolBarArea</enum>
+   </attribute>
+   <attribute name="toolBarBreak">
+    <bool>false</bool>
+   </attribute>
+  </widget>
+  <widget class="QToolBar" name="toolBar_2">
+   <property name="windowTitle">
+    <string>toolBar_2</string>
+   </property>
+   <attribute name="toolBarArea">
+    <enum>TopToolBarArea</enum>
+   </attribute>
+   <attribute name="toolBarBreak">
+    <bool>false</bool>
+   </attribute>
+  </widget>
+ </widget>
+ <resources/>
+ <connections/>
+ <slots>
+  <slot>startLiveView()</slot>
+ </slots>
+</ui>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/wavelength.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+#!/usr/bin/python
+
+import sys, wave
+
+
+def wavelength(filename):
+    filename = filename.replace('.ogg', '.wav')
+    wavefile = wave.open(filename, 'rb')
+
+    framerate = wavefile.getframerate()  # frames / second
+    nframes = wavefile.getnframes()  # number of frames
+    song_length = nframes / framerate
+
+    return song_length
+
+
+if __name__ == "__main__":
+    for songfile in sys.argv[1:]:
+        print(songfile, wavelength(songfile))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/wavepoints.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,43 @@
+import wave, audioop
+
+
+def simp(filename, seconds_per_average=0.001):
+    """smaller seconds_per_average means fewer data points"""
+    wavefile = wave.open(filename, 'rb')
+    print("# gnuplot data for %s, seconds_per_average=%s" %
+          (filename, seconds_per_average))
+    print(
+        "# %d channels, samplewidth: %d, framerate: %s, frames: %d\n# Compression type: %s (%s)"
+        % wavefile.getparams())
+
+    framerate = wavefile.getframerate()  # frames / second
+
+    frames_to_read = int(framerate * seconds_per_average)
+    print("# frames_to_read=%s" % frames_to_read)
+
+    time_and_max = []
+    values = []
+    count = 0
+    while True:
+        fragment = wavefile.readframes(frames_to_read)
+        if not fragment:
+            break
+
+        # other possibilities:
+        # m = audioop.avg(fragment, 2)
+        # print count, "%s %s" % audioop.minmax(fragment, 2)
+
+        m = audioop.rms(fragment, wavefile._framesize)
+        time_and_max.append((count, m))
+        values.append(m)
+        count += frames_to_read
+        # if count>1000000:
+        #     break
+
+    # find the min and max
+    min_value, max_value = min(values), max(values)
+    points = []  # (secs,height)
+    for count, value in time_and_max:
+        points.append(
+            (count / framerate, (value - min_value) / (max_value - min_value)))
+    return points
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/webcontrol.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
+"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:n="http://nevow.com/ns/nevow/0.1">
+  <head>
+    <title>light9 remote</title>
+    <style type="text/css" media="all">
+      /* <![CDATA[ */
+
+body {
+    background:black none repeat scroll 0 0;
+    color:white;
+    width:320px;
+}
+div.section {
+    border:2px groove #060;
+    margin:5px;
+    padding:5px;
+}
+.doubleWide {
+    width:120px;
+    background:#610;
+}
+.section img {
+    width:36px;
+    vertical-align: middle;
+}
+form {
+    display:inline;
+}
+button {
+    height:64px;
+    width:64px;
+    background:#035;
+    color:white;
+    margin:5px;
+    vertical-align:top;
+    border:1px #035 outset;
+    -moz-border-radius:8px;
+}
+
+div.status {
+    color:#FFFF00;
+    font-family:monospace;
+    text-align:center;
+}
+
+div.status img {
+    width: 32px;
+    vertical-align: middle;
+    margin-right: 3px;
+}
+
+      /* ]]> */
+    </style>
+
+  </head>
+  <body>
+
+    <div class="status"><n:invisible n:render="status"/></div>
+
+    <div class="section music">
+      <div class="title"><img src="icon/music.png"/> Music control</div>
+      <n:invisible n:render="songButtons"/>
+
+      <form method="post" action="stopMusic">
+	<button type="submit" class="doubleWide">Stop music</button>
+      </form>
+    </div>
+
+    <div class="section light">
+      <div class="title"><img src="icon/tvshow.png"/> Light control</div>
+      <div>
+	<form style="display: inline" method="post" action="worklightsOn">
+	  <button type="submit">Works on</button>
+	</form>
+	<form style="display: inline" method="post" action="worklightsOff">
+	  <button type="submit">Works off</button>
+	</form>
+      </div>
+    </div>
+
+  </body>
+</html>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/light9/zmqtransport.py	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,44 @@
+import json
+import logging
+from typing import Tuple
+
+from rdflib import Literal, URIRef
+from txzmq import ZmqEndpoint, ZmqFactory, ZmqPullConnection
+
+from light9.effect.settings import DeviceSettings
+from light9.metrics import metrics
+from light9.newtypes import ClientSessionType, ClientType, UnixTime
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+
+log = logging.getLogger('zmq')
+
+
+def parseJsonMessage(graph: SyncedGraph, msg) -> Tuple[ClientType, ClientSessionType, DeviceSettings, UnixTime]:
+    body = json.loads(msg)
+    settings = []
+    for device, attr, value in body['settings']:
+        if isinstance(value, str) and value.startswith('http'):
+            value = URIRef(value)
+        else:
+            value = Literal(value)
+        settings.append((URIRef(device), URIRef(attr), value))
+    return body['client'], body['clientSession'], DeviceSettings(graph, settings), body['sendTime']
+
+
+def startZmq(graph, port, collector):
+    zf = ZmqFactory()
+    addr = 'tcp://*:%s' % port
+    log.info('creating zmq endpoint at %r', addr)
+    e = ZmqEndpoint('bind', addr)
+
+    class Pull(ZmqPullConnection):
+        #highWaterMark = 3
+        def onPull(self, message):
+            with metrics('zmq_server_set_attr').time():
+                # todo: new compressed protocol where you send all URIs up
+                # front and then use small ints to refer to devices and
+                # attributes in subsequent requests.
+                client, clientSession, settings, sendTime = parseJsonMessage(graph, message[0])
+                collector.setAttrs(client, clientSession, settings, sendTime)
+
+    Pull(zf, e)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/AutoDependencies.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,137 @@
+import debug from "debug";
+import { NamedNode, Quad_Graph, Quad_Object, Quad_Predicate, Quad_Subject, Term, Util } from "n3";
+import { filter } from "underscore";
+import { Patch, QuadPattern } from "./patch";
+import { SubEvent } from "sub-events";
+import { SyncedGraph } from "./SyncedGraph";
+
+const log = debug("autodep");
+
+// use patch as an optional optimization, but you can't count on it
+export type HandlerFunc = (p?: Patch) => void;
+
+class Handler {
+  patterns: QuadPattern[];
+  innerHandlers: Handler[];
+  // a function and the quad patterns it cared about
+  constructor(public func: HandlerFunc | null, public label: string) {
+    this.patterns = []; // s,p,o,g quads that should trigger the next run
+    this.innerHandlers = []; // Handlers requested while this one was running
+  }
+}
+
+export class AutoDependencies {
+  handlers: Handler;
+  handlerStack: Handler[];
+  graphError: SubEvent<string> = new SubEvent();
+  constructor(private graph: SyncedGraph) {
+    // tree of all known Handlers (at least those with non-empty
+    // patterns). Top node is not a handler.
+    this.handlers = new Handler(null, "root");
+    this.handlerStack = [this.handlers]; // currently running
+    log("window.ad");
+    (window as any).ad = this;
+  }
+
+  runHandler(func: HandlerFunc, label: string) {
+    // what if we have this func already? duplicate is safe?
+    if (label == null) {
+      throw new Error("missing label");
+    }
+
+    const h = new Handler(func, label);
+    const tailChildren = this.handlerStack[this.handlerStack.length - 1].innerHandlers;
+    const matchingLabel = filter(tailChildren, (c: Handler) => c.label === label).length;
+    // ohno, something depends on some handlers getting run twice :(
+    if (matchingLabel < 2) {
+      tailChildren.push(h);
+    }
+    //console.time("handler #{label}")
+    // todo: this may fire 1-2 times before the
+    // graph is initially loaded, which is a waste. Try deferring it if we
+    // haven't gotten the graph yet.
+    this._rerunHandler(h, /*patch=*/ undefined);
+    log(`new handler ${label} ran first time and requested ${h.patterns.length} pats`);
+  }
+
+  _rerunHandler(handler: Handler, patch?: Patch) {
+    handler.patterns = [];
+    this.handlerStack.push(handler);
+    try {
+      if (handler.func === null) {
+        throw new Error("tried to rerun root");
+      }
+      handler.func(patch);
+    } catch (e) {
+      this.graphError.emit(String(e));
+    } finally {
+      // assuming here it didn't get to do all its queries, we could
+      // add a *,*,*,* handler to call for sure the next time?
+      // log('done. got: ', handler.patterns)
+      this.handlerStack.pop();
+    }
+  }
+
+  // handler might have no watches, in which case we could forget about it
+  logHandlerTree() {
+    log("handler tree:");
+    const shorten = (x: Term | null) => {
+      if (x === null) {
+        return "null";
+      }
+      if (!Util.isNamedNode(x)) {
+        return x.value;
+      }
+      return this.graph.shorten(x as NamedNode);
+    };
+
+    var prn = (h: Handler, indent: string) => {
+      log(`${indent} 🤝 handler "${h.label}" ${h.patterns.length} pats`);
+      for (let pat of h.patterns) {
+        log(`${indent}   ⣝ s=${shorten(pat.subject)} p=${shorten(pat.predicate)} o=${shorten(pat.object)}`);
+      }
+      Array.from(h.innerHandlers).map((c: any) => prn(c, indent + "    "));
+    };
+    prn(this.handlers, "");
+  }
+
+  _handlerIsAffected(child: Handler, patch: Patch): boolean {
+    // it should be correct but slow to always return true here
+    for (let pat of child.patterns) {
+      if (patch.matches(pat)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  graphChanged(patch: Patch) {
+    // SyncedGraph is telling us this patch just got applied to the graph.
+
+    var rerunInners = (cur: Handler) => {
+      const toRun = cur.innerHandlers.slice();
+      for (let child of Array.from(toRun)) {
+        const match = this._handlerIsAffected(child, patch);
+
+        if (match) {
+          log("match", child.label, match);
+          child.innerHandlers = []; // let all children get called again
+          this._rerunHandler(child, patch);
+        } else {
+          rerunInners(child);
+        }
+      }
+    };
+    rerunInners(this.handlers);
+  }
+
+  askedFor(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null, g: Quad_Graph | null) {
+    // SyncedGraph is telling us someone did a query that depended on
+    // quads in the given pattern.
+    // console.log(`  asked for s/${s?.id} p/${p?.id} o/${o?.id}`)
+    const current = this.handlerStack[this.handlerStack.length - 1];
+    if (current != null && current !== this.handlers) {
+      current.patterns.push({ subject: s, predicate: p, object: o, graph: g } as QuadPattern);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/EditChoice.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,118 @@
+// see light9/editchoice.py for gtk version
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { $V, Vector } from "sylvester";
+export { ResourceDisplay } from "../web/ResourceDisplay";
+const log = debug("editchoice");
+const RDFS = "http://www.w3.org/2000/01/rdf-schema#";
+
+function setupDrop(
+  senseElem: HTMLElement,
+  highlightElem: HTMLElement,
+  coordinateOriginElem: HTMLElement | null,
+  onDrop: (uri: NamedNode, pos: Vector | null) => void
+) {
+  const highlight = () => highlightElem.classList.add("dragging");
+  const unhighlight = () => highlightElem.classList.remove("dragging");
+
+  senseElem.addEventListener("drag", (event: DragEvent) => { });
+
+  senseElem.addEventListener("dragstart", (event: DragEvent) => { });
+
+  senseElem.addEventListener("dragend", (event: DragEvent) => { });
+
+  senseElem.addEventListener("dragover", (event: DragEvent) => {
+    event.preventDefault();
+    event.dataTransfer!.dropEffect = "copy";
+    highlight();
+  });
+
+  senseElem.addEventListener("dragenter", (event: DragEvent) => {
+    highlight();
+  });
+
+  senseElem.addEventListener("dragleave", (event: DragEvent) => {
+    unhighlight();
+  });
+
+  senseElem.addEventListener("drop", (event: DragEvent) => {
+    event.preventDefault();
+    const uri = new NamedNode(event.dataTransfer!.getData("text/uri-list"));
+
+    let pos: Vector | null = null;
+    if (coordinateOriginElem != null) {
+      const root = coordinateOriginElem.getBoundingClientRect();
+      pos = $V([event.pageX - root.left, event.pageY - root.top]);
+    }
+
+    try {
+      onDrop(uri, pos);
+    } catch (e) {
+      log(e);
+    }
+    unhighlight();
+  });
+}
+
+// Picks a URI based on the caller setting the property OR
+// the user drag-and-dropping a text/uri-list resource (probably
+// an <resource-display> or <a href> tag)
+@customElement("edit-choice")
+export class EditChoice extends LitElement {
+  @property() uri?: NamedNode
+  @property({ type: Boolean }) nounlink = false;
+  @property({ type: Boolean }) rename = false;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        background: #141448;
+        /* min-width: 10em; */
+        padding: 3px 8px;
+      }
+      .dragging {
+        background: rgba(126, 52, 245, 0.0784313725490196);
+        box-shadow: 0 0 20px #ffff00;
+      }
+      a {
+        color: #8e8eff;
+        padding: 3px;
+        display: inline-block;
+        font-size: 145%;
+      }
+    `,
+  ];
+  render() {
+    const unlink = html`
+    <button @click=${this.unlink}>Unlink</button>
+    `
+    return html`
+      <resource-display .uri=${this.uri} ?rename=${this.rename}></resource-display>
+      ${this.nounlink ? html`` : unlink}
+    `;
+  }
+
+  constructor() {
+    super();
+    setupDrop(this, this, null, this._setUri.bind(this));
+  }
+
+  // updated(changedProperties: PropertyValues) {
+  //   log('cp' ,changedProperties)
+  //   if (changedProperties.has("box")) {
+  //     log('setupdrop', this.box)
+  //     setupDrop(this.box, this.box, null, this._setUri.bind(this));
+  //   }
+  // }
+
+  _setUri(u?: NamedNode) {
+    this.uri = u;
+    this.dispatchEvent(new CustomEvent("edited", { detail: { newValue: u } }));
+  }
+
+  unlink() {
+    return this._setUri(undefined);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/Light9CursorCanvas.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,146 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import Sylvester from "sylvester";
+import { line } from "./drawing";
+
+const $V = Sylvester.Vector.create;
+
+const log = debug("cursor");
+
+export interface PlainViewState {
+  zoomSpec: { t1: () => number; t2: () => number };
+  fullZoomX: (t: number) => number;
+  zoomInX: (t: number) => number;
+  cursor: { t: () => number };
+  audioY: () => number;
+  audioH: () => number;
+  zoomedTimeY: () => number; // not what you think- it's the zone in between
+  zoomedTimeH: () => number;
+  mouse: { pos: () => Vector };
+}
+
+// For cases where you have a zoomed-out view on top of a zoomed-in view,
+// overlay this element and it'll draw a time cursor on both views.
+@customElement("light9-cursor-canvas")
+export class Light9CursorCanvas extends LitElement {
+  cursorPath: null | {
+    top0: Vector;
+    top1: Vector;
+    mid0: Vector;
+    mid1: Vector;
+    mid2: Vector;
+    mid3: Vector;
+    bot0: Vector;
+    bot1: Vector;
+  } = null;
+  canvasEl!: HTMLCanvasElement;
+  ctx!: CanvasRenderingContext2D;
+  offsetWidth: any;
+  offsetHeight: any;
+  @property() viewState: PlainViewState | null = null;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+    `,
+  ];
+  render() {
+    return html`<canvas></canvas>`;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("viewState")) {
+      this.redrawCursor();
+    }
+  }
+  connectedCallback() {
+    super.connectedCallback();
+    window.addEventListener("resize", this.onResize);
+    this.onResize();
+  }
+
+  firstUpdated() {
+    this.canvasEl = this.shadowRoot!.firstElementChild as HTMLCanvasElement;
+    this.onResize();
+    this.ctx = this.canvasEl.getContext("2d")!;
+  }
+
+  disconnectedCallback() {
+    window.removeEventListener("resize", this.onResize);
+    super.disconnectedCallback();
+  }
+
+  // onViewState() {
+  //   ko.computed(this.redrawCursor.bind(this));
+  // }
+
+  onResize() {
+    if (!this.canvasEl) {
+      return;
+    }
+    this.canvasEl.width = this.offsetWidth;
+    this.canvasEl.height = this.offsetHeight;
+    this.redrawCursor();
+  }
+
+  redrawCursor() {
+    const vs = this.viewState;
+    if (!vs) {
+      return;
+    }
+    const dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2()];
+    const xZoomedOut = vs.fullZoomX(vs.cursor.t());
+    const xZoomedIn = vs.zoomInX(vs.cursor.t());
+
+    this.cursorPath = {
+      top0: $V([xZoomedOut, vs.audioY()]),
+      top1: $V([xZoomedOut, vs.audioY() + vs.audioH()]),
+      mid0: $V([xZoomedIn + 2, vs.zoomedTimeY() + vs.zoomedTimeH()]),
+      mid1: $V([xZoomedIn - 2, vs.zoomedTimeY() + vs.zoomedTimeH()]),
+      mid2: $V([xZoomedOut - 1, vs.audioY() + vs.audioH()]),
+      mid3: $V([xZoomedOut + 1, vs.audioY() + vs.audioH()]),
+      bot0: $V([xZoomedIn, vs.zoomedTimeY() + vs.zoomedTimeH()]),
+      bot1: $V([xZoomedIn, this.offsetHeight]),
+    };
+    this.redraw();
+  }
+
+  redraw() {
+    if (!this.ctx || !this.viewState) {
+      return;
+    }
+    this.ctx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
+
+    this.ctx.strokeStyle = "#fff";
+    this.ctx.lineWidth = 0.5;
+    this.ctx.beginPath();
+    const mouse = this.viewState.mouse.pos();
+    line(this.ctx, $V([0, mouse.e(2)]), $V([this.canvasEl.width, mouse.e(2)]));
+    line(this.ctx, $V([mouse.e(1), 0]), $V([mouse.e(1), this.canvasEl.height]));
+    this.ctx.stroke();
+
+    if (this.cursorPath) {
+      this.ctx.strokeStyle = "#ff0303";
+      this.ctx.lineWidth = 1.5;
+      this.ctx.beginPath();
+      line(this.ctx, this.cursorPath.top0, this.cursorPath.top1);
+      this.ctx.stroke();
+
+      this.ctx.fillStyle = "#9c0303";
+      this.ctx.beginPath();
+      this.ctx.moveTo(this.cursorPath.mid0.e(1), this.cursorPath.mid0.e(2));
+      for (let p of [this.cursorPath.mid1, this.cursorPath.mid2, this.cursorPath.mid3]) {
+        this.ctx.lineTo(p.e(1), p.e(2));
+      }
+      this.ctx.fill();
+
+      this.ctx.strokeStyle = "#ff0303";
+      this.ctx.lineWidth = 3;
+      this.ctx.beginPath();
+      line(this.ctx, this.cursorPath.bot0, this.cursorPath.bot1, "#ff0303", "3px");
+      this.ctx.stroke();
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/RdfDbChannel.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,160 @@
+import debug from "debug";
+import { SubEvent } from "sub-events";
+import { SyncgraphPatchMessage } from "./patch";
+const log = debug("rdfdbclient");
+
+class ChannelPinger {
+  private timeoutId?: number;
+  private lastMs: number = 0;
+  constructor(private ws: WebSocket) {
+    this._pingLoop();
+  }
+  lastPingMs(): number {
+    return this.lastMs;
+  }
+  pong() {
+    this.lastMs = Date.now() + this.lastMs;
+  }
+  _pingLoop() {
+    if (this.ws.readyState !== this.ws.OPEN) {
+      return;
+    }
+    this.ws.send("PING");
+    this.lastMs = -Date.now();
+
+    if (this.timeoutId != null) {
+      clearTimeout(this.timeoutId);
+    }
+    this.timeoutId = (setTimeout(this._pingLoop.bind(this), 10000) as unknown) as number;
+  }
+}
+
+export class RdfDbChannel {
+  // lower level reconnecting websocket -- knows about message types, but not what's inside a patch body
+  private ws?: WebSocket = undefined;
+  private pinger?: ChannelPinger;
+  private connectionId: string = "none"; // server's name for us
+  private reconnectTimer?: NodeJS.Timeout = undefined;
+  private messagesReceived = 0; // (non-ping messages)
+  private messagesSent = 0;
+
+  newConnection: SubEvent<void> = new SubEvent();
+  serverMessage: SubEvent<{ evType: string; body: SyncgraphPatchMessage }> = new SubEvent();
+  statusDisplay: SubEvent<string> = new SubEvent();
+
+  constructor(public patchSenderUrl: string) {
+    this.openConnection();
+  }
+  sendMessage(body: string): boolean {
+    // one try, best effort, true if we think it worked
+    if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
+      return false;
+    }
+    log("send patch to server, " + body.length + " bytes");
+    this.ws.send(body);
+    this.messagesSent++;
+    this.updateStatus();
+    return true;
+  }
+
+  disconnect(why:string) {
+    // will be followed by an autoconnect
+    log("disconnect requested:", why);
+    if (this.ws !== undefined) {
+      const closeHandler = this.ws.onclose?.bind(this.ws);
+      if (!closeHandler) {
+        throw new Error();
+      }
+      closeHandler(new CloseEvent("forced"));
+    }
+  }
+
+  private openConnection() {
+    const wsOrWss = window.location.protocol.replace("http", "ws");
+    const fullUrl = wsOrWss + "//" + window.location.host + this.patchSenderUrl;
+    if (this.ws !== undefined) {
+      this.ws.close();
+    }
+    this.ws = new WebSocket(fullUrl);
+    this.ws.onopen = this.onWsOpen.bind(this, this.ws);
+    this.ws.onerror = this.onWsError.bind(this);
+    this.ws.onclose = this.onWsClose.bind(this);
+    this.ws.onmessage = this.onWsMessage.bind(this);
+  }
+
+  private onWsOpen(ws: WebSocket) {
+    log("new connection to", this.patchSenderUrl);
+    this.updateStatus();
+    this.newConnection.emit();
+    this.pinger = new ChannelPinger(ws);
+  }
+
+  private onWsMessage(evt: { data: string }) {
+    const msg = evt.data;
+    if (msg === "PONG") {
+      this.onPong();
+      return;
+    }
+    this.onJson(msg);
+  }
+
+  private onPong() {
+    if (this.pinger) {
+      this.pinger.pong();
+      this.updateStatus();
+    }
+  }
+
+  private onJson(msg: string) {
+    const input = JSON.parse(msg);
+    if (input.connectedAs) {
+      this.connectionId = input.connectedAs;
+    } else {
+      this.onPatch(input as SyncgraphPatchMessage);
+    }
+  }
+
+  private onPatch(input: SyncgraphPatchMessage) {
+    log(`patch msg from server`);
+    this.serverMessage.emit({ evType: "patch", body: input });
+    this.messagesReceived++;
+    this.updateStatus();
+  }
+
+  private onWsError(e: Event) {
+    log("ws error", e);
+    this.disconnect("ws error");
+    this.updateStatus();
+  }
+
+  private onWsClose(ev: CloseEvent) {
+    log("ws close");
+    this.updateStatus();
+    if (this.reconnectTimer !== undefined) {
+      clearTimeout(this.reconnectTimer);
+    }
+    this.reconnectTimer = setTimeout(this.openConnection.bind(this), 1000);
+  }
+
+  private updateStatus() {
+    const conn = (() => {
+      if (this.ws === undefined) {
+        return "no";
+      } else {
+        switch (this.ws.readyState) {
+          case this.ws.CONNECTING:
+            return "connecting";
+          case this.ws.OPEN:
+            return `open as ${this.connectionId}`;
+          case this.ws.CLOSING:
+            return "closing";
+          case this.ws.CLOSED:
+            return "close";
+        }
+      }
+    })();
+
+    const ping = this.pinger ? this.pinger.lastPingMs() : "...";
+    this.statusDisplay.emit(`${conn}; ${this.messagesReceived} recv; ${this.messagesSent} sent; ping ${ping}ms`);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/RdfdbSyncedGraph.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,62 @@
+import debug from "debug";
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { SyncedGraph } from "./SyncedGraph";
+
+const log = debug("syncedgraph-el");
+
+// Contains a SyncedGraph. Displays as little status box.
+// Put one element on your page and use getTopGraph everywhere.
+@customElement("rdfdb-synced-graph")
+export class RdfdbSyncedGraph extends LitElement {
+  @property() graph: SyncedGraph;
+  @property() status: string;
+  @property() testGraph = false;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        border: 1px solid gray;
+        min-width: 22em;
+        background: #05335a;
+        color: #4fc1d4;
+      }
+    `,
+  ];
+  render() {
+    return html`graph: ${this.status}`;
+  }
+
+  constructor() {
+    super();
+    this.status = "startup";
+    const prefixes = new Map<string, string>([
+      ["", "http://light9.bigasterisk.com/"],
+      ["dev", "http://light9.bigasterisk.com/device/"],
+      ["effect", "http://light9.bigasterisk.com/effect/"],
+      ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
+      ["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
+      ["xsd", "http://www.w3.org/2001/XMLSchema#"],
+    ]);
+    this.graph = new SyncedGraph(
+      this.testGraph ? "unused" : "/service/rdfdb/syncedGraph",
+      prefixes,
+      (s: string) => {
+        this.status = s;
+      }
+    );
+    setTopGraph(this.graph);
+  }
+}
+
+// todo: consider if this has anything to contribute:
+// https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
+let setTopGraph: (sg: SyncedGraph) => void;
+(window as any).topSyncedGraph = new Promise<SyncedGraph>((res, rej) => {
+  setTopGraph = res;
+});
+
+export async function getTopGraph(): Promise<SyncedGraph> {
+  const s = (window as any).topSyncedGraph;
+  return await s;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ResourceDisplay.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,164 @@
+import { TextField } from "@material/mwc-textfield";
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "./RdfdbSyncedGraph";
+import { SyncedGraph } from "./SyncedGraph";
+export { Button } from "@material/mwc-button";
+export { Dialog } from "@material/mwc-dialog";
+export { TextField } from "@material/mwc-textfield";
+
+const log = debug("rdisplay");
+
+@customElement("resource-display")
+export class ResourceDisplay extends LitElement {
+  graph!: SyncedGraph;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+
+      a.resource {
+        color: inherit;
+        text-decoration: none;
+      }
+
+      .resource {
+        border: 1px solid #545454;
+        border-radius: 5px;
+        padding: 1px;
+        margin: 2px;
+        background: rgb(49, 49, 49);
+        display: inline-block;
+        text-shadow: 1px 1px 2px black;
+      }
+      .resource.minor {
+        background: none;
+        border: none;
+      }
+      .resource a {
+        color: rgb(150, 150, 255);
+        padding: 1px;
+        display: inline-block;
+      }
+      .resource.minor a {
+        text-decoration: none;
+        color: rgb(155, 155, 193);
+        padding: 0;
+      }
+    `,
+  ];
+
+  render() {
+    let renameDialog = html``;
+    if (this.renameDialogOpen) {
+      renameDialog = html` <mwc-dialog id="renameDialog" open @closing=${this.onRenameClosing} @closed=${this.onRenameClosed}>
+        <p>
+          New label:
+          <mwc-textfield id="renameField" dialogInitialFocus .value=${this.renameTo}></mwc-textfield>
+        </p>
+        <mwc-button dialogAction="cancel" slot="secondaryAction">Cancel</mwc-button>
+        <mwc-button dialogAction="ok" slot="primaryAction">OK</mwc-button>
+      </mwc-dialog>`;
+    }
+
+    return html` <span class="${this.resClasses()}">
+        <a href="${this.href()}" id="uri"> <!-- type icon goes here -->${this.label}</a>
+        ${this.rename ? html`<button @click=${this.onRename}>Rename</button>` : ""} </span
+      >${renameDialog}`;
+    //
+  }
+  @property() uri?: NamedNode;
+
+  @state() label: string = "";
+  @state() renameDialogOpen = false;
+  @state() renameTo = "";
+
+  @property({ type: Boolean }) rename: boolean = false;
+  @property({ type: Boolean }) noclick: boolean = false;
+  @property({ type: Boolean }) minor: boolean = false;
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.onUri();
+    });
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("uri")) {
+      this.onUri();
+    }
+  }
+
+  private onUri() {
+    if (!this.graph) {
+      return; /*too soon, but getTopGraph will call us again*/
+    }
+    
+    if (this.uri === undefined) {
+      this.label = "(unset)";
+    } else if  (this.uri === null) {
+      throw 'use undefined please'
+    } else {
+      this.graph.runHandler(this.compile.bind(this, this.graph), `label for ${this.uri.id}`);
+    }
+  }
+  private compile(graph: SyncedGraph) {
+    if (this.uri === undefined) {
+      return;
+    } else {
+      this.label = this.graph.labelOrTail(this.uri);
+    }
+  }
+
+  private href(): string {
+    if (!this.uri || this.noclick) {
+      return "javascript:;";
+    }
+    return this.uri.value;
+  }
+
+  private resClasses() {
+    return this.minor ? "resource minor" : "resource";
+  }
+
+  private onRename() {
+    this.renameTo = this.label;
+    this.renameDialogOpen = true;
+    setTimeout(() => {
+      // I! 👏 know! 👏 the! 👏 element! 👏 I! 👏 want!
+      const inputEl = this.shadowRoot!.querySelector("#renameField")!.shadowRoot!.querySelector("input")! as HTMLInputElement;
+      inputEl.setSelectionRange(0, -1);
+    }, 100);
+  }
+
+  // move to SyncedGraph
+  private whatCtxHeldTheObj(subj: NamedNode, pred: NamedNode): NamedNode {
+    var ctxs = this.graph.contextsWithPattern(subj, pred, null);
+    if (ctxs.length != 1) {
+      throw new Error(`${ctxs.length} ${pred.id} stmts for ${subj.id}`);
+    }
+    return ctxs[0];
+  }
+
+  private onRenameClosing(ev: CustomEvent) {
+    this.renameTo = (this.shadowRoot!.querySelector("#renameField")! as TextField).value;
+  }
+
+  private onRenameClosed(ev: CustomEvent) {
+    this.renameDialogOpen = false;
+    if (ev.detail.action == "ok") {
+      var label = this.graph.Uri("rdfs:label");
+      if (this.uri === undefined) {
+        throw "lost uri";
+      }
+      const ctx = this.whatCtxHeldTheObj(this.uri, label);
+      this.graph.patchObject(this.uri, label, this.graph.Literal(this.renameTo), ctx);
+    }
+    this.renameTo = "";
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/SyncedGraph.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,404 @@
+import debug from "debug";
+import * as N3 from "n3";
+import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
+import { sortBy, unique } from "underscore";
+import { AutoDependencies, HandlerFunc } from "./AutoDependencies";
+import { Patch, patchToDeleteEntireGraph } from "./patch";
+import { RdfDbClient } from "./rdfdbclient";
+
+const log = debug("graph");
+
+const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+
+export class SyncedGraph {
+  private autoDeps: AutoDependencies;
+  private client: RdfDbClient;
+  private graph: N3.Store;
+  private cachedFloatValues: Map<string, number> = new Map();
+  private cachedUriValues: Map<string, N3.NamedNode> = new Map();
+  private prefixFuncs: (prefix: string) => N3.PrefixedToIri;
+  private serial: any;
+  private nextNumber: any;
+  // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own
+  // one of these. Syncs both ways with rdfdb. Meant to hide the choice of RDF lib, so we can change it
+  // later.
+  //
+  // Note that _applyPatch is the only method to write to the graph, so
+  // it can fire subscriptions.
+
+  constructor(
+    // The /syncedGraph path of an rdfdb server.
+    patchSenderUrl: string,
+    // prefixes can be used in Uri(curie) calls. This mapping may grow during loadTrig calls.
+    public prefixes: Map<string, string>,
+    private setStatus: (status: string) => void
+  ) {
+    this.prefixFuncs = this.rebuildPrefixFuncs(prefixes);
+    this.graph = new N3.Store();
+    this.autoDeps = new AutoDependencies(this);
+    this.autoDeps.graphError.subscribe((e) => {
+      log("graph learned of error - reconnecting", e);
+      this.client.disconnect("graph error");
+    });
+    this.clearGraph();
+
+    this.client = new RdfDbClient(patchSenderUrl, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
+  }
+
+  clearGraph() {
+    // must not try send a patch to the server!
+    // just deletes the statements; watchers are unaffected.
+    this.cachedFloatValues = new Map(); // s + '|' + p -> number
+    this.cachedUriValues = new Map(); // s + '|' + p -> Uri
+
+    const p = patchToDeleteEntireGraph(this.graph);
+    if (!p.isEmpty()) {
+      this._applyPatch(p);
+    }
+    // if we had a Store already, this lets N3.Store free all its indices/etc
+    this.graph = new N3.Store();
+    this.rebuildPrefixFuncs(this.prefixes);
+  }
+
+  _clearGraphOnNewConnection() {
+    // must not try send a patch to the server
+
+    log("clearGraphOnNewConnection");
+    this.clearGraph();
+    log("clearGraphOnNewConnection done");
+  }
+
+  private rebuildPrefixFuncs(prefixes: Map<string, string>) {
+    const p = Object.create(null);
+    prefixes.forEach((v: string, k: string) => (p[k] = v));
+
+    this.prefixFuncs = N3.Util.prefixes(p);
+    return this.prefixFuncs;
+  }
+
+  U() {
+    // just a shorthand
+    return this.Uri.bind(this);
+  }
+
+  Uri(curie: string) {
+    if (curie == null) {
+      throw new Error("no uri");
+    }
+    if (curie.match(/^http/)) {
+      return N3.DataFactory.namedNode(curie);
+    }
+    const part = curie.split(":");
+    return this.prefixFuncs(part[0])(part[1]);
+  }
+
+  // Uri(shorten(u)).value==u
+  shorten(uri: N3.NamedNode): string {
+    for (let row of [
+      { sh: "dev", lo: "http://light9.bigasterisk.com/theater/vet/device/" },
+      { sh: "effect", lo: "http://light9.bigasterisk.com/effect/" },
+      { sh: "", lo: "http://light9.bigasterisk.com/" },
+      { sh: "rdfs", lo: "http://www.w3.org/2000/01/rdf-schema#" },
+      { sh: "xsd", lo: "http://www.w3.org/2001/XMLSchema#" },
+    ]) {
+      if (uri.value.startsWith(row.lo)) {
+        return row.sh + ":" + uri.value.substring(row.lo.length);
+      }
+    }
+    return uri.value;
+  }
+
+  Literal(jsValue: string | number) {
+    return N3.DataFactory.literal(jsValue);
+  }
+
+  LiteralRoundedFloat(f: number) {
+    return N3.DataFactory.literal(f.toPrecision(3), this.Uri("http://www.w3.org/2001/XMLSchema#decimal"));
+  }
+
+  Quad(s: any, p: any, o: any, g: any) {
+    return N3.DataFactory.quad(s, p, o, g);
+  }
+
+  toJs(literal: { value: any }) {
+    // incomplete
+    return parseFloat(literal.value);
+  }
+
+  loadTrig(trig: any, cb: () => any) {
+    // for debugging
+    const adds: Quad[] = [];
+    const parser = new N3.Parser();
+    parser.parse(trig, (error: any, quad: any, prefixes: any) => {
+      if (error) {
+        throw new Error(error);
+      }
+      if (quad) {
+        adds.push(quad);
+      } else {
+        this._applyPatch(new Patch([], adds));
+        // todo: here, add those prefixes to our known set
+        if (cb) {
+          cb();
+        }
+      }
+    });
+  }
+
+  quads(): any {
+    // for debugging
+    return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]);
+  }
+
+  applyAndSendPatch(patch: Patch) {
+    console.time("applyAndSendPatch");
+    if (!this.client) {
+      log("not connected-- dropping patch");
+      return;
+    }
+    if (!patch.isEmpty()) {
+      this._applyPatch(patch);
+      // // chaos delay
+      //       setTimeout(()=>{
+      if (this.client) {
+        log("sending patch:\n", patch.dump());
+        this.client.sendPatch(patch);
+      }
+      // },300*Math.random())
+    }
+    console.timeEnd("applyAndSendPatch");
+  }
+
+  _applyPatch(patch: Patch) {
+    // In most cases you want applyAndSendPatch.
+    //
+    // This is the only method that writes to this.graph!
+    if (patch.isEmpty()) throw "dont send empty patches here";
+    log("_applyPatch [1] \n", patch.dump());
+    this.cachedFloatValues.clear();
+    this.cachedUriValues.clear();
+    patch.applyToGraph(this.graph);
+    if (false) {
+      log("applied patch locally", patch.summary());
+    } else {
+      log("applied patch locally:\n" + patch.dump());
+    }
+    this.autoDeps.graphChanged(patch);
+  }
+
+  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode): Patch {
+    // make a patch which removes existing values for (s,p,*,c) and
+    // adds (s,p,newObject,c). Values in other graphs are not affected.
+    const existing = this.graph.getQuads(s, p, null, g);
+    return new Patch(existing, newObject !== null ? [this.Quad(s, p, newObject, g)] : []);
+  }
+
+  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object | null, g: N3.NamedNode) {
+    this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
+  }
+
+  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
+    this.applyAndSendPatch(new Patch(this.graph.getQuads(s, p, null, g), []));
+  }
+
+  public runHandler(func: HandlerFunc, label: string) {
+    // runs your func once, tracking graph calls. if a future patch
+    // matches what you queried, we runHandler your func again (and
+    // forget your queries from the first time).
+
+    // helps with memleak? not sure yet. The point was if two matching
+    // labels get puushed on, we should run only one. So maybe
+    // appending a serial number is backwards.
+    if (!this.serial) {
+      this.serial = 1;
+    }
+    this.serial += 1;
+    //label = label + @serial
+
+    this.autoDeps.runHandler(func, label);
+  }
+
+  _singleValue(s: Quad_Subject, p: Quad_Predicate) {
+    this.autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    const objs = new Set(Array.from(quads).map((q: Quad) => q.object));
+
+    switch (objs.size) {
+      case 0:
+        throw new Error("no value for " + s.value + " " + p.value);
+      case 1:
+        var obj = objs.values().next().value;
+        return obj;
+      default:
+        throw new Error("too many different values: " + JSON.stringify(quads));
+    }
+  }
+
+  floatValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedFloatValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+    //log('float miss', s, p)
+
+    const v = this._singleValue(s, p).value;
+    const ret = parseFloat(v);
+    if (isNaN(ret)) {
+      throw new Error(`${s.value} ${p.value} -> ${v} not a float`);
+    }
+    this.cachedFloatValues.set(key, ret);
+    return ret;
+  }
+
+  stringValue(s: any, p: any) {
+    return this._singleValue(s, p).value;
+  }
+
+  uriValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedUriValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+
+    const ret = this._singleValue(s, p);
+    this.cachedUriValues.set(key, ret);
+    return ret;
+  }
+
+  labelOrTail(uri: { value: { split: (arg0: string) => any } }) {
+    let ret: any;
+    try {
+      ret = this.stringValue(uri, this.Uri("rdfs:label"));
+    } catch (error) {
+      const words = uri.value.split("/");
+      ret = words[words.length - 1];
+    }
+    if (!ret) {
+      ret = uri.value;
+    }
+    return ret;
+  }
+
+  objects(s: any, p: any): Quad_Object[] {
+    this.autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    return Array.from(quads).map((q: { object: any }) => q.object);
+  }
+
+  subjects(p: any, o: any): Quad_Subject[] {
+    this.autoDeps.askedFor(null, p, o, null);
+    const quads = this.graph.getQuads(null, p, o, null);
+    return Array.from(quads).map((q: { subject: any }) => q.subject);
+  }
+
+  subjectStatements(s: Quad_Subject): Quad[] {
+    this.autoDeps.askedFor(s, null, null, null);
+    const quads = this.graph.getQuads(s, null, null, null);
+    return quads;
+  }
+
+  items(list: any) {
+    const out = [];
+    let current = list;
+    while (true) {
+      if (current.value === RDF + "nil") {
+        break;
+      }
+
+      this.autoDeps.askedFor(current, null, null, null); // a little loose
+
+      const firsts = this.graph.getQuads(current, RDF + "first", null, null);
+      const rests = this.graph.getQuads(current, RDF + "rest", null, null);
+      if (firsts.length !== 1) {
+        throw new Error(`list node ${current} has ${firsts.length} rdf:first edges`);
+      }
+      out.push(firsts[0].object);
+
+      if (rests.length !== 1) {
+        throw new Error(`list node ${current} has ${rests.length} rdf:rest edges`);
+      }
+      current = rests[0].object;
+    }
+
+    return out;
+  }
+
+  contains(s: any, p: any, o: any): boolean {
+    this.autoDeps.askedFor(s, p, o, null);
+    // Sure this is a nice warning to remind me to rewrite, but the graph.size call itself was taking 80% of the time in here
+    // log("contains calling getQuads when graph has ", this.graph.size);
+    return this.graph.getQuads(s, p, o, null).length > 0;
+  }
+
+  nextNumberedResources(base: { id: any }, howMany: number) {
+    // base is NamedNode or string
+    // Note this is unsafe before we're synced with the graph. It'll
+    // always return 'name0'.
+    if (base.id) {
+      base = base.id;
+    }
+    const results = [];
+
+    // @contains is really slow.
+    if (this.nextNumber == null) {
+      this.nextNumber = new Map();
+    }
+    let start = this.nextNumber.get(base);
+    if (start === undefined) {
+      start = 0;
+    }
+
+    for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) {
+      const uri = this.Uri(`${base}${serial}`);
+      if (!this.contains(uri, null, null)) {
+        results.push(uri);
+        log("nextNumberedResources", `picked ${uri}`);
+        this.nextNumber.set(base, serial + 1);
+        if (results.length >= howMany) {
+          return results;
+        }
+      }
+    }
+    throw new Error(`can't make sequential uri with base ${base}`);
+  }
+
+  nextNumberedResource(base: any) {
+    return this.nextNumberedResources(base, 1)[0];
+  }
+
+  contextsWithPattern(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null): N3.NamedNode[] {
+    this.autoDeps.askedFor(s, p, o, null);
+    const ctxs: N3.NamedNode[] = [];
+    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
+      if (q.graph.termType != "NamedNode") throw `context was ${q.graph.id}`;
+      ctxs.push(q.graph);
+    }
+    return unique(ctxs);
+  }
+
+  sortKey(uri: N3.NamedNode) {
+    const parts = uri.value.split(/([0-9]+)/);
+    const expanded = parts.map(function (p: string) {
+      const f = parseInt(p);
+      if (isNaN(f)) {
+        return p;
+      }
+      return p.padStart(8, "0");
+    });
+    return expanded.join("");
+  }
+
+  sortedUris(uris: any) {
+    return sortBy(uris, this.sortKey);
+  }
+
+  prettyLiteral(x: any) {
+    if (typeof x === "number") {
+      return this.LiteralRoundedFloat(x);
+    } else {
+      return this.Literal(x);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/TiledHome.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,49 @@
+import * as React from "react";
+import { createRoot } from "react-dom/client";
+import * as FlexLayout from "flexlayout-react";
+export { Light9DeviceSettings } from "./live/Light9DeviceSettings";
+export { Light9CollectorUi } from "./collector/Light9CollectorUi";
+
+const config:FlexLayout.IJsonModel = {
+  global: {},
+  borders: [],
+  layout: {
+    type: "row",
+    weight: 100,
+    children: [
+      {
+        type: "tabset",
+        weight: 50,
+        children: [{ type: "tab", name: "devsettings", component: "light9-device-settings" }],
+      },
+      {
+        type: "tabset",
+        weight: 50,
+        children: [{ type: "tab", name: "collector", component: "light9-collector-ui" }],
+      },
+    ],
+  },
+};
+
+const e = React.createElement;
+
+// see https://github.com/lit/lit/tree/main/packages/labs/react
+
+class Main extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = { model: FlexLayout.Model.fromJson(config) };
+  }
+
+  factory = (node) => {
+    var component = node.getComponent();
+    return e(component, null, "");
+  };
+
+  render() {
+    return e(FlexLayout.Layout, { model: this.state.model, factory: this.factory });
+  }
+}
+
+const root = createRoot(document.getElementById("container")!);
+root.render(React.createElement(Main));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/Light9AscoltamiUi.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,310 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { NamedNode } from "n3";
+import Sylvester from "sylvester";
+import { Zoom } from "../light9-timeline-audio";
+import { PlainViewState } from "../Light9CursorCanvas";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { TimingUpdate } from "./main";
+import { showRoot } from "../show_specific";
+export { Light9TimelineAudio } from "../light9-timeline-audio";
+export { Light9CursorCanvas } from "../Light9CursorCanvas";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+const $V = Sylvester.Vector.create;
+
+debug.enable("*");
+const log = debug("asco");
+
+function byId(id: string): HTMLElement {
+  return document.getElementById(id)!;
+}
+async function postJson(url: string, jsBody: Object) {
+  return fetch(url, {
+    method: "POST",
+    headers: { "Content-Type": "applcation/json" },
+    body: JSON.stringify(jsBody),
+  });
+}
+@customElement("light9-ascoltami-ui")
+export class Light9AscoltamiUi extends LitElement {
+  graph!: SyncedGraph;
+  times!: { intro: number; post: number };
+  @property() nextText: string = "";
+  @property() isPlaying: boolean = false;
+  @property() show: NamedNode | null = null;
+  @property() song: NamedNode | null = null;
+  @property() selectedSong: NamedNode | null = null;
+  @property() currentDuration: number = 0;
+  @property() zoom: Zoom;
+  @property() overviewZoom: Zoom;
+  @property() viewState: PlainViewState | null = null;
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      .timeRow {
+        margin: 14px;
+        position: relative;
+      }
+      #overview {
+        height: 60px;
+      }
+      #zoomed {
+        margin-top: 40px;
+        height: 80px;
+      }
+      #cursor {
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      }
+      #grow {
+        flex: 1 1 auto;
+        display: flex;
+      }
+      #grow > span {
+        display: flex;
+        position: relative;
+        width: 50%;
+      }
+      #playSelected {
+        height: 100px;
+      }
+      #songList {
+        overflow-y: scroll;
+        position: absolute;
+        left: 0;
+        top: 0;
+        right: 0;
+        bottom: 0;
+      }
+      #songList .row {
+        width: 60%;
+        min-height: 40px;
+        text-align: left;
+        position: relative;
+      }
+      #songList .row:nth-child(even) {
+        background: #333;
+      }
+      #songList .row:nth-child(odd) {
+        background: #444;
+      }
+      #songList button {
+        min-height: 40px;
+        margin-bottom: 10px;
+      }
+      #songList .row.playing {
+        box-shadow: 0 0 30px red;
+        background-color: #de5050;
+      }
+    `,
+  ];
+  render() {
+    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <link rel="stylesheet" href="../style.css" />
+
+      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
+
+      <div id="grow">
+        <span>
+          <div id="songList">
+            <table>
+              ${this.songList.map(
+                (song) => html`
+                  <tr
+                    class="row ${classMap({
+                      playing: !!(this.song && song.equals(this.song)),
+                    })}"
+                  >
+                    <td><resource-display .uri=${song} noclick></resource-display></td>
+                    <td>
+                      <button @click=${this.onSelectSong.bind(this, song)}>
+                        <span>Select</span>
+                      </button>
+                    </td>
+                  </tr>
+                `
+              )}
+            </table>
+          </div> </span
+        ><span>
+          <div id="right">
+            <div>
+              Selected:
+              <resource-display .uri=${this.selectedSong}></resource-display>
+            </div>
+            <div>
+              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
+            </div>
+          </div>
+        </span>
+      </div>
+
+      <div class="timeRow">
+        <div id="timeSlider"></div>
+        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
+        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
+        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
+      </div>
+
+      <div class="commands">
+        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
+          <strong>Stop</strong>
+          <div class="key">s</div>
+        </button>
+        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
+          <strong>Play</strong>
+          <div class="key">p</div>
+        </button>
+        <button id="cmd-intro" @click=${this.onCmdIntro}>
+          <strong>Skip intro</strong>
+          <div class="key">i</div>
+        </button>
+        <button id="cmd-post" @click=${this.onCmdPost}>
+          <strong>Skip to Post</strong>
+          <div class="key">t</div>
+        </button>
+        <button id="cmd-go" @click=${this.onCmdGo}>
+          <strong>Go</strong>
+          <div class="key">g</div>
+          <div id="next">${this.nextText}</div>
+        </button>
+      </div>`;
+  }
+
+  onSelectSong(song: NamedNode, ev: MouseEvent) {
+    if (this.selectedSong && song.equals(this.selectedSong)) {
+      this.selectedSong = null;
+    } else {
+      this.selectedSong = song;
+    }
+  }
+  async onPlaySelected(ev: Event) {
+    if (!this.selectedSong) {
+      return;
+    }
+    await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
+  }
+
+  onCmdStop(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { pause: true });
+  }
+  onCmdPlay(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { resume: true });
+  }
+  onCmdIntro(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
+  }
+  onCmdPost(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", {
+      t: this.currentDuration - this.times.post,
+      resume: true,
+    });
+  }
+  onCmdGo(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/go", {});
+  }
+
+  bindKeys() {
+    document.addEventListener("keypress", (ev) => {
+      if (ev.which == 115) {
+        this.onCmdStop();
+        return false;
+      }
+      if (ev.which == 112) {
+        this.onCmdPlay();
+        return false;
+      }
+      if (ev.which == 105) {
+        this.onCmdIntro();
+        return false;
+      }
+      if (ev.which == 116) {
+        this.onCmdPost();
+        return false;
+      }
+
+      if (ev.key == "g") {
+        this.onCmdGo();
+        return false;
+      }
+      return true;
+    });
+  }
+
+  async musicSetup() {
+    // shoveled over from the vanillajs version
+    const config = await (await fetch("../service/ascoltami/config")).json();
+    this.show = new NamedNode(config.show);
+    this.times = config.times;
+    document.title = document.title.replace("{{host}}", config.host);
+    try {
+      const h1 = document.querySelector("h1")!;
+      h1.innerText = h1.innerText.replace("{{host}}", config.host);
+    } catch (e) {}
+
+    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
+  }
+
+  onOldStyleUpdate(data: TimingUpdate) {
+    this.nextText = data.next;
+    this.isPlaying = data.playing;
+    this.currentDuration = data.duration;
+    this.song = new NamedNode(data.song);
+    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
+    const t1 = data.t - 2,
+      t2 = data.t + 20;
+    this.zoom = { duration: data.duration, t1, t2 };
+    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
+    const w = timeRow.offsetWidth;
+    this.viewState = {
+      zoomSpec: { t1: () => t1, t2: () => t2 },
+      cursor: { t: () => data.t },
+      audioY: () => 0,
+      audioH: () => 60,
+      zoomedTimeY: () => 60,
+      zoomedTimeH: () => 40,
+      fullZoomX: (sec: number) => (sec / data.duration) * w,
+      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
+      mouse: { pos: () => $V([0, 0]) },
+    };
+  }
+
+  @property() songList: NamedNode[] = [];
+  constructor() {
+    super();
+    this.bindKeys();
+    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.musicSetup(); // async
+      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
+    });
+  }
+  graphChanged() {
+    this.songList = [];
+    try {
+      const playList = this.graph.uriValue(
+        //
+        this.graph.Uri(showRoot),
+        this.graph.Uri(":playList")
+      );
+      log(playList);
+      this.songList = this.graph.items(playList) as NamedNode[];
+    } catch (e) {
+      log("no playlist yet");
+    }
+    log(this.songList.length);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>ascoltami on {{host}}</title>
+    <link rel="stylesheet" href="../style.css" />
+    <style>
+      #cmd-go {
+        min-width: 5em;
+      }
+      .song-name {
+        padding-left: 0.4em;
+      }
+      .dimStalled #currentTime {
+        font-size: 20px;
+        background: green;
+        color: black;
+        padding: 3px;
+      }
+      .dimStalled {
+        font-size: 90%;
+      }
+      body {
+        margin: 0;
+        padding: 0;
+        overflow: hidden;
+        min-height: 100vh;
+      }
+      #page {
+        width: 100%;
+        height: 100vh; /* my phone was losing the bottom :( */
+        display: flex;
+        flex-direction: column;
+      }
+      #page > div,
+      #page > p {
+        flex: 0 1 auto;
+        margin: 0;
+      }
+      light9-ascoltami-ui {
+        flex: 1 1 auto;
+      }
+    </style>
+    <meta
+      name="viewport"
+      content="user-scalable=no, width=device-width, initial-scale=.7"
+    />
+    <script type="module" src="./Light9AscoltamiUi"></script>
+  </head>
+  <body>
+    <div id="page">
+      <h1>ascoltami on {{host}}</h1>
+      <div class="songs" style="display: none"></div>
+
+      <div class="dimStalled">
+        <table>
+          <tr>
+            <td colspan="3">
+              <strong>Song:</strong> <span id="currentSong"></span>
+            </td>
+          </tr>
+          <tr>
+            <td><strong>Time:</strong> <span id="currentTime"></span></td>
+            <td><strong>Left:</strong> <span id="leftTime"></span></td>
+            <td>
+              <strong>Until autostop:</strong>
+              <span id="leftAutoStopTime"></span>
+            </td>
+          </tr>
+          <tr>
+            <td colspan="3">
+               <span id="states"></span>
+            </td>
+          </tr>
+        </table>
+      </div>
+
+      <hr />
+      <light9-ascoltami-ui></light9-ascoltami-ui>
+      <p><a href="">reload</a></p>
+    </div>
+    <script type="module" src="./main.ts"></script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/ascoltami/main.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,100 @@
+function byId(id: string): HTMLElement {
+  return document.getElementById(id)!;
+}
+
+export interface TimingUpdate {
+  // GET /ascoltami/time response
+  duration: number;
+  next: string; // e.g. 'play'
+  playing: boolean;
+  song: string;
+  started: number; // unix sec
+  t: number; // seconds into song
+  state: { current: { name: string }; pending: { name: string } };
+}
+
+(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => {
+  let currentHighlightedSong = "";
+  // let lastPlaying = false;
+
+  
+  const events = new EventSource("../service/ascoltami/time/stream");
+  events.addEventListener("message", (m)=>{
+    const update = JSON.parse(m.data) as TimingUpdate
+    updateCurrent(update)
+    markUpdateTiming();
+  })
+
+  async function updateCurrent(data:TimingUpdate) {
+    byId("currentSong").innerText = data.song;
+    if (data.song != currentHighlightedSong) {
+      showCurrentSong(data.song);
+    }
+    byId("currentTime").innerText = data.t.toFixed(1);
+    byId("leftTime").innerText = (data.duration - data.t).toFixed(1);
+    byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1);
+    byId("states").innerText = JSON.stringify(data.state);
+    //   document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration });
+    timingUpdate(data);
+  }
+  let recentUpdates: Array<number> = [];
+  function markUpdateTiming() {
+    recentUpdates.push(+new Date());
+    recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0));
+  }
+
+  function refreshUpdateFreqs() {
+    if (recentUpdates.length > 1) {
+      if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) {
+        byId("updateActual").innerText = "(stalled)";
+        return;
+      }
+
+      var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1);
+      byId("updateActual").innerText = "" + Math.round(1000 / avgMs);
+    }
+  }
+  setInterval(refreshUpdateFreqs, 2000);
+
+  function showCurrentSong(uri: string) {
+    document.querySelectorAll(".songs div").forEach((row: Element, i: number) => {
+      if (row.querySelector("button")!.dataset.uri == uri) {
+        row.classList.add("currentSong");
+      } else {
+        row.classList.remove("currentSong");
+      }
+    });
+    currentHighlightedSong = uri;
+  }
+
+  const data = await (await fetch("api/songs")).json();
+  data.songs.forEach((song: { uri: string; label: string }) => {
+    const button = document.createElement("button");
+    // link is just for dragging, not clicking
+    const link = document.createElement("a");
+    const n = document.createElement("span");
+    n.classList.add("num");
+    n.innerText = song.label.slice(0, 2);
+    link.appendChild(n);
+
+    const sn = document.createElement("span");
+    sn.classList.add("song-name");
+    sn.innerText = song.label.slice(2).trim();
+    link.appendChild(sn);
+    link.setAttribute("href", song.uri);
+    link.addEventListener("click", (ev) => {
+      ev.stopPropagation();
+      button.click();
+    });
+    button.appendChild(link);
+    button.dataset.uri = song.uri;
+    button.addEventListener("click", async (ev) => {
+      await fetch("api/song", { method: "POST", body: song.uri });
+      showCurrentSong(song.uri);
+    });
+    const dv = document.createElement("div");
+    dv.appendChild(button);
+    document.querySelector(".songs")!.appendChild(dv);
+  });
+
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/collector/Light9CollectorDevice.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,75 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+export { ResourceDisplay } from "../../web/ResourceDisplay";
+
+const log = debug("device-el");
+
+@customElement("light9-collector-device")
+export class Light9CollectorDevice extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        break-inside: avoid-column;
+        font-size: 80%;
+      }
+      h3 {
+        margin-top: 12px;
+        margin-bottom: 0;
+      }
+      td {
+        white-space: nowrap;
+      }
+
+      td.nonzero {
+        background: #310202;
+        color: #e25757;
+      }
+      td.full {
+        background: #2b0000;
+        color: red;
+        font-weight: bold;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <h3><resource-display .uri=${this.uri}></resource-display></h3>
+      <table class="borders">
+        <tr>
+          <th>out attr</th>
+          <th>value</th>
+          <th>chan</th>
+        </tr>
+        ${this.attrs.map(
+          (item) => html`
+            <tr>
+              <td>${item.attr}</td>
+              <td class=${item.valClass}>${item.val} →</td>
+              <td>${item.chan}</td>
+            </tr>
+          `
+        )}
+      </table>
+    `;
+  }
+  @property({
+    converter: acceptStringOrUri(),
+  })
+  uri: NamedNode = new NamedNode("");
+  @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = [];
+
+  setAttrs(attrs: any) {
+    this.attrs = attrs;
+    this.attrs.forEach(function (row: any) {
+      row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
+    });
+  }
+}
+
+function acceptStringOrUri() {
+  return (s: string | null) => new NamedNode(s || "");
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/collector/Light9CollectorUi.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,91 @@
+import debug from "debug";
+import { html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import ReconnectingWebSocket from "reconnectingwebsocket";
+import { sortBy, uniq } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Light9CollectorDevice } from "./Light9CollectorDevice";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { Light9CollectorDevice };
+
+debug.enable("*");
+const log = debug("collector");
+
+@customElement("light9-collector-ui")
+export class Light9CollectorUi extends LitElement {
+  graph!: SyncedGraph;
+  render() {
+    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
+      <h1>Collector</h1>
+
+      <h2>Devices</h2>
+      <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `;
+  }
+
+  @property() devices: NamedNode[] = [];
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
+    });
+
+    const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "../service/collector/updates");
+    ws.addEventListener("message", (ev: any) => {
+      const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet;
+      if (outputAttrsSet) {
+        this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs);
+      }
+    });
+  }
+
+  findDevices(patch?: Patch) {
+    const U = this.graph.U();
+
+    this.devices = [];
+    this.clearDeviceChildElementCache();
+    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    uniq(sortBy(classes, "value"), true).forEach((dc) => {
+      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
+        this.devices.push(dev as NamedNode);
+      });
+    });
+  }
+
+  deviceElements: Map<string, Light9CollectorDevice> = new Map();
+
+  clearDeviceChildElementCache() {
+    this.deviceElements = new Map();
+  }
+
+  findDeviceChildElement(uri: string): Light9CollectorDevice | undefined {
+    const known = this.deviceElements.get(uri);
+    if (known) {
+      return known;
+    }
+
+    for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) {
+      const eld = el as Light9CollectorDevice;
+      if (eld.uri.value == uri) {
+        this.deviceElements.set(uri, eld);
+        return eld;
+      }
+    }
+
+    return undefined;
+  }
+
+  updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) {
+    const el = this.findDeviceChildElement(uri);
+    if (!el) {
+      // unresolved race: updates come in before we have device elements to display them
+      setTimeout(() => this.updateDev(uri, attrs), 300);
+      return;
+    }
+    el.setAttrs(attrs);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/collector/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>collector</title>
+    <meta charset="utf-8" />
+
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="Light9CollectorUi"></script>
+
+    <style>
+      td {
+        white-space: nowrap;
+      }
+    </style>
+  </head>
+  <body>
+    <light9-collector-ui></light9-collector-ui>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/colorpick_crosshair_large.svg	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="1000"
+   height="1000"
+   viewBox="0 0 1000 1000"
+   version="1.1"
+   id="svg8"
+   inkscape:export-filename="/home/drewp/projects-local/light9/light9/web/colorpick_crosshair_large.png"
+   inkscape:export-xdpi="60.720001"
+   inkscape:export-ydpi="60.720001"
+   inkscape:version="0.92.1 unknown"
+   sodipodi:docname="colorpick_crosshair_large.svg">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="0.7"
+     inkscape:cx="628.31802"
+     inkscape:cy="596.88994"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     showguides="true"
+     inkscape:snap-bbox="true"
+     inkscape:snap-global="true"
+     inkscape:bbox-paths="false"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:window-width="1785"
+     inkscape:window-height="1286"
+     inkscape:window-x="855"
+     inkscape:window-y="211"
+     inkscape:window-maximized="0"
+     units="px"
+     viewbox-width="100"
+     viewbox-height="100"
+     inkscape:snap-page="true"
+     scale-x="1"
+     inkscape:showpageshadow="true"
+     inkscape:pagecheckerboard="false">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4487"
+       originx="84.666667"
+       originy="42.333326" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(84.666667,-398.3332)">
+    <ellipse
+       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:0.69199997;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+       id="path4485"
+       cx="415.33334"
+       cy="898.33374"
+       rx="23.649397"
+       ry="23.649393" />
+    <path
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 391.02774,898.33321 H -84.666663"
+       id="path4489"
+       inkscape:connector-curvature="0" />
+    <ellipse
+       ry="13.000324"
+       rx="13.000322"
+       cy="898.33374"
+       cx="414.77835"
+       id="ellipse4497"
+       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4509"
+       d="m 415.33329,874.02821 v 52.083"
+       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.64999998;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:1.64999998;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 439.63886,898.33321 H 387.55552"
+       id="path4511"
+       inkscape:connector-curvature="0" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4515"
+       d="M 915.33327,898.33321 H 439.63886"
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    <path
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 415.33329,1398.3332 V 922.63921"
+       id="path4517"
+       inkscape:connector-curvature="0" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4519"
+       d="M 415.33329,874.02821 V 398.33323"
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:3.6500001;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+  </g>
+</svg>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/colorpick_crosshair_small.svg	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="400"
+   height="60"
+   viewBox="0 0 400 60"
+   version="1.1"
+   id="svg8"
+   inkscape:export-filename="/home/drewp/projects-local/light9/light9/web/colorpick_crosshair_small.png"
+   inkscape:export-xdpi="60.720001"
+   inkscape:export-ydpi="60.720001"
+   inkscape:version="0.92.1 unknown"
+   sodipodi:docname="colorpick_crosshair_small.svg">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="44.8"
+     inkscape:cx="197.93986"
+     inkscape:cy="27.463198"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="true"
+     showguides="true"
+     inkscape:snap-bbox="true"
+     inkscape:snap-global="true"
+     inkscape:bbox-paths="false"
+     inkscape:bbox-nodes="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:window-width="1785"
+     inkscape:window-height="1286"
+     inkscape:window-x="945"
+     inkscape:window-y="735"
+     inkscape:window-maximized="0"
+     units="px"
+     inkscape:snap-page="true"
+     inkscape:snap-object-midpoints="true">
+    <inkscape:grid
+       type="xygrid"
+       id="grid4487"
+       originx="84.666667"
+       originy="42.333326" />
+  </sodipodi:namedview>
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(84.666667,-1014.75)">
+    <path
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 106.75804,1044.7945 H -187.55665"
+       id="path4489"
+       inkscape:connector-curvature="0" />
+    <ellipse
+       ry="7.6999803"
+       rx="7.6999774"
+       cy="1044.7944"
+       cx="115.35117"
+       id="ellipse4497"
+       style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:0.531;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.78628826;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4509"
+       d="m 115.35115,1036.2014 v 17.1862"
+       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.26794326;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    <path
+       style="fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:0.26794326;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 123.94429,1044.7946 H 106.75804"
+       id="path4511"
+       inkscape:connector-curvature="0" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4515"
+       d="M 418.25895,1044.7945 H 123.94429"
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+    <path
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 115.35115,1347.7023 V 1053.3876"
+       id="path4517"
+       inkscape:connector-curvature="0" />
+    <path
+       inkscape:connector-curvature="0"
+       id="path4519"
+       d="M 115.35115,1036.2014 V 741.88681"
+       style="opacity:0.531;fill:none;fill-rule:evenodd;stroke:#000000;stroke-width:2.54072428;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+  </g>
+</svg>
Binary file web/colorpick_rainbow_large.png has changed
Binary file web/colorpick_rainbow_small.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/drawing.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,64 @@
+
+export function svgPathFromPoints(pts: { forEach: (arg0: (p: any) => void) => void }) {
+  let out = "";
+  pts.forEach(function (p: Number[] | { elements: Number[] }) {
+    let x, y;
+    if ((p as any).elements) {
+      // for vec2
+      [x, y] = (p as any).elements;
+    } else {
+      [x, y] = p as Number[];
+    }
+    if (out.length === 0) {
+      out = "M ";
+    } else {
+      out += "L ";
+    }
+    out += "" + x + "," + y + " ";
+  });
+  return out;
+};
+
+export function line(
+  ctx: { moveTo: (arg0: any, arg1: any) => void; lineTo: (arg0: any, arg1: any) => any },
+  p1: { e: (arg0: number) => any },
+  p2: { e: (arg0: number) => any }
+) {
+  ctx.moveTo(p1.e(1), p1.e(2));
+  return ctx.lineTo(p2.e(1), p2.e(2));
+};
+
+// http://stackoverflow.com/a/4959890
+export function roundRect(
+  ctx: {
+    beginPath: () => void;
+    moveTo: (arg0: any, arg1: any) => void;
+    lineTo: (arg0: number, arg1: number) => void;
+    arc: (arg0: number, arg1: number, arg2: any, arg3: number, arg4: number, arg5: boolean) => void;
+    closePath: () => any;
+  },
+  sx: number,
+  sy: number,
+  ex: number,
+  ey: number,
+  r: number
+) {
+  const d2r = Math.PI / 180;
+  if (ex - sx - 2 * r < 0) {
+    r = (ex - sx) / 2;
+  } // ensure that the radius isn't too large for x
+  if (ey - sy - 2 * r < 0) {
+    r = (ey - sy) / 2;
+  } // ensure that the radius isn't too large for y
+  ctx.beginPath();
+  ctx.moveTo(sx + r, sy);
+  ctx.lineTo(ex - r, sy);
+  ctx.arc(ex - r, sy + r, r, d2r * 270, d2r * 360, false);
+  ctx.lineTo(ex, ey - r);
+  ctx.arc(ex - r, ey - r, r, d2r * 0, d2r * 90, false);
+  ctx.lineTo(sx + r, ey);
+  ctx.arc(sx + r, ey - r, r, d2r * 90, d2r * 180, false);
+  ctx.lineTo(sx, sy + r);
+  ctx.arc(sx + r, sy + r, r, d2r * 180, d2r * 270, false);
+  return ctx.closePath();
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/edit-choice-demo.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,30 @@
+<!doctype html>
+<html>
+  <head>
+    <title></title>
+    <meta charset="utf-8" />
+       <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+
+    <link rel="import" href="rdfdb-synced-graph.html">
+    <link rel="import" href="edit-choice.html">
+    <script src="/node_modules/n3/n3-browser.js"></script>
+      <script src="/lib/knockout/dist/knockout.js"></script>
+      <script src="/lib/shortcut/index.js"></script>
+      <script src="/lib/async/dist/async.js"></script>
+      <script src="/lib/underscore/underscore-min.js"></script>
+  </head>
+  <body>
+    <dom-bind>
+      <template>
+        <p>
+          <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+        </p>
+        <p>
+          edit-choice: <edit-choice graph="{{graph}}" uri="http://example.com/hello"></edit-choice>
+        </p>
+        <p>
+          <a href="http://light9.bigasterisk.com/effect/spideredge" >this has a label</a>
+      </template>
+    </dom-bind>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/edit-choice.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,62 @@
+log = debug('editchoice')
+RDFS = 'http://www.w3.org/2000/01/rdf-schema#'
+
+
+
+window.setupDrop = (senseElem, highlightElem, coordinateOriginElem, onDrop) ->
+
+  highlight = -> highlightElem.classList.add('dragging')
+  unhighlight = -> highlightElem.classList.remove('dragging')
+  
+  senseElem.addEventListener 'drag', (event) =>
+    
+  senseElem.addEventListener 'dragstart', (event) =>
+    
+  senseElem.addEventListener 'dragend', (event) =>
+    
+  senseElem.addEventListener 'dragover', (event) =>
+    event.preventDefault()
+    event.dataTransfer.dropEffect = 'copy'
+    highlight()
+
+  senseElem.addEventListener 'dragenter', (event) =>
+    highlight()
+    
+  senseElem.addEventListener 'dragleave', (event) =>
+    unhighlight()
+   
+  senseElem.addEventListener 'drop', (event) ->
+    event.preventDefault()
+    uri = event.dataTransfer.getData('text/uri-list')
+
+    pos = if coordinateOriginElem?
+        root = coordinateOriginElem.getBoundingClientRect()
+        $V([event.pageX - root.left, event.pageY - root.top])
+      else
+        null
+
+    try
+      onDrop(uri, pos)
+    catch e
+      log(e)
+    unhighlight()
+
+
+
+coffeeElementSetup(class EditChoice extends Polymer.Element
+  @is: "edit-choice",
+  @getter_properties:
+    graph: {type: Object, notify: true},
+    uri: {type: String, notify: true},
+
+  _setUri: (u) ->
+    @uri = u
+    @dispatchEvent(new CustomEvent('edited'))
+
+  connectedCallback: ->
+    super.connectedCallback()
+    setupDrop(@$.box, @$.box, null, @_setUri.bind(@))
+
+  unlink: ->
+    @_setUri(null)
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/edit-choice_test.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,58 @@
+<!doctype html>
+<html>
+  <head>
+    <title>edit-choice test</title>
+    <meta charset="utf-8">
+    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+    <script src="/node_modules/mocha/mocha.js"></script>
+    <script src="/node_modules/chai/chai.js"></script>
+
+    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
+    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
+
+    <link rel="import" href="rdfdb-synced-graph.html">
+    <link rel="import" href="edit-choice.html">
+  </head>
+  <body>
+    <div id="mocha"><p><a href=".">Index</a></p></div>
+    <div id="messages"></div>
+    <div id="fixtures">
+      <dom-bind>
+        <template>
+          <p>
+            <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
+          </p>
+          <p>
+            edit-choice: <edit-choice id="ec" graph="{{graph}}" uri="http://example.com/a"></edit-choice>
+          </p>
+        </template>
+      </dom-bind>
+    </div>
+    
+    <script>
+     mocha.setup('bdd')
+     const assert = chai.assert;
+     
+     describe("resource-display", () => {
+         let ec;
+         let graph;
+         beforeEach((done) => {
+             ec = document.querySelector("#ec");
+             window.ec=ec;
+             graph = document.querySelector("#graph");
+             graph.graph.clearGraph();
+             graph.graph.loadTrig(`
+                       @prefix : <http://example.com/> .
+                       @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+                       :a rdfs:label "label a" :ctx .
+                     `, done);
+         });
+         it("shows the uri as a resource-display");
+         it("accepts a drop event and changes the uri");
+         it("clears uri when you click unlink");
+         
+     }); 
+     mocha.run();
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/effects/Light9EffectListing.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,113 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { sortBy } from "underscore";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+
+debug.enable("*");
+const log = debug("listing");
+
+@customElement("light9-effect-listing")
+export class Light9EffectListing extends LitElement {
+  render() {
+    return html`
+      <h1>Effects</h1>
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)}
+    `;
+  }
+  graph!: SyncedGraph;
+  effects: NamedNode[] = [];
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.getClasses.bind(this), "getClasses");
+    });
+  }
+
+  getClasses() {
+    const U = this.graph.U();
+    this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[];
+    this.effects = sortBy(this.effects, (ec: NamedNode) => {
+      try {
+        return this.graph.stringValue(ec, U("rdfs:label"));
+      } catch (e) {
+        return ec.value;
+      }
+    });
+    this.requestUpdate();
+  }
+}
+
+@customElement("light9-effect-class")
+export class Light9EffectClass extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        padding: 5px;
+        border: 1px solid green;
+        background: #1e271e;
+        margin-bottom: 3px;
+      }
+      a {
+        color: #7992d0;
+        background: #00000859;
+        min-width: 4em;
+        min-height: 2em;
+        display: inline-block;
+        text-align: center;
+        vertical-align: middle;
+      }
+      resource-display {
+        min-width: 12em;
+        font-size: 180%;
+      }
+    `,
+  ];
+  render() {
+    if (!this.uri) {
+      return html`loading...`;
+    }
+    return html`
+      Effect
+      <resource-display .uri=${this.uri} rename></resource-display>
+      <a href="../live?effect=${this.uri.value}">Edit</a>
+      <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax>
+      <span style="float:right">
+        <button disabled @click=${this.onAdd}>Add to current song</button>
+        <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button>
+      </span>
+    `;
+  }
+  graph!: SyncedGraph;
+  uri?: NamedNode;
+
+  onAdd() {
+    // this.$.songEffects.body = { drop: this.uri.value };
+    // this.$.songEffects.generateRequest();
+  }
+
+  onMomentaryPress() {
+    // this.$.songEffects.body = { drop: this.uri.value, event: "start" };
+    // this.lastPress = this.$.songEffects.generateRequest();
+    // return this.lastPress.completes.then((request: { response: { note: any } }) => {
+    //   return (this.lastMomentaryNote = request.response.note);
+    // });
+  }
+
+  onMomentaryRelease() {
+    // if (!this.lastMomentaryNote) {
+    //   return;
+    // }
+    // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote };
+    // this.lastMomentaryNote = null;
+    // return this.$.songEffects.generateRequest();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/effects/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,12 @@
+<!doctype html>
+<html>
+  <head>
+    <title>effect listing</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css">    
+    <script type="module" src="./Light9EffectListing"></script>
+  </head>
+  <body>
+    <light9-effect-listing></light9-effect-listing>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/fade/Light9EffectFader.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,190 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode, Quad } from "n3";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { showRoot } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+import { Patch } from "../patch";
+import { Literal } from "n3";
+export { Light9Fader } from "./Light9Fader";
+
+const log = debug("efffader")
+
+//////////////////////////////////////
+const RETURN_URI = new NamedNode("");
+const RETURN_FLOAT = 1;
+function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined {
+  // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned .
+  let x: NamedNode;
+  try {
+    x = graph.uriValue(subj1, pred1);
+  } catch (e) {
+    return undefined;
+  }
+  try {
+    if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") {
+      return graph.uriValue(x, pred2) as T;
+    } else if (typeof returnWhat === "number") {
+      return graph.floatValue(x, pred2) as T;
+    }
+  } catch (e) {
+    return undefined;
+  }
+}
+function set2Step(
+  graph: SyncedGraph, //
+  subj1: NamedNode,
+  pred1: NamedNode,
+  baseName: string,
+  pred2: NamedNode,
+  newObjLiteral: Literal
+) { }
+
+function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined {
+  try {
+    return graph.uriValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined {
+  try {
+    return graph.stringValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined {
+  try {
+    return graph.floatValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+
+//////////////////////////////////////
+class EffectFader {
+  constructor(public uri: NamedNode) { }
+  column: string = "unset";
+  effect?: NamedNode;
+  effectAttr?: NamedNode; // :strength
+  setting?: NamedNode; // we assume fader always has exactly one setting
+  value?: number;
+}
+
+@customElement("light9-effect-fader")
+export class Light9EffectFader extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        border: 2px gray outset;
+        background: #272727;
+      }
+      light9-fader {
+        margin: 0px;
+        width: 100%;
+      }
+    `,
+  ];
+  render() {
+    if (this.conf === undefined || this.conf.value === undefined) {
+      return html`...`;
+    }
+    return html`
+      <div><resource-display .uri=${this.uri}></resource-display>
+      <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader>
+      <div>${this.conf.value.toPrecision(3)}</div>
+      <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div>
+      <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div>
+    `;
+  }
+
+  graph?: SyncedGraph;
+  ctx: NamedNode = new NamedNode(showRoot + "/fade");
+  @property() uri!: NamedNode;
+  @state() conf?: EffectFader; // compiled from graph
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this, this.graph), `fader config ${this.uri.value}`);
+    });
+  }
+
+  private compile(graph: SyncedGraph) {
+    const U = graph.U();
+    this.conf = undefined;
+
+    const conf = new EffectFader(this.uri);
+
+    if (!graph.contains(this.uri, U("rdf:type"), U(":Fader"))) {
+      // not loaded yet, perhaps
+      return;
+    }
+
+    conf.column = maybeStringValue(graph, this.uri, U(":column")) || "unset";
+    conf.effect = maybeUriValue(graph, this.uri, U(":effect"));
+    conf.effectAttr = get2Step(RETURN_URI, graph, this.uri, U(":setting"), U(":effectAttr"));
+
+    this.conf = conf;
+    graph.runHandler(this.compileValue.bind(this, graph, this.conf), `fader config.value ${this.uri.value}`);
+  }
+
+  private compileValue(graph: SyncedGraph, conf: EffectFader) {
+    // external graph change -> conf.value
+    const U = graph.U();
+    conf.value = get2Step(RETURN_FLOAT, graph, this.uri, U(":setting"), U(":value"));
+    // since conf attrs aren't watched as property:
+    this.requestUpdate()
+  }
+
+  onSliderInput(ev: CustomEvent) {
+    // slider user input -> graph
+    if (this.conf === undefined) return;
+    this.conf.value = ev.detail.value
+    this.writeValueToGraph()
+  }
+
+  writeValueToGraph() {
+    // this.value -> graph
+    if (this.graph === undefined) {
+      return;
+    }
+    const U = this.graph.U();
+    if (this.conf === undefined) {
+      return;
+    }
+    if (this.conf.value === undefined) {
+      log(`value of ${this.uri} is undefined`)
+      return;
+    }
+    log('writeValueToGraph', this.conf.value)
+    const valueTerm = this.graph.LiteralRoundedFloat(this.conf.value);
+    const settingNode = this.graph.uriValue(this.uri, U(":setting"));
+    this.graph.patchObject(settingNode, this.graph.Uri(":value"), valueTerm, this.ctx);
+
+  }
+
+  onEffectChange(ev: CustomEvent) {
+    if (this.graph === undefined) {
+      return;
+    }
+    const { newValue } = ev.detail;
+    this.graph.patchObject(this.uri, this.graph.Uri(":effect"), newValue, this.ctx);
+  }
+
+  onEffectAttrChange(ev: CustomEvent) {
+    if (this.graph === undefined) {
+      return;
+    }
+    // const { newValue } = ev.detail;
+    // if (this.setting === undefined) {
+    //   this.setting = this.graph.nextNumberedResource(this.graph.Uri(":fade_set"));
+    //   this.graph.patchObject(this.uri, this.graph.Uri(":setting"), this.setting, this.ctx);
+    // }
+    // this.graph.patchObject(this.setting, this.graph.Uri(":effectAttr"), newValue, this.ctx);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/fade/Light9FadeUi.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,169 @@
+import debug from "debug";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import * as N3 from "n3";
+import { NamedNode, Quad } from "n3";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { showRoot } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+export { EditChoice } from "../EditChoice";
+export { Light9EffectFader } from "./Light9EffectFader";
+export { Light9Fader } from "./Light9Fader";
+
+debug.enable("*,autodep");
+const log = debug("fade");
+
+class FaderConfig {
+  constructor(public uri: NamedNode, public column: number) { }
+}
+
+class FadePage {
+  constructor(public uri: NamedNode) { }
+  faderConfigs: FaderConfig[] = [];
+}
+class FadePages {
+  pages: FadePage[] = [];
+}
+
+@customElement("light9-fade-ui")
+export class Light9FadeUi extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        user-select: none; /* really this is only desirable during slider drag events */
+      }
+      .mappedToHw {
+        background: #393945;
+      }
+      #gm light9-fader {
+        width: 300px;
+      }
+    `,
+  ];
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>Fade</h1>
+<div id="gm">
+  <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master
+</div>
+      ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))}
+
+      <div><button @click=${this.addPage}>Add new page</button></div>
+    `;
+  }
+  private renderPage(page: FadePage): TemplateResult {
+    const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage);
+    return html`<div class="${mappedToHw ? "mappedToHw" : ""}">
+      <fieldset>
+        <legend>
+          Page
+          <resource-display rename .uri=${page.uri}></resource-display>
+          ${mappedToHw ? html`mapped to hardware sliders` : html`
+          <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button>
+          `}
+        </legend>
+        ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)}
+      </fieldset>
+    </div>`;
+  }
+
+  graph!: SyncedGraph;
+  ctx: NamedNode = new NamedNode(showRoot + "/fade");
+
+  @property() fadePages?: FadePages;
+  @property() currentHwPage?: NamedNode;
+  @property() grandMaster?: number;
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this), `faders layout`);
+      this.graph.runHandler(this.compileGm.bind(this), `faders gm`);
+    });
+  }
+  connectedCallback(): void {
+    super.connectedCallback();
+  }
+
+  compile() {
+    const U = this.graph.U();
+    this.fadePages = undefined;
+    const fadePages = new FadePages();
+    for (let page of this.graph.subjects(U("rdf:type"), U(":FadePage"))) {
+      const fp = new FadePage(page as NamedNode);
+      try {
+        for (let fader of this.graph.objects(page, U(":fader"))) {
+          const colLit = this.graph.stringValue(fader, U(':column'))
+          fp.faderConfigs.push(new FaderConfig(fader as NamedNode, parseFloat(colLit)));
+        }
+        fp.faderConfigs.sort((a, b) => {
+          return a.column - (b.column);
+        });
+        fadePages.pages.push(fp);
+      } catch (e) { }
+    }
+    fadePages.pages.sort((a, b) => {
+      return a.uri.value.localeCompare(b.uri.value);
+    });
+    this.fadePages = fadePages;
+    this.currentHwPage = undefined;
+    try {
+      const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+      this.currentHwPage = this.graph.uriValue(mc, U(":outputs"));
+    } catch (e) { }
+  }
+  compileGm() {
+    const U = this.graph.U();
+    this.grandMaster = undefined
+    let newVal
+    try {
+
+      newVal = this.graph.floatValue(U(':grandMaster'), U(':value'))
+    } catch (e) {
+      return
+    }
+    this.grandMaster = newVal;
+
+  }
+  gmChanged(ev: CustomEvent) {
+    const U = this.graph.U();
+    const newVal = ev.detail.value
+    // this.grandMaster = newVal;
+    this.graph.patchObject(U(':grandMaster'), U(':value'), this.graph.LiteralRoundedFloat(newVal), this.ctx)
+
+  }
+
+
+  mapThisToHw(page: NamedNode) {
+    const U = this.graph.U();
+    log("map to hw", page);
+    const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+    this.graph.patchObject(mc, U(":outputs"), page, this.ctx);
+  }
+
+  addPage() {
+    const U = this.graph.U();
+    const uri = this.graph.nextNumberedResource(showRoot + "/fadePage");
+    const adds = [
+      //
+      new Quad(uri, U("rdf:type"), U(":FadePage"), this.ctx),
+      new Quad(uri, U("rdfs:label"), N3.DataFactory.literal("unnamed"), this.ctx),
+    ];
+    for (let n = 1; n <= 8; n++) {
+      const f = this.graph.nextNumberedResource(showRoot + "/fader");
+      const s = this.graph.nextNumberedResource(showRoot + "/faderset");
+      adds.push(new Quad(uri, U(":fader"), f, this.ctx));
+      adds.push(new Quad(f, U("rdf:type"), U(":Fader"), this.ctx));
+      adds.push(new Quad(f, U(":column"), N3.DataFactory.literal("" + n), this.ctx));
+      adds.push(new Quad(f, U(":setting"), s, this.ctx));
+      adds.push(new Quad(s, U(":effectAttr"), U(":strength"), this.ctx));
+      adds.push(new Quad(s, U(":value"), this.graph.LiteralRoundedFloat(0), this.ctx));
+    }
+    this.graph.applyAndSendPatch(new Patch([], adds));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/fade/Light9Fader.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,146 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValueMap } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+
+import { clamp } from "../floating_color_picker";
+const log = debug("fade");
+
+class Drag {
+  constructor(public startDragPxY: number, public startDragValue: number) {}
+}
+
+@customElement("light9-fader")
+export class Light9Fader extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        border: 2px gray inset;
+        background: #000;
+        height: 80px;
+      }
+      #handle {
+        background: gray;
+        border: 5px gray outset;
+        position: relative;
+        left: 0;
+        right: -25px;
+      }
+    `,
+  ];
+
+  @property() value: number = 0;
+
+  @query("#handle") handleEl!: HTMLElement;
+
+  troughHeight = 80 - 2 - 2 - 5 - 5;
+  handleHeight = 10;
+
+  drag?: Drag;
+  unmutedValue: number = 1;
+
+  render() {
+    return html` <div id="handle"><hr /></div> `;
+  }
+
+  protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.update(changedProperties);
+    if (changedProperties.has("value")) {
+      
+    }
+  }
+  valueChangedFromUi() {
+    this.value= clamp(this.value, 0, 1)
+    this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value } }));
+  }
+
+  protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.updated(_changedProperties);
+    const y = this.sliderTopY(this.value);
+    this.handleEl.style.top = y + "px";
+  }
+
+  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.firstUpdated(_changedProperties);
+    this.handleEl.style.height = this.handleHeight + "px";
+    this.events();
+  }
+
+  events() {
+    const hand = this.handleEl;
+    hand.addEventListener("mousedown", (ev: MouseEvent) => {
+      ev.stopPropagation();
+      if (ev.buttons == 1) {
+        this.drag = new Drag(ev.clientY, this.value);
+      } else if (ev.buttons == 2) {
+        this.onRmb();
+      }
+    });
+    this.addEventListener("mousedown", (ev: MouseEvent) => {
+      ev.stopPropagation();
+      if (ev.buttons == 1) {
+        this.value = this.sliderValue(ev.offsetY);
+        this.valueChangedFromUi()
+        this.drag = new Drag(ev.clientY, this.value);
+      } else if (ev.buttons == 2) {
+        // RMB in trough
+        this.onRmb();
+      }
+    });
+
+    this.addEventListener("contextmenu", (event) => {
+      event.preventDefault();
+    });
+
+    this.addEventListener("wheel", (ev: WheelEvent) => {
+      ev.preventDefault();
+      this.value += ev.deltaY / this.troughHeight * -.05;
+      this.valueChangedFromUi()
+    });
+
+    const maybeDrag = (ev: MouseEvent) => {
+      if (ev.buttons != 1) return;
+      if (this.drag === undefined) return;
+      ev.stopPropagation();
+      this.onMouseDrag(ev.clientY - this.drag.startDragPxY!);
+    };
+    hand.addEventListener("mousemove", maybeDrag);
+    this.addEventListener("mousemove", maybeDrag);
+    window.addEventListener("mousemove", maybeDrag);
+
+    hand.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+    this.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+    window.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+  }
+  onRmb() {
+    if (this.value > 0.1) {
+      // mute
+      this.unmutedValue = this.value;
+      this.value = 0;
+    } else {
+      // unmute
+      this.value = this.unmutedValue;
+    }
+    this.valueChangedFromUi()
+  }
+  onMouseDrag(dy: number) {
+    if (this.drag === undefined) throw "unexpected";
+    this.value = this.drag.startDragValue - dy / this.troughHeight;
+    this.valueChangedFromUi()
+  }
+
+  onMouseUpAnywhere() {
+    this.drag = undefined;
+  }
+
+  sliderTopY(value: number): number {
+    const usableY = this.troughHeight - this.handleHeight;
+    const yAdj = this.handleHeight / 2 - 5 - 2;
+    return (1 - value) * usableY + yAdj;
+  }
+  sliderValue(offsetY: number): number {
+    const usableY = this.troughHeight - this.handleHeight;
+    const yAdj = this.handleHeight / 2 - 5 - 2;
+    return clamp(1 - (offsetY - yAdj) / usableY, 0, 1);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/fade/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+  <head>
+    <title>fade</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css">    
+    <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script>
+    <script type="module" src="./Light9FadeUi"></script>
+  </head>
+  <body>
+    <light9-fade-ui></light9-fade-ui>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/floating_color_picker.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,321 @@
+// Note that this file deals only with hue+sat. See Light9ColorPicker for the value component.
+
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, query } from "lit/decorators.js";
+import color from "onecolor";
+import { SubEvent } from "sub-events";
+
+const log = debug("control.color.pick");
+
+export function clamp(x: number, lo: number, hi: number) {
+  return Math.max(lo, Math.min(hi, x));
+}
+
+class RainbowCoord {
+  // origin is rainbow top-lefft
+  constructor(public x: number, public y: number) {}
+}
+
+export class ClientCoord {
+  //  origin is top-left of client viewport (regardless of page scroll)
+  constructor(public x: number, public y: number) {}
+}
+
+// Load the rainbow, and map between colors and pixels.
+class RainbowCanvas {
+  ctx: CanvasRenderingContext2D;
+  colorPos: { [color: string]: RainbowCoord } = {};
+  _loaded = false;
+  _loadWatchers: (() => void)[] = [];
+  constructor(url: string, public size: RainbowCoord) {
+    var elem = document.createElement("canvas");
+    elem.width = size.x;
+    elem.height = size.y;
+    this.ctx = elem.getContext("2d")!;
+
+    var img = new Image();
+    img.onload = () => {
+      this.ctx.drawImage(img, 0, 0);
+      this._readImage();
+      this._loaded = true;
+      this._loadWatchers.forEach(function (cb) {
+        cb();
+      });
+      this._loadWatchers = [];
+    };
+    img.src = url;
+  }
+
+  onLoad(cb: () => void) {
+    // we'll call this when posFor is available
+    if (this._loaded) {
+      cb();
+      return;
+    }
+    this._loadWatchers.push(cb);
+  }
+
+  _readImage() {
+    var data = this.ctx.getImageData(0, 0, this.size.x, this.size.y).data;
+    for (var y = 0; y < this.size.y; y += 1) {
+      for (var x = 0; x < this.size.x; x += 1) {
+        var base = (y * this.size.x + x) * 4;
+        let px = [data[base + 0], data[base + 1], data[base + 2], 255];
+        if (px[0] == 0 && px[1] == 0 && px[2] == 0) {
+          // (there's no black on the rainbow images)
+          throw new Error(`color picker canvas (${this.size.x}) returns 0,0,0`);
+        }
+        var c = color(px).hex();
+        this.colorPos[c] = new RainbowCoord(x, y);
+      }
+    }
+  }
+
+  colorAt(pos: RainbowCoord) {
+    var data = this.ctx.getImageData(pos.x, pos.y, 1, 1).data;
+    return color([data[0], data[1], data[2], 255]).hex();
+  }
+
+  posFor(col: string): RainbowCoord {
+    if (col == "#000000") {
+      throw new Error("no match");
+    }
+
+    log("col", col);
+    if (col == "#ffffff") {
+      return new RainbowCoord(400 / 2, 0);
+    }
+
+    let bright = color(col).value(1).hex();
+    let r = parseInt(bright.slice(1, 3), 16),
+      g = parseInt(bright.slice(3, 5), 16),
+      b = parseInt(bright.slice(5, 7), 16);
+
+    // We may not have a match for this color exactly (e.g. on
+    // the small image), so we have to search for a near one.
+
+    // 0, 1, -1, 2, -2, ...
+    let walk = function (x: number): number {
+      return -x + (x > 0 ? 0 : 1);
+    };
+
+    var radius = 8;
+    for (var dr = 0; dr < radius; dr = walk(dr)) {
+      for (var dg = 0; dg < radius; dg = walk(dg)) {
+        for (var db = 0; db < radius; db = walk(db)) {
+          // Don't need bounds check- out of range
+          // corrupt colors just won't match.
+          const color2 = color([r + dr, g + dg, b + db, 255]);
+          const pos = this.colorPos[color2.hex()];
+          if (pos !== undefined) {
+            return pos;
+          }
+        }
+      }
+    }
+    throw new Error("no match");
+  }
+}
+
+// One-per-page element that floats above everything. Plus the scrim element, which is also per-page.
+@customElement("light9-color-picker-float")
+class Light9ColorPickerFloat extends LitElement {
+  static styles = [
+    css`
+      :host {
+        z-index: 10;
+        position: fixed; /* host coords are the same as client coords /*
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+
+        /* Updated later. */
+        display: none;
+      }
+      #largeCrosshair {
+        position: absolute;
+        left: -60px;
+        top: -62px;
+        pointer-events: none;
+      }
+      #largeCrosshair {
+        background: url(/colorpick_crosshair_large.svg);
+        width: 1000px;
+        height: 1000px;
+      }
+      #largeRainbowComp {
+        z-index: 2;
+        position: relative;
+        width: 400px;
+        height: 200px;
+        border: 10px solid #000;
+        box-shadow: 8px 11px 40px 0px rgba(0, 0, 0, 0.74);
+        overflow: hidden;
+      }
+      #largeRainbow {
+        background: url(/colorpick_rainbow_large.png);
+        width: 400px;
+        height: 200px;
+        user-select: none;
+      }
+      #outOfBounds {
+        user-select: none;
+        z-index: 1;
+        background: #00000060;
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      }
+    `,
+  ];
+
+  @query("#largeCrosshair") largeCrosshairEl!: HTMLElement;
+  @query("#largeRainbow") largeRainbowEl!: HTMLElement;
+
+  canvasMove: SubEvent<RainbowCoord> = new SubEvent();
+  outsideMove: SubEvent<ClientCoord> = new SubEvent();
+  mouseUp: SubEvent<void> = new SubEvent();
+
+  render() {
+    return html`
+      <!-- Temporary scrim on the rest of the page. It looks like we're dimming
+            the page to look pretty, but really this is so we can track the mouse
+            when it's outside the large canvas. -->
+      <div id="outOfBounds" @mousemove=${this.onOutOfBoundsMove} @mouseup=${this.onMouseUp}></div>
+      <div id="largeRainbowComp">
+        <div id="largeRainbow" @mousemove=${this.onCanvasMove} @mouseup=${this.onMouseUp}></div>
+        <div id="largeCrosshair"></div>
+      </div>
+    `;
+  }
+
+  // make top-left of rainbow image be at this pos
+  placeRainbow(pos: ClientCoord) {
+    const el = this.shadowRoot?.querySelector("#largeRainbowComp")! as HTMLElement;
+    const cssBorder = 10;
+    el.style.left = pos.x - cssBorder + "px";
+    el.style.top = pos.y - cssBorder + "px";
+  }
+
+  moveLargeCrosshair(pos: RainbowCoord) {
+    const ch = this.largeCrosshairEl;
+    ch.style.left = pos.x - ch.offsetWidth / 2 + "px";
+    ch.style.top = pos.y - ch.offsetHeight / 2 + "px";
+  }
+
+  private onCanvasMove(ev: MouseEvent) {
+    this.canvasMove.emit(new RainbowCoord(ev.offsetX, ev.offsetY));
+  }
+
+  private onMouseUp(ev: MouseEvent) {
+    this.mouseUp.emit();
+  }
+
+  private onOutOfBoundsMove(ev: MouseEvent) {
+    this.outsideMove.emit(new ClientCoord(ev.clientX, ev.clientY));
+  }
+}
+
+class PickerFloat {
+  private rainbow?: RainbowCanvas;
+  private currentListener?: (hsc: string) => void;
+  private rainbowOrigin: ClientCoord = new ClientCoord(0, 0);
+  private floatEl?: Light9ColorPickerFloat;
+
+  pageInit() {
+    this.getFloatEl();
+    this.getRainbow();
+  }
+  private forceHostStyle(el: HTMLElement) {
+    el.style.zIndex = "10";
+    el.style.position = "fixed";
+    el.style.left = "0";
+    el.style.top = "0";
+    el.style.width = "100%";
+    el.style.height = "100%";
+    el.style.display = "none";
+  }
+  private getFloatEl(): Light9ColorPickerFloat {
+    if (!this.floatEl) {
+      this.floatEl = document.createElement("light9-color-picker-float") as Light9ColorPickerFloat;
+      this.forceHostStyle(this.floatEl);
+      this.subscribeToFloatElement(this.floatEl);
+      document.body.appendChild(this.floatEl);
+    }
+    return this.floatEl;
+  }
+
+  private subscribeToFloatElement(el: Light9ColorPickerFloat) {
+    el.canvasMove.subscribe(this.onCanvasMove.bind(this));
+    el.outsideMove.subscribe(this.onOutsideMove.bind(this));
+    el.mouseUp.subscribe(() => {
+      this.hide();
+    });
+  }
+
+  private onCanvasMove(pos: RainbowCoord) {
+    pos = new RainbowCoord( //
+      clamp(pos.x, 0, 400 - 1), //
+      clamp(pos.y, 0, 200 - 1)
+    );
+    this.getFloatEl().moveLargeCrosshair(pos);
+    if (this.currentListener) {
+      this.currentListener(this.getRainbow().colorAt(pos));
+    }
+  }
+
+  private onOutsideMove(pos: ClientCoord) {
+    const rp = this.toRainbow(pos);
+    this.onCanvasMove(rp);
+  }
+
+  private getRainbow(): RainbowCanvas {
+    if (!this.rainbow) {
+      this.rainbow = new RainbowCanvas("/colorpick_rainbow_large.png", new RainbowCoord(400, 200));
+    }
+    return this.rainbow;
+  }
+
+  startPick(clickPoint: ClientCoord, startColor: string, onNewHueSatColor: (hsc: string) => void) {
+    const el = this.getFloatEl();
+
+    let pos: RainbowCoord;
+    try {
+      pos = this.getRainbow().posFor(startColor);
+    } catch (e) {
+      pos = new RainbowCoord(-999, -999);
+    }
+
+    this.rainbowOrigin = new ClientCoord( //
+      clickPoint.x - clamp(pos.x, 0, 400), //
+      clickPoint.y - clamp(pos.y, 0, 200)
+    );
+
+    el.placeRainbow(this.rainbowOrigin);
+    setTimeout(() => {
+      this.getFloatEl().moveLargeCrosshair(pos);
+    }, 1);
+
+    el.style.display = "block";
+    this.currentListener = onNewHueSatColor;
+  }
+
+  private hide() {
+    const el = this.getFloatEl();
+    el.style.display = "none";
+    this.currentListener = undefined;
+  }
+
+  private toRainbow(pos: ClientCoord): RainbowCoord {
+    return new RainbowCoord( //
+      pos.x - this.rainbowOrigin.x, //
+      pos.y - this.rainbowOrigin.y
+    );
+  }
+}
+
+export const pickerFloat = new PickerFloat();
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/graph_test.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,257 @@
+log = console.log
+assert = require('chai').assert
+expect = require('chai').expect
+SyncedGraph = require('./graph.js').SyncedGraph
+
+describe 'SyncedGraph', ->
+  describe 'constructor', ->
+    it 'should successfully make an empty graph without connecting to rdfdb', ->
+      g = new SyncedGraph()
+      g.quads()
+      assert.equal(g.quads().length, 0)
+
+  describe 'auto dependencies', ->
+    graph = new SyncedGraph()
+    RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+    U = (tail) -> graph.Uri('http://example.com/' + tail)
+    A1 = U('a1')
+    A2 = U('a2')
+    A3 = U('a3')
+    A4 = U('a4')
+    ctx = U('ctx')
+    quad = (s, p, o) -> graph.Quad(s, p, o, ctx)
+
+    beforeEach (done) ->
+      graph = new SyncedGraph()
+      graph.loadTrig("
+        @prefix : <http://example.com/> .
+        :ctx {
+          :a1 :a2 :a3 .
+          :a1 :someFloat 1.5 .
+          :a1 :someString \"hello\" .
+          :a1 :multipleObjects :a4, :a5 .
+          :a2 a :Type1 .
+          :a3 a :Type1 .
+        }
+      ", done)
+    
+    it 'calls a handler right away', ->
+      called = 0
+      hand = ->
+        called++
+      graph.runHandler(hand, 'run')
+      assert.equal(1, called)
+      
+    it 'calls a handler a 2nd time if the graph is patched with relevant data', ->
+      called = 0
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+      graph.runHandler(hand, 'run')
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      assert.equal(2, called)
+
+    it 'notices new queries a handler makes upon rerun', ->
+      called = 0
+      objsFound = []
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+        if called > 1
+          objsFound.push(graph.objects(A1, A3))
+      graph.runHandler(hand, 'run')
+      # first run looked up A1,A2,*
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      # second run also looked up A1,A3,* (which matched none)
+      graph.applyAndSendPatch({
+        delQuads: [], addQuads: [quad(A1, A3, A4)]})
+      # third run should happen here, noticing the new A1,A3,* match
+      assert.equal(3, called)
+      assert.deepEqual([[], [A4]], objsFound)
+
+    it 'calls a handler again even if the handler throws an error', ->
+      called = 0
+      hand = ->
+        called++
+        graph.uriValue(A1, A2)
+        throw new Error('this test handler throws an error')
+      graph.runHandler(hand, 'run')
+      graph.applyAndSendPatch({
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]})
+      assert.equal(2, called)
+
+    describe 'works with nested handlers', ->
+
+      innerResults = []
+      inner = ->
+        console.log('\nninnerfetch')
+        innerResults.push(graph.uriValue(A1, A2))
+        console.log("innerResults #{JSON.stringify(innerResults)}\n")
+
+      outerResults = []
+      doRunInner = true
+      outer = ->
+        if doRunInner
+          graph.runHandler(inner, 'runinner')
+        console.log('push outer')
+        outerResults.push(graph.floatValue(A1, U('someFloat')))
+
+      beforeEach ->
+        innerResults = []
+        outerResults = []
+        doRunInner = true
+
+      affectInner = {
+        delQuads: [quad(A1, A2, A3)], addQuads: [quad(A1, A2, A4)]
+      }
+      affectOuter = {
+        delQuads: [
+          quad(A1, U('someFloat'), graph.Literal(1.5))
+        ], addQuads: [
+          quad(A1, U('someFloat'), graph.LiteralRoundedFloat(2))
+        ]}
+      affectBoth = {
+        delQuads: affectInner.delQuads.concat(affectOuter.delQuads),
+        addQuads: affectInner.addQuads.concat(affectOuter.addQuads)
+        }
+                  
+      it 'calls everything normally once', ->
+        graph.runHandler(outer, 'run')
+        assert.deepEqual([A3], innerResults)
+        assert.deepEqual([1.5], outerResults)
+
+      it.skip '[performance] reruns just the inner if its dependencies change', ->
+        console.log(graph.quads())
+        graph.runHandler(outer, 'run')
+        graph.applyAndSendPatch(affectInner)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5], outerResults)
+        
+      it.skip '[performance] reruns the outer (and therefore inner) if its dependencies change', ->
+        graph.runHandler(outer, 'run')
+        graph.applyAndSendPatch(affectOuter)
+        assert.deepEqual([A3, A3], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+        
+        
+      it.skip '[performance] does not send a redundant inner run if it is already rerunning outer', ->
+        # Note that outer may or may not call inner each time, and we
+        # don't want to redundantly call inner. We need to:
+        #  1. build the set of handlers to rerun,
+        #  2. call them from outside-in, and
+        #  3. any runHandler calls that happen, they need to count as reruns.
+        graph.runHandler(outer, 'run')
+        graph.applyAndSendPatch(affectBoth)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+
+      it 'reruns the outer and the inner if all dependencies change, but outer omits calling inner this time', ->
+        graph.runHandler(outer, 'run')
+        doRunInner = false
+        graph.applyAndSendPatch(affectBoth)
+        assert.deepEqual([A3, A4], innerResults)
+        assert.deepEqual([1.5, 2], outerResults)
+        
+    describe 'watches calls to:', ->
+      it 'floatValue', ->
+        values = []
+        hand = -> values.push(graph.floatValue(A1, U('someFloat')))
+        graph.runHandler(hand, 'run')
+        graph.patchObject(A1, U('someFloat'), graph.LiteralRoundedFloat(2), ctx)
+        assert.deepEqual([1.5, 2.0], values)
+        
+      it 'stringValue', ->
+        values = []
+        hand = -> values.push(graph.stringValue(A1, U('someString')))
+        graph.runHandler(hand, 'run')
+        graph.patchObject(A1, U('someString'), graph.Literal('world'), ctx)
+        assert.deepEqual(['hello', 'world'], values)
+
+      it 'uriValue', ->
+        # covered above, but this one tests patchObject on a uri, too
+        values = []
+        hand = -> values.push(graph.uriValue(A1, A2))
+        graph.runHandler(hand, 'run')
+        graph.patchObject(A1, A2, A4, ctx)
+        assert.deepEqual([A3, A4], values)
+
+      it 'objects', ->
+        values = []
+        hand = -> values.push(graph.objects(A1, U('multipleObjects')))
+        graph.runHandler(hand, 'run')
+        graph.patchObject(A1, U('multipleObjects'), U('newOne'), ctx)
+        expect(values[0]).to.deep.have.members([U('a4'), U('a5')])
+        expect(values[1]).to.deep.have.members([U('newOne')])
+
+      it 'subjects', ->
+        values = []
+        rdfType = graph.Uri(RDF + 'type')
+        hand = -> values.push(graph.subjects(rdfType, U('Type1')))
+        graph.runHandler(hand, 'run')
+        graph.applyAndSendPatch(
+          {delQuads: [], addQuads: [quad(A4, rdfType, U('Type1'))]})
+        expect(values[0]).to.deep.have.members([A2, A3])
+        expect(values[1]).to.deep.have.members([A2, A3, A4])
+
+      describe 'items', ->
+        it 'when the list order changes', (done) ->
+          values = []
+          successes = 0
+          hand = ->
+            try
+              head = graph.uriValue(U('x'), U('y'))
+            catch
+              # graph goes empty between clearGraph and loadTrig
+              return
+            values.push(graph.items(head))
+            successes++
+          graph.clearGraph()
+          graph.loadTrig "
+            @prefix : <http://example.com/> .
+            :ctx { :x :y (:a1 :a2 :a3) } .
+          ", () ->
+             graph.runHandler(hand, 'run')
+             graph.clearGraph()
+             graph.loadTrig "
+              @prefix : <http://example.com/> .
+              :ctx { :x :y (:a1 :a3 :a2) } .
+            ", () ->
+               assert.deepEqual([[A1, A2, A3], [A1, A3, A2]], values)
+               assert.equal(2, successes)
+               done()
+  
+      describe 'contains', ->
+        it 'when a new triple is added', ->
+          values = []
+          hand = -> values.push(graph.contains(A1, A1, A1))
+          graph.runHandler(hand, 'run')
+          graph.applyAndSendPatch(
+            {delQuads: [], addQuads: [quad(A1, A1, A1)]})
+          assert.deepEqual([false, true], values)
+
+        it 'when a relevant triple is removed', ->
+          values = []
+          hand = -> values.push(graph.contains(A1, A2, A3))
+          graph.runHandler(hand, 'run')
+          graph.applyAndSendPatch(
+            {delQuads: [quad(A1, A2, A3)], addQuads: []})
+          assert.deepEqual([true, false], values)
+
+    describe 'performs well', ->
+      it "[performance] doesn't call handler a 2nd time if the graph gets an unrelated patch", ->
+        called = 0
+        hand = ->
+          called++
+          graph.uriValue(A1, A2)
+        graph.runHandler(hand, 'run')
+        graph.applyAndSendPatch({
+          delQuads: [], addQuads: [quad(A2, A3, A4)]})
+        assert.equal(1, called)
+
+      it.skip '[performance] calls a handler 2x but then not again if the handler stopped caring about the data', ->
+        assert.fail()
+
+      it.skip "[performance] doesn't get slow if the handler makes tons of repetitive lookups", ->
+        assert.fail()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>light9 home</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="style.css" />
+    <link rel="stylesheet" href="flexlayout-react/style/dark.css" />
+    <script type="module" src="TiledHome.ts"></script>
+  </head>
+  <body>
+    <div id="container"></div>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/.bowerrc	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+{
+  "directory": "."
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/bower.json	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,46 @@
+{
+  "name": "3rd-party-libs",
+  "dependencies": {
+    "N3.js": "https://github.com/RubenVerborgh/N3.js.git#04f4e21f4ccb351587dc00a3f26340b28d4bb10f",
+    "QueryString": "http://unixpapa.com/js/QueryString.js",
+    "async": "https://github.com/caolan/async.git#^1.5.2",
+    "color": "https://github.com/One-com/one-color.git#^3.0.4",
+    "iron-ajax": "PolymerElements/iron-ajax#~2.1.3",
+    "iron-resizable-behavior": "PolymerElements/iron-resizable-behavior#^2.1.0",
+    "isotope": "^3.0.4",
+    "isotope-fit-columns": "^1.1.3",
+    "jquery": "^3.3.1",
+    "jquery-ui": "~1.11.4",
+    "jquery.columnizer": "https://github.com/adamwulf/Columnizer-jQuery-Plugin.git#^1.6.2",
+    "knockout": "knockoutjs#^3.4.2",
+    "paper-button": "PolymerElements/paper-button#^2.1.1",
+    "paper-dialog": "PolymerElements/paper-dialog#^2.1.0",
+    "paper-item": "PolymerElements/paper-item#2.1.0",
+    "paper-listbox": "PolymerElements/paper-listbox#2.1.0",
+    "paper-radio-button": "PolymerElements/paper-radio-button#^2.1.0",
+    "paper-radio-group": "PolymerElements/paper-radio-group#^2.1.0",
+    "paper-slider": "PolymerElements/paper-slider#~2.0.6",
+    "paper-styles": "PolymerElements/paper-styles#^2.1.0",
+    "rdflib.js": "https://github.com/linkeddata/rdflib.js.git#920e59fe37",
+    "rdfstore": "https://github.com/antoniogarrote/rdfstore-js.git#b3f7c0c9c1da9b26261af0d4858722fa982411bb",
+    "shortcut": "http://www.openjs.com/scripts/events/keyboard_shortcuts/shortcut.js",
+    "sylvester": "~0.1.3",
+    "underscore": "~1.8.3",
+    "polymer": "Polymer/polymer#^2.0.0",
+    "iron-flex-layout": "PolymerElements/iron-flex-layout#^2.0.3",
+    "iron-component-page": "PolymerElements/iron-component-page#^3.0.1",
+    "paper-header-panel": "PolymerElements/paper-header-panel#^2.1.0",
+    "iron-overlay-behavior": "PolymerElements/iron-overlay-behavior#^2.3.4",
+    "debug": "https://github.com/visionmedia/debug/archive/master.zip"
+  },
+  "resolutions": {
+    "webcomponentsjs": "^v1.1.0",
+    "polymer": "^2.0.0",
+    "iron-flex-layout": "^2.0.3",
+    "paper-button": "^2.1.1",
+    "iron-component-page": "^3.0.1",
+    "iron-doc-viewer": "^3.0.0",
+    "paper-header-panel": "^2.1.0",
+    "iron-overlay-behavior": "^2.3.4"
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/onecolor.d.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,13 @@
+type ColorFormat = "hex" | "rgb" | "hsl" | "hsv";
+interface Color {
+  clone(): this;
+  toString(format?: ColorFormat): string;
+  toJSON(): string;
+  value(): number;
+  value(v: number): this;
+  hex(): string;
+}
+
+declare function color(value: any): Color;
+
+export = color;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/parse-prometheus-text-format.d.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+declare module "parse-prometheus-text-format" {
+  function parsePrometheusTextFormat(s: string): any;
+  export default parsePrometheusTextFormat;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/sylvester.d.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,828 @@
+// local fixes; the DefinitelyTyped one had "is not a module" errors
+
+
+// Type definitions for sylvester 0.1.3
+// Project: https://github.com/jcoglan/sylvester
+// Definitions by: Stephane Alie <https://github.com/StephaneAlie>
+// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
+
+// === Sylvester ===
+// Vector and Matrix mathematics modules for JavaScript
+// Copyright (c) 2007 James Coglan
+
+export declare module Sylvester {
+    interface VectorStatic {
+        /**
+         * Constructor function.
+         */
+        create(elements: Vector|Array<number>): Vector;
+
+        i: Vector;
+        j: Vector;
+        k: Vector;
+
+        /**
+         * Random vector of size n.
+         *
+         * @param {number} n The vector size.
+         */
+        Random(n: number): Vector;
+
+        /**
+         * Vector filled with zeros.
+         *
+         * @param {number} n The vector size.
+         */
+        Zero(n: number): Vector;
+    }
+    interface MatrixStatic {
+        /**
+         * Constructor function.
+         *
+         * @param {Array<number>|Array<Array<number>>|Vector|Matrix} elements The elements.
+         */
+        create(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
+
+        /**
+         * Identity matrix of size n.
+         *
+         * @param {number} n The size.
+         */
+        I(n: number): Matrix;
+
+        /**
+         * Diagonal matrix - all off-diagonal elements are zero
+         *
+         * @param {any} elements The elements.
+         */
+        Diagonal(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
+
+        /**
+         * Rotation matrix about some axis. If no axis is supplied, assume we're after a 2D transform.
+         *
+         * @param {number} theta The angle in radians.
+         * @param {Vector} a [Optional] The axis.
+         */
+        Rotation(theta: number, a?: Vector): Matrix;
+
+        RotationX(t: number): Matrix;
+        RotationY(t: number): Matrix;
+        RotationZ(t: number): Matrix;
+
+        /**
+         * Random matrix of n rows, m columns.
+         *
+         * @param {number} n The number of rows.
+         * @param {number} m The number of columns.
+         */
+        Random(n: number, m: number): Matrix;
+
+        /**
+         * Matrix filled with zeros.
+         *
+         * @param {number} n The number of rows.
+         * @param {number} m The number of columns.
+         */
+        Zero(n: number, m: number): Matrix;
+    }
+
+    interface LineStatic {
+        /**
+         * Constructor function.
+         *
+         * @param Array<number>|Vector anchor The anchor vector.
+         * @param Array<number>|Vector direction The direction vector.
+         */
+        create(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
+
+        X: Line;
+        Y: Line;
+        Z: Line;
+    }
+    interface PlaneStatic {
+        /**
+         * Constructor function.
+         */
+        create(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
+
+        /**
+         * Constructor function.
+         */
+        create(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
+
+        XY: Plane;
+        YZ: Plane;
+        ZX: Plane;
+        YX: Plane;
+    }
+}
+
+interface Vector {
+    /**
+     * Gets an array containing the vector's elements.
+     */
+    elements: Array<number>;
+
+    /**
+     * Returns element i of the vector.
+     */
+    e(i: number): number;
+
+    /**
+     * Returns the number of elements the vector has.
+     */
+    dimensions(): number;
+
+    /**
+     * Returns the modulus ('length') of the vector.
+     */
+    modulus(): number;
+
+    /**
+     * Returns true if the vector is equal to the argument.
+     *
+     * @param {Vector|Array<number>} vector The vector to compare equality.
+     */
+    eql(vector: Vector|Array<number>): boolean;
+
+    /**
+     * Returns a copy of the vector.
+     */
+    dup(): Vector;
+
+    /**
+     * Maps the vector to another vector according to the given function.
+     *
+     * @param {Function} fn The function to apply to each element (x, i) => {}.
+     */
+    map(fn: (x: number, i: number) => any): Vector;
+
+    /**
+     * Calls the iterator for each element of the vector in turn.
+     *
+     * @param {Function} fn The function to apply to each element (x, i) => {}.
+     */
+    each(fn: (x: number, i: number) => any): void;
+
+    /**
+     * Returns a new vector created by normalizing the receiver.
+     */
+    toUnitVector(): Vector;
+
+    /**
+     * Returns the angle between the vector and the argument (also a vector).
+     *
+     * @param {Vector} vector The other vector to calculate the angle.
+     */
+    angleFrom(vector: Vector): number;
+
+    /**
+     * Returns true if the vector is parallel to the argument.
+     *
+     * @param {Vector} vector The other vector.
+     */
+    isParallelTo(vector: Vector): boolean;
+
+    /**
+     * Returns true if the vector is antiparallel to the argument.
+     *
+     * @param {Vector} vector The other vector.
+     */
+    isAntiparallelTo(vector: Vector): boolean;
+
+    /**
+     * Returns true iff the vector is perpendicular to the argument.
+     *
+     * @param {Vector} vector The other vector.
+     */
+    isPerpendicularTo(vector: Vector): boolean;
+
+    /**
+     * Returns the result of adding the argument to the vector.
+     *
+     * @param {Vector|Array<number>} vector The vector.
+     */
+    add(vector: Vector|Array<number>): Vector;
+
+    /**
+     * Returns the result of subtracting the argument from the vector.
+     *
+     * @param {Vector|Array<number>} vector The vector.
+     */
+    subtract(vector: Vector|Array<number>): Vector;
+
+    /**
+     * Returns the result of multiplying the elements of the vector by the argument.
+     *
+     * @param {number} k The value by which to multiply the vector.
+     */
+    multiply(k: number): Vector;
+
+    /**
+     * Returns the result of multiplying the elements of the vector by the argument (Alias for multiply(k)).
+     *
+     * @param {number} k The value by which to multiply the vector.
+     */
+    x(k: number): Vector;
+
+    /**
+     * Returns the scalar product of the vector with the argument. Both vectors must have equal dimensionality.
+     *
+     * @param: {Vector|Array<number>} vector The other vector.
+     */
+    dot(vector: Vector|Array<number>): number;
+
+    /**
+     * Returns the vector product of the vector with the argument. Both vectors must have dimensionality 3.
+     *
+     * @param {Vector|Array<number>} vector The other vector.
+     */
+    cross(vector: Vector|Array<number>): Vector;
+
+    /**
+     * Returns the (absolute) largest element of the vector.
+     */
+    max(): number;
+
+    /**
+     * Returns the index of the first match found.
+     *
+     * @param {number} x The value.
+     */
+    indexOf(x: number): number;
+
+    /**
+     * Returns a diagonal matrix with the vector's elements as its diagonal elements.
+     */
+    toDiagonalMatrix(): Matrix;
+
+    /**
+     * Returns the result of rounding the elements of the vector.
+     */
+    round(): Vector;
+
+    /**
+     * Returns a copy of the vector with elements set to the given value if they differ from
+     * it by less than Sylvester.precision.
+     *
+     * @param {number} x The value to snap to.
+     */
+    snapTo(x: number): Vector;
+
+    /**
+     * Returns the vector's distance from the argument, when considered as a point in space.
+     *
+     * @param {Vector|Line|Plane} obj The object to calculate the distance.
+     */
+    distanceFrom(obj: Vector|Line|Plane): number;
+
+    /**
+     * Returns true if the vector is point on the given line.
+     *
+     * @param {Line} line The line.
+     */
+    liesOn(line: Line): boolean;
+
+    /**
+     * Return true if the vector is a point in the given plane.
+     *
+     * @param {Plane} plane The plane.
+     */
+    liesIn(plane: Plane): boolean;
+
+    /**
+     * Rotates the vector about the given object. The object should be a point if the vector is 2D,
+     * and a line if it is 3D. Be careful with line directions!
+     *
+     * @param {number|Matrix} t The angle in radians or in rotation matrix.
+     * @param {Vector|Line} obj The rotation axis.
+     */
+    rotate(t: number|Matrix, obj: Vector|Line): Vector;
+
+    /**
+     * Returns the result of reflecting the point in the given point, line or plane.
+     *
+     * @param {Vector|Line|Plane} obj The object.
+     */
+    reflectionIn(obj: Vector|Line|Plane): Vector;
+
+    /**
+     * Utility to make sure vectors are 3D. If they are 2D, a zero z-component is added.
+     */
+    to3D(): Vector;
+
+    /**
+     * Returns a string representation of the vector.
+     */
+    inspect(): string;
+
+    /**
+     * Set vector's elements from an array.
+     *
+     * @param {Vector|Array<number>} els The elements.
+     */
+    setElements(els: Vector|Array<number>): Vector;
+}
+
+interface Matrix {
+    /**
+     * Gets a nested array containing the matrix's elements.
+     */
+    elements: Array<Array<number>>;
+    /**
+     * Returns element (i,j) of the matrix.
+     *
+     * @param {number} i The row index.
+     * @param {number} j The column index.
+     */
+    e(i: number, j: number): any;
+
+    /**
+     * Returns row k of the matrix as a vector.
+     *
+     * @param {number} i The row index.
+     */
+    row(i: number): Vector;
+
+    /**
+     * Returns column k of the matrix as a vector.
+     *
+     * @param {number} j The column index.
+     */
+    col(j: number): Vector;
+
+    /**
+     * Returns the number of rows/columns the matrix has.
+     *
+     * @return {any} An object { rows: , cols: }.
+     */
+    dimensions(): any;
+
+    /**
+     * Returns the number of rows in the matrix.
+     */
+    rows(): number;
+
+    /**
+     * Returns the number of columns in the matrix.
+     */
+    cols(): number;
+
+    /**
+     * Returns true if the matrix is equal to the argument. You can supply a vector as the argument,
+     * in which case the receiver must be a one-column matrix equal to the vector.
+     *
+     * @param {Vector|Matrix} matrix The argument to compare.
+     */
+    eql(matrix: Vector|Matrix): boolean;
+
+    /**
+     * Returns a copy of the matrix.
+     */
+    dup(): Matrix;
+
+    /**
+     * Maps the matrix to another matrix (of the same dimensions) according to the given function.
+     *
+     * @param {Function} fn The function.
+     */
+    map(fn: (x: number, i: number, j: number) => any): Matrix;
+
+    /**
+     * Returns true iff the argument has the same dimensions as the matrix.
+     *
+     * @param {Matrix} matrix The other matrix.
+     */
+    isSameSizeAs(matrix: Matrix): boolean;
+
+    /**
+     * Returns the result of adding the argument to the matrix.
+     *
+     * @param {Matrix} matrix The matrix to add.
+     */
+    add(matrix: Matrix): Matrix;
+
+    /**
+     * Returns the result of subtracting the argument from the matrix.
+     *
+     * @param {Matrix} matrix The matrix to substract.
+     */
+    subtract(matrix: Matrix): Matrix;
+
+    /**
+     * Returns true iff the matrix can multiply the argument from the left.
+     *
+     * @param {Matrix} matrix The matrix.
+     */
+    canMultiplyFromLeft(matrix: Matrix): boolean;
+
+    /**
+     * Returns the result of multiplying the matrix from the right by the argument. If the argument is a scalar
+     * then just multiply all the elements. If the argument is a vector, a vector is returned, which saves you
+     * having to remember calling col(1) on the result.
+     *
+     * @param {number|Matrix} matrix The multiplier.
+     */
+    multiply(matrix: number|Matrix): Matrix;
+
+    /**
+     * Returns the result of multiplying the matrix from the right by the argument. If the argument is a scalar
+     * then just multiply all the elements. If the argument is a vector, a vector is returned, which saves you
+     * having to remember calling col(1) on the result.
+     *
+     * @param {Vector} vector The multiplier.
+     */
+    multiply(vector: Vector): Vector;
+
+    x(matrix: number|Matrix): Matrix;
+
+    x(vector: Vector): Vector;
+
+    /**
+     * Returns a submatrix taken from the matrix. Argument order is: start row, start col, nrows, ncols.
+     * Element selection wraps if the required index is outside the matrix's bounds, so you could use
+     * this to perform row/column cycling or copy-augmenting.
+     *
+     * @param {number} a Starting row index.
+     * @param {number} b Starting column index.
+     * @param {number} c Number of rows.
+     * @param {number} d Number of columns.
+     */
+    minor(a: number, b: number, c: number, d: number): Matrix;
+
+    /**
+     * Returns the transpose of the matrix.
+     */
+    transpose(): Matrix;
+
+    /**
+     * Returns true if the matrix is square.
+     */
+    isSquare(): boolean;
+
+    /**
+     * Returns the (absolute) largest element of the matrix.
+     */
+    max(): number;
+
+    /**
+     * Returns the indeces of the first match found by reading row-by-row from left to right.
+     *
+     * @param {number} x The value.
+     *
+     * @return {any} The element indeces i.e: { row:1, col:1 }
+     */
+    indexOf(x: number): any;
+
+    /**
+     * If the matrix is square, returns the diagonal elements as a vector; otherwise, returns null.
+     */
+    diagonal(): Vector;
+
+    /**
+     * Make the matrix upper (right) triangular by Gaussian elimination. This method only adds multiples
+     * of rows to other rows. No rows are scaled up or switched, and the determinant is preserved.
+     */
+    toRightTriangular(): Matrix;
+    toUpperTriangular(): Matrix;
+
+    /**
+     * Returns the determinant for square matrices.
+     */
+    determinant(): number;
+    det(): number;
+
+    /**
+     * Returns true if the matrix is singular.
+     */
+    isSingular(): boolean;
+
+    /**
+     * Returns the trace for square matrices.
+     */
+    trace(): number;
+    tr(): number;
+
+    /**
+     * Returns the rank of the matrix.
+     */
+    rank(): number;
+    rk(): number;
+
+    /**
+     * Returns the result of attaching the given argument to the right-hand side of the matrix.
+     *
+     * @param {Matrix|Vector} matrix The matrix or vector.
+     */
+    augment(matrix: Matrix|Vector): Matrix;
+
+    /**
+     * Returns the inverse (if one exists) using Gauss-Jordan.
+     */
+    inverse(): Matrix;
+    inv(): Matrix;
+
+    /**
+     * Returns the result of rounding all the elements.
+     */
+    round(): Matrix;
+
+    /**
+     * Returns a copy of the matrix with elements set to the given value if they differ from it
+     * by less than Sylvester.precision.
+     *
+     * @param {number} x The value.
+     */
+    snapTo(x: number): Matrix;
+
+    /**
+     * Returns a string representation of the matrix.
+     */
+    inspect(): string;
+
+    /**
+     * Set the matrix's elements from an array. If the argument passed is a vector, the resulting matrix
+     * will be a single column.
+     *
+     * @param {Array<number>|Array<Array<number>>|Vector|Matrix} matrix The elements.
+     */
+    setElements(matrix: Array<number>|Array<Array<number>>|Vector|Matrix): Matrix;
+}
+
+interface Line {
+    /**
+     * Gets the 3D vector corresponding to a point on the line.
+     */
+    anchor: Vector;
+
+    /**
+     * Gets a normalized 3D vector representing the line's direction.
+     */
+    direction: Vector;
+
+    /**
+     * Returns true if the argument occupies the same space as the line.
+     *
+     * @param {Line} line The other line.
+     */
+    eql(line: Line): boolean;
+
+    /**
+     * Returns a copy of the line.
+     */
+    dup(): Line;
+
+    /**
+     * Returns the result of translating the line by the given vector/array.
+     *
+     * @param {Vector|Array<number>} vector The translation vector.
+     */
+    translate(vector: Vector|Array<number>): Line;
+
+    /**
+     * Returns true if the line is parallel to the argument. Here, 'parallel to' means that the argument's
+     * direction is either parallel or antiparallel to the line's own direction. A line is parallel to a
+     * plane if the two do not have a unique intersection.
+     *
+     * @param {Line|Plane} obj The object.
+     */
+    isParallelTo(obj: Line|Plane): boolean;
+
+    /**
+     * Returns the line's perpendicular distance from the argument, which can be a point, a line or a plane.
+     *
+     * @param {Vector|Line|Plane} obj The object.
+     */
+    distanceFrom(obj: Vector|Line|Plane): number;
+
+    /**
+     * Returns true if the argument is a point on the line.
+     *
+     * @param {Vector} point The point.
+     */
+    contains(point: Vector): boolean;
+
+    /**
+     * Returns true if the line lies in the given plane.
+     *
+     * @param {Plane} plane The plane.
+     */
+    liesIn(plane: Plane): boolean;
+
+    /**
+     * Returns true if the line has a unique point of intersection with the argument.
+     *
+     * @param {Line|Plane} obj The object.
+     */
+    intersects(obj: Line|Plane): boolean;
+
+    /**
+     * Returns the unique intersection point with the argument, if one exists.
+     *
+     * @param {Line|Plane} obj The object.
+     */
+    intersectionWith(obj: Line|Plane): Vector;
+
+    /**
+     * Returns the point on the line that is closest to the given point or line.
+     *
+     * @param {Vector|Line} obj The object.
+     */
+    pointClosestTo(obj: Vector|Line): Vector;
+
+    /**
+     * Returns a copy of the line rotated by t radians about the given line. Works by finding the argument's
+     * closest point to this line's anchor point (call this C) and rotating the anchor about C. Also rotates
+     * the line's direction about the argument's. Be careful with this - the rotation axis' direction
+     * affects the outcome!
+     *
+     * @param {number} t The angle in radians.
+     * @param {Vector|Line} axis The axis.
+     */
+    rotate(t: number, axis: Vector|Line): Line;
+
+    /**
+     * Returns the line's reflection in the given point or line.
+     *
+     * @param {Vector|Line|Plane} obj The object.
+     */
+    reflectionIn(obj: Vector|Line|Plane): Line;
+
+    /**
+     * Set the line's anchor point and direction.
+     *
+     * @param {Array<number>|Vector} anchor The anchor vector.
+     * @param {Array<number>|Vector} direction The direction vector.
+     */
+    setVectors(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
+}
+
+interface Plane {
+    /**
+     * Gets the 3D vector corresponding to a point in the plane.
+     */
+    anchor: Vector;
+
+    /**
+     * Gets a normalized 3D vector perpendicular to the plane.
+     */
+    normal: Vector;
+
+    /**
+     * Returns true if the plane occupies the same space as the argument.
+     *
+     * @param {Plane} plane The other plane.
+     */
+    eql(plane: Plane): boolean;
+
+    /**
+     * Returns a copy of the plane.
+     */
+    dup(): Plane;
+
+    /**
+     * Returns the result of translating the plane by the given vector.
+     *
+     * @param {Array<number>|Vector} vector The translation vector.
+     */
+    translate(vector: Array<number>|Vector): Plane;
+
+    /**
+     * Returns true if the plane is parallel to the argument. Will return true if the planes are equal,
+     * or if you give a line and it lies in the plane.
+     *
+     * @param {Line|Plane} obj The object.
+     */
+    isParallelTo(obj: Line|Plane): boolean;
+
+    /**
+     * Returns true if the receiver is perpendicular to the argument.
+     *
+     * @param {Plane} plane The other plane.
+     */
+    isPerpendicularTo(plane: Plane): boolean;
+
+    /**
+     * Returns the plane's distance from the given object (point, line or plane).
+     *
+     * @parm {Vector|Line|Plane} obj The object.
+     */
+    distanceFrom(obj: Vector|Line|Plane): number;
+
+    /**
+     * Returns true if the plane contains the given point or line.
+     *
+     * @param {Vector|Line} obj The object.
+     */
+    contains(obj: Vector|Line): boolean;
+
+    /**
+     * Returns true if the plane has a unique point/line of intersection with the argument.
+     *
+     * @param {Line|Plane} obj The object.
+     */
+    intersects(obj: Line|Plane): boolean;
+
+    /**
+     * Returns the unique intersection with the argument, if one exists.
+     *
+     * @param {Line} line The line.
+     */
+    intersectionWith(line: Line): Vector;
+
+    /**
+     * Returns the unique intersection with the argument, if one exists.
+     *
+     * @param {Plane} plane The plane.
+     */
+    intersectionWith(plane: Plane): Line;
+
+    /**
+     * Returns the point in the plane closest to the given point.
+     *
+     * @param {Vector} point The point.
+     */
+    pointClosestTo(point: Vector): Vector;
+
+    /**
+     * Returns a copy of the plane, rotated by t radians about the given line. See notes on Line#rotate.
+     *
+     * @param {number} t The angle in radians.
+     * @param {Line} axis The line axis.
+     */
+    rotate(t: number, axis: Line): Plane;
+
+    /**
+     * Returns the reflection of the plane in the given point, line or plane.
+     *
+     * @param {Vector|Line|Plane} obj The object.
+     */
+    reflectionIn(obj: Vector|Line|Plane): Plane;
+
+    /**
+     * Sets the anchor point and normal to the plane. Normal vector is normalised before storage.
+     *
+     * @param {Array<number>|Vector} anchor The anchor vector.
+     * @param {Array<number>|Vector} normal The normal vector.
+     */
+    setVectors(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
+
+    /**
+     * Sets the anchor point and normal to the plane. The normal is calculated by assuming the three points
+     * should lie in the same plane. Normal vector is normalised before storage.
+     *
+     * @param {Array<number>|Vector} anchor The anchor vector.
+     * @param {Array<number>|Vector} v1 The first direction vector.
+     * @param {Array<number>|Vector} v2 The second direction vector.
+     */
+    setVectors(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
+}
+
+declare module Sylvester {
+    export var version: string;
+    export var precision: number;
+}
+
+declare var Vector: Sylvester.VectorStatic;
+declare var Matrix: Sylvester.MatrixStatic;
+declare var Line: Sylvester.LineStatic;
+declare var Plane: Sylvester.PlaneStatic;
+
+/**
+* Constructor function.
+*
+* @param {Vector|Array<number} elements The elements.
+*/
+declare function $V(elements: Vector|Array<number>): Vector;
+
+/**
+* Constructor function.
+*
+* @param {Array<number>|Array<Array<number>>|Vector|Matrix} elements The elements.
+*/
+declare function $M(elements: Array<number>|Array<Array<number>>|Vector | Matrix): Matrix;
+
+/**
+* Constructor function.
+*
+* @param Array<number>|Vector anchor The anchor vector.
+* @param Array<number>|Vector direction The direction vector.
+*/
+declare function $L(anchor: Array<number>|Vector, direction: Array<number>|Vector): Line;
+
+/**
+* Constructor function.
+*
+* @param {Array<number>|Vector} anchor The anchor vector.
+* @param {Array<number>|Vector} normal The normal vector.
+*/
+declare function $P(anchor: Array<number>|Vector, normal: Array<number>|Vector): Plane;
+
+/**
+ * Constructor function.
+ *
+ * @param {Array<number>|Vector} anchor The anchor vector.
+ * @param {Array<number>|Vector} v1 The first direction vector.
+ * @param {Array<number>|Vecotr} v2 The second direction vector.
+ */
+declare function $P(anchor: Array<number>|Vector, v1: Array<number>|Vector, v2: Array<number>|Vector): Plane;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/MIT-LICENSE.txt	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,22 @@
+Copyright (c) 2011 Tapmodo Interactive LLC,
+  http://github.com/tapmodo/Jcrop
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/README.md	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,66 @@
+Jcrop Image Cropping Plugin
+===========================
+
+Jcrop is the quick and easy way to add image cropping functionality to
+your web application. It combines the ease-of-use of a typical jQuery
+plugin with a powerful cross-platform DHTML cropping engine that is
+faithful to familiar desktop graphics applications.
+
+Cross-platform Compatibility
+----------------------------
+
+* Firefox 2+
+* Safari 3+
+* Opera 9.5+
+* Google Chrome 0.2+
+* Internet Explorer 6+
+
+Feature Overview
+----------------
+
+* Attaches unobtrusively to any image
+* Supports aspect ratio locking
+* Supports minSize/maxSize setting
+* Callbacks for selection done, or while moving
+* Keyboard support for nudging selection
+* API features to create interactivity, including animation
+* Support for CSS styling
+* Experimental touch-screen support (iOS, Android, etc)
+
+Contributors
+============
+
+**Special thanks to the following contributors:**
+
+* [Bruno Agutoli](mailto:brunotla1@gmail.com)
+* dhorrigan
+* Phil-B
+* jaymecd
+* all others who have committed their time and effort to help improve Jcrop
+
+MIT License
+===========
+
+**Jcrop is free software under MIT License.**
+
+#### Copyright (c) 2008-2012 Tapmodo Interactive LLC,<br />http://github.com/tapmodo/Jcrop
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
Binary file web/lib/tapmodo-Jcrop-1902fbc/css/Jcrop.gif has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.css	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,165 @@
+/* jquery.Jcrop.css v0.9.12 - MIT License */
+/*
+  The outer-most container in a typical Jcrop instance
+  If you are having difficulty with formatting related to styles
+  on a parent element, place any fixes here or in a like selector
+
+  You can also style this element if you want to add a border, etc
+  A better method for styling can be seen below with .jcrop-light
+  (Add a class to the holder and style elements for that extended class)
+*/
+.jcrop-holder {
+  direction: ltr;
+  text-align: left;
+}
+/* Selection Border */
+.jcrop-vline,
+.jcrop-hline {
+  background: #ffffff url("Jcrop.gif");
+  font-size: 0;
+  position: absolute;
+}
+.jcrop-vline {
+  height: 100%;
+  width: 1px !important;
+}
+.jcrop-vline.right {
+  right: 0;
+}
+.jcrop-hline {
+  height: 1px !important;
+  width: 100%;
+}
+.jcrop-hline.bottom {
+  bottom: 0;
+}
+/* Invisible click targets */
+.jcrop-tracker {
+  height: 100%;
+  width: 100%;
+  /* "turn off" link highlight */
+  -webkit-tap-highlight-color: transparent;
+  /* disable callout, image save panel */
+  -webkit-touch-callout: none;
+  /* disable cut copy paste */
+  -webkit-user-select: none;
+}
+/* Selection Handles */
+.jcrop-handle {
+  background-color: #333333;
+  border: 1px #eeeeee solid;
+  width: 7px;
+  height: 7px;
+  font-size: 1px;
+}
+.jcrop-handle.ord-n {
+  left: 50%;
+  margin-left: -4px;
+  margin-top: -4px;
+  top: 0;
+}
+.jcrop-handle.ord-s {
+  bottom: 0;
+  left: 50%;
+  margin-bottom: -4px;
+  margin-left: -4px;
+}
+.jcrop-handle.ord-e {
+  margin-right: -4px;
+  margin-top: -4px;
+  right: 0;
+  top: 50%;
+}
+.jcrop-handle.ord-w {
+  left: 0;
+  margin-left: -4px;
+  margin-top: -4px;
+  top: 50%;
+}
+.jcrop-handle.ord-nw {
+  left: 0;
+  margin-left: -4px;
+  margin-top: -4px;
+  top: 0;
+}
+.jcrop-handle.ord-ne {
+  margin-right: -4px;
+  margin-top: -4px;
+  right: 0;
+  top: 0;
+}
+.jcrop-handle.ord-se {
+  bottom: 0;
+  margin-bottom: -4px;
+  margin-right: -4px;
+  right: 0;
+}
+.jcrop-handle.ord-sw {
+  bottom: 0;
+  left: 0;
+  margin-bottom: -4px;
+  margin-left: -4px;
+}
+/* Dragbars */
+.jcrop-dragbar.ord-n,
+.jcrop-dragbar.ord-s {
+  height: 7px;
+  width: 100%;
+}
+.jcrop-dragbar.ord-e,
+.jcrop-dragbar.ord-w {
+  height: 100%;
+  width: 7px;
+}
+.jcrop-dragbar.ord-n {
+  margin-top: -4px;
+}
+.jcrop-dragbar.ord-s {
+  bottom: 0;
+  margin-bottom: -4px;
+}
+.jcrop-dragbar.ord-e {
+  margin-right: -4px;
+  right: 0;
+}
+.jcrop-dragbar.ord-w {
+  margin-left: -4px;
+}
+/* The "jcrop-light" class/extension */
+.jcrop-light .jcrop-vline,
+.jcrop-light .jcrop-hline {
+  background: #ffffff;
+  filter: alpha(opacity=70) !important;
+  opacity: .70!important;
+}
+.jcrop-light .jcrop-handle {
+  -moz-border-radius: 3px;
+  -webkit-border-radius: 3px;
+  background-color: #000000;
+  border-color: #ffffff;
+  border-radius: 3px;
+}
+/* The "jcrop-dark" class/extension */
+.jcrop-dark .jcrop-vline,
+.jcrop-dark .jcrop-hline {
+  background: #000000;
+  filter: alpha(opacity=70) !important;
+  opacity: 0.7 !important;
+}
+.jcrop-dark .jcrop-handle {
+  -moz-border-radius: 3px;
+  -webkit-border-radius: 3px;
+  background-color: #ffffff;
+  border-color: #000000;
+  border-radius: 3px;
+}
+/* Simple macro to turn off the antlines */
+.solid-line .jcrop-vline,
+.solid-line .jcrop-hline {
+  background: #ffffff;
+}
+/* Fix for twitter bootstrap et al. */
+.jcrop-holder img,
+img.jcrop-preview {
+  max-width: none;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/css/jquery.Jcrop.min.css	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,29 @@
+/* jquery.Jcrop.min.css v0.9.12 (build:20130126) */
+.jcrop-holder{direction:ltr;text-align:left;}
+.jcrop-vline,.jcrop-hline{background:#FFF url(Jcrop.gif);font-size:0;position:absolute;}
+.jcrop-vline{height:100%;width:1px!important;}
+.jcrop-vline.right{right:0;}
+.jcrop-hline{height:1px!important;width:100%;}
+.jcrop-hline.bottom{bottom:0;}
+.jcrop-tracker{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;height:100%;width:100%;}
+.jcrop-handle{background-color:#333;border:1px #EEE solid;font-size:1px;height:7px;width:7px;}
+.jcrop-handle.ord-n{left:50%;margin-left:-4px;margin-top:-4px;top:0;}
+.jcrop-handle.ord-s{bottom:0;left:50%;margin-bottom:-4px;margin-left:-4px;}
+.jcrop-handle.ord-e{margin-right:-4px;margin-top:-4px;right:0;top:50%;}
+.jcrop-handle.ord-w{left:0;margin-left:-4px;margin-top:-4px;top:50%;}
+.jcrop-handle.ord-nw{left:0;margin-left:-4px;margin-top:-4px;top:0;}
+.jcrop-handle.ord-ne{margin-right:-4px;margin-top:-4px;right:0;top:0;}
+.jcrop-handle.ord-se{bottom:0;margin-bottom:-4px;margin-right:-4px;right:0;}
+.jcrop-handle.ord-sw{bottom:0;left:0;margin-bottom:-4px;margin-left:-4px;}
+.jcrop-dragbar.ord-n,.jcrop-dragbar.ord-s{height:7px;width:100%;}
+.jcrop-dragbar.ord-e,.jcrop-dragbar.ord-w{height:100%;width:7px;}
+.jcrop-dragbar.ord-n{margin-top:-4px;}
+.jcrop-dragbar.ord-s{bottom:0;margin-bottom:-4px;}
+.jcrop-dragbar.ord-e{margin-right:-4px;right:0;}
+.jcrop-dragbar.ord-w{margin-left:-4px;}
+.jcrop-light .jcrop-vline,.jcrop-light .jcrop-hline{background:#FFF;filter:alpha(opacity=70)!important;opacity:.70!important;}
+.jcrop-light .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#000;border-color:#FFF;border-radius:3px;}
+.jcrop-dark .jcrop-vline,.jcrop-dark .jcrop-hline{background:#000;filter:alpha(opacity=70)!important;opacity:.7!important;}
+.jcrop-dark .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#FFF;border-color:#000;border-radius:3px;}
+.solid-line .jcrop-vline,.solid-line .jcrop-hline{background:#FFF;}
+.jcrop-holder img,img.jcrop-preview{max-width:none;}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Jcrop: the jQuery Image Cropping Plugin</title>
+  <link rel="stylesheet" href="demos/demo_files/main.css" type="text/css" />
+  <link rel="stylesheet" href="demos/demo_files/demos.css" type="text/css" />
+  <script src="js/jquery.min.js"></script>
+  <meta http-equiv="Content-type" content="text/html;charset=UTF-8" />
+</head>
+<body>
+
+<div class="container">
+<div class="row">
+<div class="span12">
+<div class="jc-demo-box">
+
+<div class="page-header">
+<ul class="breadcrumb first">
+  <li><a href="http://tapmodo.com/">Tapmodo</a> <span class="divider">/</span></li>
+  <li class="active">Jcrop Plugin</li>
+</ul>
+<h1>Jcrop Image Cropping Plugin</h1>
+</div>
+
+
+<big>
+	<a href="http://deepliquid.com/content/Jcrop.html"><b>Jcrop</b></a>
+	is the image cropping plugin for
+	<a href="http://jquery.com/">jQuery</a>.<br />
+	You've successfully unpacked Jcrop.
+</big>
+
+<h3>Static Demos</h3>
+
+<ul>
+	<li><a href="demos/tutorial1.html">Hello World</a>
+		&mdash; default behavior</li>
+	<li><a href="demos/tutorial2.html">Basic Handler</a>
+		&mdash; basic form integration</li>
+	<li><a href="demos/tutorial3.html">Aspect Ratio w/ Preview Pane</a>
+		&mdash; nice visual example</li>
+	<li><a href="demos/tutorial4.html">Animation/Transitions</a>
+		&mdash; animation/fading demo</li>
+	<li><a href="demos/tutorial5.html">API Interface</a>
+		&mdash; real-time API example</li>
+	<li><a href="demos/styling.html">Styling Example</a>
+		&mdash; style Jcrop dynamically with CSS
+    <small>New in 0.9.10</small>
+    </li>
+  <li><a href="demos/non-image.html">Non-Image Elements</a>
+    &mdash; attach to other DOM block elements
+    <small>New in 0.9.10</small>
+    </li>
+</ul>
+
+<h3>Live Demo</h3>
+
+<ul>
+	<li><a href="demos/crop.php">PHP Cropping Demo</a>
+		&mdash; requires PHP/gd support</li>
+</ul>
+
+<h3>Jcrop Links</h3>
+
+<ul>
+	<li><a href="http://deepliquid.com/content/Jcrop.html">Jcrop Home</a></li>
+	<li><a href="http://deepliquid.com/content/Jcrop_Manual.html">Jcrop Manual</a></li>
+</ul>
+
+<div class="tapmodo-footer">
+  <a href="http://tapmodo.com" class="tapmodo-logo segment">tapmodo.com</a>
+  <div class="segment"><b>&copy; 2008-2013 Tapmodo Interactive LLC</b><br />
+    Jcrop is free software released under <a href="MIT-LICENSE.txt">MIT License</a>
+  </div>
+</div>
+<div class="clearfix"></div>
+
+</div>
+</div>
+</div>
+</div>
+
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,1694 @@
+/**
+ * jquery.Jcrop.js v0.9.12
+ * jQuery Image Cropping Plugin - released under MIT License 
+ * Author: Kelly Hallman <khallman@gmail.com>
+ * http://github.com/tapmodo/Jcrop
+ * Copyright (c) 2008-2013 Tapmodo Interactive LLC {{{
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * }}}
+ */
+
+(function ($) {
+
+  $.Jcrop = function (obj, opt) {
+    var options = $.extend({}, $.Jcrop.defaults),
+        docOffset,
+        _ua = navigator.userAgent.toLowerCase(),
+        is_msie = /msie/.test(_ua),
+        ie6mode = /msie [1-6]\./.test(_ua);
+
+    // Internal Methods {{{
+    function px(n) {
+      return Math.round(n) + 'px';
+    }
+    function cssClass(cl) {
+      return options.baseClass + '-' + cl;
+    }
+    function supportsColorFade() {
+      return $.fx.step.hasOwnProperty('backgroundColor');
+    }
+    function getPos(obj) //{{{
+    {
+      var pos = $(obj).offset();
+      return [pos.left, pos.top];
+    }
+    //}}}
+    function mouseAbs(e) //{{{
+    {
+      return [(e.pageX - docOffset[0]), (e.pageY - docOffset[1])];
+    }
+    //}}}
+    function setOptions(opt) //{{{
+    {
+      if (typeof(opt) !== 'object') opt = {};
+      options = $.extend(options, opt);
+
+      $.each(['onChange','onSelect','onRelease','onDblClick'],function(i,e) {
+        if (typeof(options[e]) !== 'function') options[e] = function () {};
+      });
+    }
+    //}}}
+    function startDragMode(mode, pos, touch) //{{{
+    {
+      docOffset = getPos($img);
+      Tracker.setCursor(mode === 'move' ? mode : mode + '-resize');
+
+      if (mode === 'move') {
+        return Tracker.activateHandlers(createMover(pos), doneSelect, touch);
+      }
+
+      var fc = Coords.getFixed();
+      var opp = oppLockCorner(mode);
+      var opc = Coords.getCorner(oppLockCorner(opp));
+
+      Coords.setPressed(Coords.getCorner(opp));
+      Coords.setCurrent(opc);
+
+      Tracker.activateHandlers(dragmodeHandler(mode, fc), doneSelect, touch);
+    }
+    //}}}
+    function dragmodeHandler(mode, f) //{{{
+    {
+      return function (pos) {
+        if (!options.aspectRatio) {
+          switch (mode) {
+          case 'e':
+            pos[1] = f.y2;
+            break;
+          case 'w':
+            pos[1] = f.y2;
+            break;
+          case 'n':
+            pos[0] = f.x2;
+            break;
+          case 's':
+            pos[0] = f.x2;
+            break;
+          }
+        } else {
+          switch (mode) {
+          case 'e':
+            pos[1] = f.y + 1;
+            break;
+          case 'w':
+            pos[1] = f.y + 1;
+            break;
+          case 'n':
+            pos[0] = f.x + 1;
+            break;
+          case 's':
+            pos[0] = f.x + 1;
+            break;
+          }
+        }
+        Coords.setCurrent(pos);
+        Selection.update();
+      };
+    }
+    //}}}
+    function createMover(pos) //{{{
+    {
+      var lloc = pos;
+      KeyManager.watchKeys();
+
+      return function (pos) {
+        Coords.moveOffset([pos[0] - lloc[0], pos[1] - lloc[1]]);
+        lloc = pos;
+
+        Selection.update();
+      };
+    }
+    //}}}
+    function oppLockCorner(ord) //{{{
+    {
+      switch (ord) {
+      case 'n':
+        return 'sw';
+      case 's':
+        return 'nw';
+      case 'e':
+        return 'nw';
+      case 'w':
+        return 'ne';
+      case 'ne':
+        return 'sw';
+      case 'nw':
+        return 'se';
+      case 'se':
+        return 'nw';
+      case 'sw':
+        return 'ne';
+      }
+    }
+    //}}}
+    function createDragger(ord) //{{{
+    {
+      return function (e) {
+        if (options.disabled) {
+          return false;
+        }
+        if ((ord === 'move') && !options.allowMove) {
+          return false;
+        }
+        
+        // Fix position of crop area when dragged the very first time.
+        // Necessary when crop image is in a hidden element when page is loaded.
+        docOffset = getPos($img);
+
+        btndown = true;
+        startDragMode(ord, mouseAbs(e));
+        e.stopPropagation();
+        e.preventDefault();
+        return false;
+      };
+    }
+    //}}}
+    function presize($obj, w, h) //{{{
+    {
+      var nw = $obj.width(),
+          nh = $obj.height();
+      if ((nw > w) && w > 0) {
+        nw = w;
+        nh = (w / $obj.width()) * $obj.height();
+      }
+      if ((nh > h) && h > 0) {
+        nh = h;
+        nw = (h / $obj.height()) * $obj.width();
+      }
+      xscale = $obj.width() / nw;
+      yscale = $obj.height() / nh;
+      $obj.width(nw).height(nh);
+    }
+    //}}}
+    function unscale(c) //{{{
+    {
+      return {
+        x: c.x * xscale,
+        y: c.y * yscale,
+        x2: c.x2 * xscale,
+        y2: c.y2 * yscale,
+        w: c.w * xscale,
+        h: c.h * yscale
+      };
+    }
+    //}}}
+    function doneSelect(pos) //{{{
+    {
+      var c = Coords.getFixed();
+      if ((c.w > options.minSelect[0]) && (c.h > options.minSelect[1])) {
+        Selection.enableHandles();
+        Selection.done();
+      } else {
+        Selection.release();
+      }
+      Tracker.setCursor(options.allowSelect ? 'crosshair' : 'default');
+    }
+    //}}}
+    function newSelection(e) //{{{
+    {
+      if (options.disabled) {
+        return false;
+      }
+      if (!options.allowSelect) {
+        return false;
+      }
+      btndown = true;
+      docOffset = getPos($img);
+      Selection.disableHandles();
+      Tracker.setCursor('crosshair');
+      var pos = mouseAbs(e);
+      Coords.setPressed(pos);
+      Selection.update();
+      Tracker.activateHandlers(selectDrag, doneSelect, e.type.substring(0,5)==='touch');
+      KeyManager.watchKeys();
+
+      e.stopPropagation();
+      e.preventDefault();
+      return false;
+    }
+    //}}}
+    function selectDrag(pos) //{{{
+    {
+      Coords.setCurrent(pos);
+      Selection.update();
+    }
+    //}}}
+    function newTracker() //{{{
+    {
+      var trk = $('<div></div>').addClass(cssClass('tracker'));
+      if (is_msie) {
+        trk.css({
+          opacity: 0,
+          backgroundColor: 'white'
+        });
+      }
+      return trk;
+    }
+    //}}}
+
+    // }}}
+    // Initialization {{{
+    // Sanitize some options {{{
+    if (typeof(obj) !== 'object') {
+      obj = $(obj)[0];
+    }
+    if (typeof(opt) !== 'object') {
+      opt = {};
+    }
+    // }}}
+    setOptions(opt);
+    // Initialize some jQuery objects {{{
+    // The values are SET on the image(s) for the interface
+    // If the original image has any of these set, they will be reset
+    // However, if you destroy() the Jcrop instance the original image's
+    // character in the DOM will be as you left it.
+    var img_css = {
+      border: 'none',
+      visibility: 'visible',
+      margin: 0,
+      padding: 0,
+      position: 'absolute',
+      top: 0,
+      left: 0
+    };
+
+    var $origimg = $(obj),
+      img_mode = true;
+
+    if (obj.tagName == 'IMG') {
+      // Fix size of crop image.
+      // Necessary when crop image is within a hidden element when page is loaded.
+      if ($origimg[0].width != 0 && $origimg[0].height != 0) {
+        // Obtain dimensions from contained img element.
+        $origimg.width($origimg[0].width);
+        $origimg.height($origimg[0].height);
+      } else {
+        // Obtain dimensions from temporary image in case the original is not loaded yet (e.g. IE 7.0). 
+        var tempImage = new Image();
+        tempImage.src = $origimg[0].src;
+        $origimg.width(tempImage.width);
+        $origimg.height(tempImage.height);
+      } 
+
+      var $img = $origimg.clone().removeAttr('id').css(img_css).show();
+
+      $img.width($origimg.width());
+      $img.height($origimg.height());
+      $origimg.after($img).hide();
+
+    } else {
+      $img = $origimg.css(img_css).show();
+      img_mode = false;
+      if (options.shade === null) { options.shade = true; }
+    }
+
+    presize($img, options.boxWidth, options.boxHeight);
+
+    var boundx = $img.width(),
+        boundy = $img.height(),
+        
+        
+        $div = $('<div />').width(boundx).height(boundy).addClass(cssClass('holder')).css({
+        position: 'relative',
+        backgroundColor: options.bgColor
+      }).insertAfter($origimg).append($img);
+
+    if (options.addClass) {
+      $div.addClass(options.addClass);
+    }
+
+    var $img2 = $('<div />'),
+
+        $img_holder = $('<div />') 
+        .width('100%').height('100%').css({
+          zIndex: 310,
+          position: 'absolute',
+          overflow: 'hidden'
+        }),
+
+        $hdl_holder = $('<div />') 
+        .width('100%').height('100%').css('zIndex', 320), 
+
+        $sel = $('<div />') 
+        .css({
+          position: 'absolute',
+          zIndex: 600
+        }).dblclick(function(){
+          var c = Coords.getFixed();
+          options.onDblClick.call(api,c);
+        }).insertBefore($img).append($img_holder, $hdl_holder); 
+
+    if (img_mode) {
+
+      $img2 = $('<img />')
+          .attr('src', $img.attr('src')).css(img_css).width(boundx).height(boundy),
+
+      $img_holder.append($img2);
+
+    }
+
+    if (ie6mode) {
+      $sel.css({
+        overflowY: 'hidden'
+      });
+    }
+
+    var bound = options.boundary;
+    var $trk = newTracker().width(boundx + (bound * 2)).height(boundy + (bound * 2)).css({
+      position: 'absolute',
+      top: px(-bound),
+      left: px(-bound),
+      zIndex: 290
+    }).mousedown(newSelection);
+
+    /* }}} */
+    // Set more variables {{{
+    var bgcolor = options.bgColor,
+        bgopacity = options.bgOpacity,
+        xlimit, ylimit, xmin, ymin, xscale, yscale, enabled = true,
+        btndown, animating, shift_down;
+
+    docOffset = getPos($img);
+    // }}}
+    // }}}
+    // Internal Modules {{{
+    // Touch Module {{{ 
+    var Touch = (function () {
+      // Touch support detection function adapted (under MIT License)
+      // from code by Jeffrey Sambells - http://github.com/iamamused/
+      function hasTouchSupport() {
+        var support = {}, events = ['touchstart', 'touchmove', 'touchend'],
+            el = document.createElement('div'), i;
+
+        try {
+          for(i=0; i<events.length; i++) {
+            var eventName = events[i];
+            eventName = 'on' + eventName;
+            var isSupported = (eventName in el);
+            if (!isSupported) {
+              el.setAttribute(eventName, 'return;');
+              isSupported = typeof el[eventName] == 'function';
+            }
+            support[events[i]] = isSupported;
+          }
+          return support.touchstart && support.touchend && support.touchmove;
+        }
+        catch(err) {
+          return false;
+        }
+      }
+
+      function detectSupport() {
+        if ((options.touchSupport === true) || (options.touchSupport === false)) return options.touchSupport;
+          else return hasTouchSupport();
+      }
+      return {
+        createDragger: function (ord) {
+          return function (e) {
+            if (options.disabled) {
+              return false;
+            }
+            if ((ord === 'move') && !options.allowMove) {
+              return false;
+            }
+            docOffset = getPos($img);
+            btndown = true;
+            startDragMode(ord, mouseAbs(Touch.cfilter(e)), true);
+            e.stopPropagation();
+            e.preventDefault();
+            return false;
+          };
+        },
+        newSelection: function (e) {
+          return newSelection(Touch.cfilter(e));
+        },
+        cfilter: function (e){
+          e.pageX = e.originalEvent.changedTouches[0].pageX;
+          e.pageY = e.originalEvent.changedTouches[0].pageY;
+          return e;
+        },
+        isSupported: hasTouchSupport,
+        support: detectSupport()
+      };
+    }());
+    // }}}
+    // Coords Module {{{
+    var Coords = (function () {
+      var x1 = 0,
+          y1 = 0,
+          x2 = 0,
+          y2 = 0,
+          ox, oy;
+
+      function setPressed(pos) //{{{
+      {
+        pos = rebound(pos);
+        x2 = x1 = pos[0];
+        y2 = y1 = pos[1];
+      }
+      //}}}
+      function setCurrent(pos) //{{{
+      {
+        pos = rebound(pos);
+        ox = pos[0] - x2;
+        oy = pos[1] - y2;
+        x2 = pos[0];
+        y2 = pos[1];
+      }
+      //}}}
+      function getOffset() //{{{
+      {
+        return [ox, oy];
+      }
+      //}}}
+      function moveOffset(offset) //{{{
+      {
+        var ox = offset[0],
+            oy = offset[1];
+
+        if (0 > x1 + ox) {
+          ox -= ox + x1;
+        }
+        if (0 > y1 + oy) {
+          oy -= oy + y1;
+        }
+
+        if (boundy < y2 + oy) {
+          oy += boundy - (y2 + oy);
+        }
+        if (boundx < x2 + ox) {
+          ox += boundx - (x2 + ox);
+        }
+
+        x1 += ox;
+        x2 += ox;
+        y1 += oy;
+        y2 += oy;
+      }
+      //}}}
+      function getCorner(ord) //{{{
+      {
+        var c = getFixed();
+        switch (ord) {
+        case 'ne':
+          return [c.x2, c.y];
+        case 'nw':
+          return [c.x, c.y];
+        case 'se':
+          return [c.x2, c.y2];
+        case 'sw':
+          return [c.x, c.y2];
+        }
+      }
+      //}}}
+      function getFixed() //{{{
+      {
+        if (!options.aspectRatio) {
+          return getRect();
+        }
+        // This function could use some optimization I think...
+        var aspect = options.aspectRatio,
+            min_x = options.minSize[0] / xscale,
+            
+            
+            //min_y = options.minSize[1]/yscale,
+            max_x = options.maxSize[0] / xscale,
+            max_y = options.maxSize[1] / yscale,
+            rw = x2 - x1,
+            rh = y2 - y1,
+            rwa = Math.abs(rw),
+            rha = Math.abs(rh),
+            real_ratio = rwa / rha,
+            xx, yy, w, h;
+
+        if (max_x === 0) {
+          max_x = boundx * 10;
+        }
+        if (max_y === 0) {
+          max_y = boundy * 10;
+        }
+        if (real_ratio < aspect) {
+          yy = y2;
+          w = rha * aspect;
+          xx = rw < 0 ? x1 - w : w + x1;
+
+          if (xx < 0) {
+            xx = 0;
+            h = Math.abs((xx - x1) / aspect);
+            yy = rh < 0 ? y1 - h : h + y1;
+          } else if (xx > boundx) {
+            xx = boundx;
+            h = Math.abs((xx - x1) / aspect);
+            yy = rh < 0 ? y1 - h : h + y1;
+          }
+        } else {
+          xx = x2;
+          h = rwa / aspect;
+          yy = rh < 0 ? y1 - h : y1 + h;
+          if (yy < 0) {
+            yy = 0;
+            w = Math.abs((yy - y1) * aspect);
+            xx = rw < 0 ? x1 - w : w + x1;
+          } else if (yy > boundy) {
+            yy = boundy;
+            w = Math.abs(yy - y1) * aspect;
+            xx = rw < 0 ? x1 - w : w + x1;
+          }
+        }
+
+        // Magic %-)
+        if (xx > x1) { // right side
+          if (xx - x1 < min_x) {
+            xx = x1 + min_x;
+          } else if (xx - x1 > max_x) {
+            xx = x1 + max_x;
+          }
+          if (yy > y1) {
+            yy = y1 + (xx - x1) / aspect;
+          } else {
+            yy = y1 - (xx - x1) / aspect;
+          }
+        } else if (xx < x1) { // left side
+          if (x1 - xx < min_x) {
+            xx = x1 - min_x;
+          } else if (x1 - xx > max_x) {
+            xx = x1 - max_x;
+          }
+          if (yy > y1) {
+            yy = y1 + (x1 - xx) / aspect;
+          } else {
+            yy = y1 - (x1 - xx) / aspect;
+          }
+        }
+
+        if (xx < 0) {
+          x1 -= xx;
+          xx = 0;
+        } else if (xx > boundx) {
+          x1 -= xx - boundx;
+          xx = boundx;
+        }
+
+        if (yy < 0) {
+          y1 -= yy;
+          yy = 0;
+        } else if (yy > boundy) {
+          y1 -= yy - boundy;
+          yy = boundy;
+        }
+
+        return makeObj(flipCoords(x1, y1, xx, yy));
+      }
+      //}}}
+      function rebound(p) //{{{
+      {
+        if (p[0] < 0) p[0] = 0;
+        if (p[1] < 0) p[1] = 0;
+
+        if (p[0] > boundx) p[0] = boundx;
+        if (p[1] > boundy) p[1] = boundy;
+
+        return [Math.round(p[0]), Math.round(p[1])];
+      }
+      //}}}
+      function flipCoords(x1, y1, x2, y2) //{{{
+      {
+        var xa = x1,
+            xb = x2,
+            ya = y1,
+            yb = y2;
+        if (x2 < x1) {
+          xa = x2;
+          xb = x1;
+        }
+        if (y2 < y1) {
+          ya = y2;
+          yb = y1;
+        }
+        return [xa, ya, xb, yb];
+      }
+      //}}}
+      function getRect() //{{{
+      {
+        var xsize = x2 - x1,
+            ysize = y2 - y1,
+            delta;
+
+        if (xlimit && (Math.abs(xsize) > xlimit)) {
+          x2 = (xsize > 0) ? (x1 + xlimit) : (x1 - xlimit);
+        }
+        if (ylimit && (Math.abs(ysize) > ylimit)) {
+          y2 = (ysize > 0) ? (y1 + ylimit) : (y1 - ylimit);
+        }
+
+        if (ymin / yscale && (Math.abs(ysize) < ymin / yscale)) {
+          y2 = (ysize > 0) ? (y1 + ymin / yscale) : (y1 - ymin / yscale);
+        }
+        if (xmin / xscale && (Math.abs(xsize) < xmin / xscale)) {
+          x2 = (xsize > 0) ? (x1 + xmin / xscale) : (x1 - xmin / xscale);
+        }
+
+        if (x1 < 0) {
+          x2 -= x1;
+          x1 -= x1;
+        }
+        if (y1 < 0) {
+          y2 -= y1;
+          y1 -= y1;
+        }
+        if (x2 < 0) {
+          x1 -= x2;
+          x2 -= x2;
+        }
+        if (y2 < 0) {
+          y1 -= y2;
+          y2 -= y2;
+        }
+        if (x2 > boundx) {
+          delta = x2 - boundx;
+          x1 -= delta;
+          x2 -= delta;
+        }
+        if (y2 > boundy) {
+          delta = y2 - boundy;
+          y1 -= delta;
+          y2 -= delta;
+        }
+        if (x1 > boundx) {
+          delta = x1 - boundy;
+          y2 -= delta;
+          y1 -= delta;
+        }
+        if (y1 > boundy) {
+          delta = y1 - boundy;
+          y2 -= delta;
+          y1 -= delta;
+        }
+
+        return makeObj(flipCoords(x1, y1, x2, y2));
+      }
+      //}}}
+      function makeObj(a) //{{{
+      {
+        return {
+          x: a[0],
+          y: a[1],
+          x2: a[2],
+          y2: a[3],
+          w: a[2] - a[0],
+          h: a[3] - a[1]
+        };
+      }
+      //}}}
+
+      return {
+        flipCoords: flipCoords,
+        setPressed: setPressed,
+        setCurrent: setCurrent,
+        getOffset: getOffset,
+        moveOffset: moveOffset,
+        getCorner: getCorner,
+        getFixed: getFixed
+      };
+    }());
+
+    //}}}
+    // Shade Module {{{
+    var Shade = (function() {
+      var enabled = false,
+          holder = $('<div />').css({
+            position: 'absolute',
+            zIndex: 240,
+            opacity: 0
+          }),
+          shades = {
+            top: createShade(),
+            left: createShade().height(boundy),
+            right: createShade().height(boundy),
+            bottom: createShade()
+          };
+
+      function resizeShades(w,h) {
+        shades.left.css({ height: px(h) });
+        shades.right.css({ height: px(h) });
+      }
+      function updateAuto()
+      {
+        return updateShade(Coords.getFixed());
+      }
+      function updateShade(c)
+      {
+        shades.top.css({
+          left: px(c.x),
+          width: px(c.w),
+          height: px(c.y)
+        });
+        shades.bottom.css({
+          top: px(c.y2),
+          left: px(c.x),
+          width: px(c.w),
+          height: px(boundy-c.y2)
+        });
+        shades.right.css({
+          left: px(c.x2),
+          width: px(boundx-c.x2)
+        });
+        shades.left.css({
+          width: px(c.x)
+        });
+      }
+      function createShade() {
+        return $('<div />').css({
+          position: 'absolute',
+          backgroundColor: options.shadeColor||options.bgColor
+        }).appendTo(holder);
+      }
+      function enableShade() {
+        if (!enabled) {
+          enabled = true;
+          holder.insertBefore($img);
+          updateAuto();
+          Selection.setBgOpacity(1,0,1);
+          $img2.hide();
+
+          setBgColor(options.shadeColor||options.bgColor,1);
+          if (Selection.isAwake())
+          {
+            setOpacity(options.bgOpacity,1);
+          }
+            else setOpacity(1,1);
+        }
+      }
+      function setBgColor(color,now) {
+        colorChangeMacro(getShades(),color,now);
+      }
+      function disableShade() {
+        if (enabled) {
+          holder.remove();
+          $img2.show();
+          enabled = false;
+          if (Selection.isAwake()) {
+            Selection.setBgOpacity(options.bgOpacity,1,1);
+          } else {
+            Selection.setBgOpacity(1,1,1);
+            Selection.disableHandles();
+          }
+          colorChangeMacro($div,0,1);
+        }
+      }
+      function setOpacity(opacity,now) {
+        if (enabled) {
+          if (options.bgFade && !now) {
+            holder.animate({
+              opacity: 1-opacity
+            },{
+              queue: false,
+              duration: options.fadeTime
+            });
+          }
+          else holder.css({opacity:1-opacity});
+        }
+      }
+      function refreshAll() {
+        options.shade ? enableShade() : disableShade();
+        if (Selection.isAwake()) setOpacity(options.bgOpacity);
+      }
+      function getShades() {
+        return holder.children();
+      }
+
+      return {
+        update: updateAuto,
+        updateRaw: updateShade,
+        getShades: getShades,
+        setBgColor: setBgColor,
+        enable: enableShade,
+        disable: disableShade,
+        resize: resizeShades,
+        refresh: refreshAll,
+        opacity: setOpacity
+      };
+    }());
+    // }}}
+    // Selection Module {{{
+    var Selection = (function () {
+      var awake,
+          hdep = 370,
+          borders = {},
+          handle = {},
+          dragbar = {},
+          seehandles = false;
+
+      // Private Methods
+      function insertBorder(type) //{{{
+      {
+        var jq = $('<div />').css({
+          position: 'absolute',
+          opacity: options.borderOpacity
+        }).addClass(cssClass(type));
+        $img_holder.append(jq);
+        return jq;
+      }
+      //}}}
+      function dragDiv(ord, zi) //{{{
+      {
+        var jq = $('<div />').mousedown(createDragger(ord)).css({
+          cursor: ord + '-resize',
+          position: 'absolute',
+          zIndex: zi
+        }).addClass('ord-'+ord);
+
+        if (Touch.support) {
+          jq.bind('touchstart.jcrop', Touch.createDragger(ord));
+        }
+
+        $hdl_holder.append(jq);
+        return jq;
+      }
+      //}}}
+      function insertHandle(ord) //{{{
+      {
+        var hs = options.handleSize,
+
+          div = dragDiv(ord, hdep++).css({
+            opacity: options.handleOpacity
+          }).addClass(cssClass('handle'));
+
+        if (hs) { div.width(hs).height(hs); }
+
+        return div;
+      }
+      //}}}
+      function insertDragbar(ord) //{{{
+      {
+        return dragDiv(ord, hdep++).addClass('jcrop-dragbar');
+      }
+      //}}}
+      function createDragbars(li) //{{{
+      {
+        var i;
+        for (i = 0; i < li.length; i++) {
+          dragbar[li[i]] = insertDragbar(li[i]);
+        }
+      }
+      //}}}
+      function createBorders(li) //{{{
+      {
+        var cl,i;
+        for (i = 0; i < li.length; i++) {
+          switch(li[i]){
+            case'n': cl='hline'; break;
+            case's': cl='hline bottom'; break;
+            case'e': cl='vline right'; break;
+            case'w': cl='vline'; break;
+          }
+          borders[li[i]] = insertBorder(cl);
+        }
+      }
+      //}}}
+      function createHandles(li) //{{{
+      {
+        var i;
+        for (i = 0; i < li.length; i++) {
+          handle[li[i]] = insertHandle(li[i]);
+        }
+      }
+      //}}}
+      function moveto(x, y) //{{{
+      {
+        if (!options.shade) {
+          $img2.css({
+            top: px(-y),
+            left: px(-x)
+          });
+        }
+        $sel.css({
+          top: px(y),
+          left: px(x)
+        });
+      }
+      //}}}
+      function resize(w, h) //{{{
+      {
+        $sel.width(Math.round(w)).height(Math.round(h));
+      }
+      //}}}
+      function refresh() //{{{
+      {
+        var c = Coords.getFixed();
+
+        Coords.setPressed([c.x, c.y]);
+        Coords.setCurrent([c.x2, c.y2]);
+
+        updateVisible();
+      }
+      //}}}
+
+      // Internal Methods
+      function updateVisible(select) //{{{
+      {
+        if (awake) {
+          return update(select);
+        }
+      }
+      //}}}
+      function update(select) //{{{
+      {
+        var c = Coords.getFixed();
+
+        resize(c.w, c.h);
+        moveto(c.x, c.y);
+        if (options.shade) Shade.updateRaw(c);
+
+        awake || show();
+
+        if (select) {
+          options.onSelect.call(api, unscale(c));
+        } else {
+          options.onChange.call(api, unscale(c));
+        }
+      }
+      //}}}
+      function setBgOpacity(opacity,force,now) //{{{
+      {
+        if (!awake && !force) return;
+        if (options.bgFade && !now) {
+          $img.animate({
+            opacity: opacity
+          },{
+            queue: false,
+            duration: options.fadeTime
+          });
+        } else {
+          $img.css('opacity', opacity);
+        }
+      }
+      //}}}
+      function show() //{{{
+      {
+        $sel.show();
+
+        if (options.shade) Shade.opacity(bgopacity);
+          else setBgOpacity(bgopacity,true);
+
+        awake = true;
+      }
+      //}}}
+      function release() //{{{
+      {
+        disableHandles();
+        $sel.hide();
+
+        if (options.shade) Shade.opacity(1);
+          else setBgOpacity(1);
+
+        awake = false;
+        options.onRelease.call(api);
+      }
+      //}}}
+      function showHandles() //{{{
+      {
+        if (seehandles) {
+          $hdl_holder.show();
+        }
+      }
+      //}}}
+      function enableHandles() //{{{
+      {
+        seehandles = true;
+        if (options.allowResize) {
+          $hdl_holder.show();
+          return true;
+        }
+      }
+      //}}}
+      function disableHandles() //{{{
+      {
+        seehandles = false;
+        $hdl_holder.hide();
+      } 
+      //}}}
+      function animMode(v) //{{{
+      {
+        if (v) {
+          animating = true;
+          disableHandles();
+        } else {
+          animating = false;
+          enableHandles();
+        }
+      } 
+      //}}}
+      function done() //{{{
+      {
+        animMode(false);
+        refresh();
+      } 
+      //}}}
+      // Insert draggable elements {{{
+      // Insert border divs for outline
+
+      if (options.dragEdges && $.isArray(options.createDragbars))
+        createDragbars(options.createDragbars);
+
+      if ($.isArray(options.createHandles))
+        createHandles(options.createHandles);
+
+      if (options.drawBorders && $.isArray(options.createBorders))
+        createBorders(options.createBorders);
+
+      //}}}
+
+      // This is a hack for iOS5 to support drag/move touch functionality
+      $(document).bind('touchstart.jcrop-ios',function(e) {
+        if ($(e.currentTarget).hasClass('jcrop-tracker')) e.stopPropagation();
+      });
+
+      var $track = newTracker().mousedown(createDragger('move')).css({
+        cursor: 'move',
+        position: 'absolute',
+        zIndex: 360
+      });
+
+      if (Touch.support) {
+        $track.bind('touchstart.jcrop', Touch.createDragger('move'));
+      }
+
+      $img_holder.append($track);
+      disableHandles();
+
+      return {
+        updateVisible: updateVisible,
+        update: update,
+        release: release,
+        refresh: refresh,
+        isAwake: function () {
+          return awake;
+        },
+        setCursor: function (cursor) {
+          $track.css('cursor', cursor);
+        },
+        enableHandles: enableHandles,
+        enableOnly: function () {
+          seehandles = true;
+        },
+        showHandles: showHandles,
+        disableHandles: disableHandles,
+        animMode: animMode,
+        setBgOpacity: setBgOpacity,
+        done: done
+      };
+    }());
+    
+    //}}}
+    // Tracker Module {{{
+    var Tracker = (function () {
+      var onMove = function () {},
+          onDone = function () {},
+          trackDoc = options.trackDocument;
+
+      function toFront(touch) //{{{
+      {
+        $trk.css({
+          zIndex: 450
+        });
+
+        if (touch)
+          $(document)
+            .bind('touchmove.jcrop', trackTouchMove)
+            .bind('touchend.jcrop', trackTouchEnd);
+
+        else if (trackDoc)
+          $(document)
+            .bind('mousemove.jcrop',trackMove)
+            .bind('mouseup.jcrop',trackUp);
+      } 
+      //}}}
+      function toBack() //{{{
+      {
+        $trk.css({
+          zIndex: 290
+        });
+        $(document).unbind('.jcrop');
+      } 
+      //}}}
+      function trackMove(e) //{{{
+      {
+        onMove(mouseAbs(e));
+        return false;
+      } 
+      //}}}
+      function trackUp(e) //{{{
+      {
+        e.preventDefault();
+        e.stopPropagation();
+
+        if (btndown) {
+          btndown = false;
+
+          onDone(mouseAbs(e));
+
+          if (Selection.isAwake()) {
+            options.onSelect.call(api, unscale(Coords.getFixed()));
+          }
+
+          toBack();
+          onMove = function () {};
+          onDone = function () {};
+        }
+
+        return false;
+      }
+      //}}}
+      function activateHandlers(move, done, touch) //{{{
+      {
+        btndown = true;
+        onMove = move;
+        onDone = done;
+        toFront(touch);
+        return false;
+      }
+      //}}}
+      function trackTouchMove(e) //{{{
+      {
+        onMove(mouseAbs(Touch.cfilter(e)));
+        return false;
+      }
+      //}}}
+      function trackTouchEnd(e) //{{{
+      {
+        return trackUp(Touch.cfilter(e));
+      }
+      //}}}
+      function setCursor(t) //{{{
+      {
+        $trk.css('cursor', t);
+      }
+      //}}}
+
+      if (!trackDoc) {
+        $trk.mousemove(trackMove).mouseup(trackUp).mouseout(trackUp);
+      }
+
+      $img.before($trk);
+      return {
+        activateHandlers: activateHandlers,
+        setCursor: setCursor
+      };
+    }());
+    //}}}
+    // KeyManager Module {{{
+    var KeyManager = (function () {
+      var $keymgr = $('<input type="radio" />').css({
+        position: 'fixed',
+        left: '-120px',
+        width: '12px'
+      }).addClass('jcrop-keymgr'),
+
+        $keywrap = $('<div />').css({
+          position: 'absolute',
+          overflow: 'hidden'
+        }).append($keymgr);
+
+      function watchKeys() //{{{
+      {
+        if (options.keySupport) {
+          $keymgr.show();
+          $keymgr.focus();
+        }
+      }
+      //}}}
+      function onBlur(e) //{{{
+      {
+        $keymgr.hide();
+      }
+      //}}}
+      function doNudge(e, x, y) //{{{
+      {
+        if (options.allowMove) {
+          Coords.moveOffset([x, y]);
+          Selection.updateVisible(true);
+        }
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      //}}}
+      function parseKey(e) //{{{
+      {
+        if (e.ctrlKey || e.metaKey) {
+          return true;
+        }
+        shift_down = e.shiftKey ? true : false;
+        var nudge = shift_down ? 10 : 1;
+
+        switch (e.keyCode) {
+        case 37:
+          doNudge(e, -nudge, 0);
+          break;
+        case 39:
+          doNudge(e, nudge, 0);
+          break;
+        case 38:
+          doNudge(e, 0, -nudge);
+          break;
+        case 40:
+          doNudge(e, 0, nudge);
+          break;
+        case 27:
+          if (options.allowSelect) Selection.release();
+          break;
+        case 9:
+          return true;
+        }
+
+        return false;
+      }
+      //}}}
+
+      if (options.keySupport) {
+        $keymgr.keydown(parseKey).blur(onBlur);
+        if (ie6mode || !options.fixedSupport) {
+          $keymgr.css({
+            position: 'absolute',
+            left: '-20px'
+          });
+          $keywrap.append($keymgr).insertBefore($img);
+        } else {
+          $keymgr.insertBefore($img);
+        }
+      }
+
+
+      return {
+        watchKeys: watchKeys
+      };
+    }());
+    //}}}
+    // }}}
+    // API methods {{{
+    function setClass(cname) //{{{
+    {
+      $div.removeClass().addClass(cssClass('holder')).addClass(cname);
+    }
+    //}}}
+    function animateTo(a, callback) //{{{
+    {
+      var x1 = a[0] / xscale,
+          y1 = a[1] / yscale,
+          x2 = a[2] / xscale,
+          y2 = a[3] / yscale;
+
+      if (animating) {
+        return;
+      }
+
+      var animto = Coords.flipCoords(x1, y1, x2, y2),
+          c = Coords.getFixed(),
+          initcr = [c.x, c.y, c.x2, c.y2],
+          animat = initcr,
+          interv = options.animationDelay,
+          ix1 = animto[0] - initcr[0],
+          iy1 = animto[1] - initcr[1],
+          ix2 = animto[2] - initcr[2],
+          iy2 = animto[3] - initcr[3],
+          pcent = 0,
+          velocity = options.swingSpeed;
+
+      x1 = animat[0];
+      y1 = animat[1];
+      x2 = animat[2];
+      y2 = animat[3];
+
+      Selection.animMode(true);
+      var anim_timer;
+
+      function queueAnimator() {
+        window.setTimeout(animator, interv);
+      }
+      var animator = (function () {
+        return function () {
+          pcent += (100 - pcent) / velocity;
+
+          animat[0] = Math.round(x1 + ((pcent / 100) * ix1));
+          animat[1] = Math.round(y1 + ((pcent / 100) * iy1));
+          animat[2] = Math.round(x2 + ((pcent / 100) * ix2));
+          animat[3] = Math.round(y2 + ((pcent / 100) * iy2));
+
+          if (pcent >= 99.8) {
+            pcent = 100;
+          }
+          if (pcent < 100) {
+            setSelectRaw(animat);
+            queueAnimator();
+          } else {
+            Selection.done();
+            Selection.animMode(false);
+            if (typeof(callback) === 'function') {
+              callback.call(api);
+            }
+          }
+        };
+      }());
+      queueAnimator();
+    }
+    //}}}
+    function setSelect(rect) //{{{
+    {
+      setSelectRaw([rect[0] / xscale, rect[1] / yscale, rect[2] / xscale, rect[3] / yscale]);
+      options.onSelect.call(api, unscale(Coords.getFixed()));
+      Selection.enableHandles();
+    }
+    //}}}
+    function setSelectRaw(l) //{{{
+    {
+      Coords.setPressed([l[0], l[1]]);
+      Coords.setCurrent([l[2], l[3]]);
+      Selection.update();
+    }
+    //}}}
+    function tellSelect() //{{{
+    {
+      return unscale(Coords.getFixed());
+    }
+    //}}}
+    function tellScaled() //{{{
+    {
+      return Coords.getFixed();
+    }
+    //}}}
+    function setOptionsNew(opt) //{{{
+    {
+      setOptions(opt);
+      interfaceUpdate();
+    }
+    //}}}
+    function disableCrop() //{{{
+    {
+      options.disabled = true;
+      Selection.disableHandles();
+      Selection.setCursor('default');
+      Tracker.setCursor('default');
+    }
+    //}}}
+    function enableCrop() //{{{
+    {
+      options.disabled = false;
+      interfaceUpdate();
+    }
+    //}}}
+    function cancelCrop() //{{{
+    {
+      Selection.done();
+      Tracker.activateHandlers(null, null);
+    }
+    //}}}
+    function destroy() //{{{
+    {
+      $div.remove();
+      $origimg.show();
+      $origimg.css('visibility','visible');
+      $(obj).removeData('Jcrop');
+    }
+    //}}}
+    function setImage(src, callback) //{{{
+    {
+      Selection.release();
+      disableCrop();
+      var img = new Image();
+      img.onload = function () {
+        var iw = img.width;
+        var ih = img.height;
+        var bw = options.boxWidth;
+        var bh = options.boxHeight;
+        $img.width(iw).height(ih);
+        $img.attr('src', src);
+        $img2.attr('src', src);
+        presize($img, bw, bh);
+        boundx = $img.width();
+        boundy = $img.height();
+        $img2.width(boundx).height(boundy);
+        $trk.width(boundx + (bound * 2)).height(boundy + (bound * 2));
+        $div.width(boundx).height(boundy);
+        Shade.resize(boundx,boundy);
+        enableCrop();
+
+        if (typeof(callback) === 'function') {
+          callback.call(api);
+        }
+      };
+      img.src = src;
+    }
+    //}}}
+    function colorChangeMacro($obj,color,now) {
+      var mycolor = color || options.bgColor;
+      if (options.bgFade && supportsColorFade() && options.fadeTime && !now) {
+        $obj.animate({
+          backgroundColor: mycolor
+        }, {
+          queue: false,
+          duration: options.fadeTime
+        });
+      } else {
+        $obj.css('backgroundColor', mycolor);
+      }
+    }
+    function interfaceUpdate(alt) //{{{
+    // This method tweaks the interface based on options object.
+    // Called when options are changed and at end of initialization.
+    {
+      if (options.allowResize) {
+        if (alt) {
+          Selection.enableOnly();
+        } else {
+          Selection.enableHandles();
+        }
+      } else {
+        Selection.disableHandles();
+      }
+
+      Tracker.setCursor(options.allowSelect ? 'crosshair' : 'default');
+      Selection.setCursor(options.allowMove ? 'move' : 'default');
+
+      if (options.hasOwnProperty('trueSize')) {
+        xscale = options.trueSize[0] / boundx;
+        yscale = options.trueSize[1] / boundy;
+      }
+
+      if (options.hasOwnProperty('setSelect')) {
+        setSelect(options.setSelect);
+        Selection.done();
+        delete(options.setSelect);
+      }
+
+      Shade.refresh();
+
+      if (options.bgColor != bgcolor) {
+        colorChangeMacro(
+          options.shade? Shade.getShades(): $div,
+          options.shade?
+            (options.shadeColor || options.bgColor):
+            options.bgColor
+        );
+        bgcolor = options.bgColor;
+      }
+
+      if (bgopacity != options.bgOpacity) {
+        bgopacity = options.bgOpacity;
+        if (options.shade) Shade.refresh();
+          else Selection.setBgOpacity(bgopacity);
+      }
+
+      xlimit = options.maxSize[0] || 0;
+      ylimit = options.maxSize[1] || 0;
+      xmin = options.minSize[0] || 0;
+      ymin = options.minSize[1] || 0;
+
+      if (options.hasOwnProperty('outerImage')) {
+        $img.attr('src', options.outerImage);
+        delete(options.outerImage);
+      }
+
+      Selection.refresh();
+    }
+    //}}}
+    //}}}
+
+    if (Touch.support) $trk.bind('touchstart.jcrop', Touch.newSelection);
+
+    $hdl_holder.hide();
+    interfaceUpdate(true);
+
+    var api = {
+      setImage: setImage,
+      animateTo: animateTo,
+      setSelect: setSelect,
+      setOptions: setOptionsNew,
+      tellSelect: tellSelect,
+      tellScaled: tellScaled,
+      setClass: setClass,
+
+      disable: disableCrop,
+      enable: enableCrop,
+      cancel: cancelCrop,
+      release: Selection.release,
+      destroy: destroy,
+
+      focus: KeyManager.watchKeys,
+
+      getBounds: function () {
+        return [boundx * xscale, boundy * yscale];
+      },
+      getWidgetSize: function () {
+        return [boundx, boundy];
+      },
+      getScaleFactor: function () {
+        return [xscale, yscale];
+      },
+      getOptions: function() {
+        // careful: internal values are returned
+        return options;
+      },
+
+      ui: {
+        holder: $div,
+        selection: $sel
+      }
+    };
+
+    if (is_msie) $div.bind('selectstart', function () { return false; });
+
+    $origimg.data('Jcrop', api);
+    return api;
+  };
+  $.fn.Jcrop = function (options, callback) //{{{
+  {
+    var api;
+    // Iterate over each object, attach Jcrop
+    this.each(function () {
+      // If we've already attached to this object
+      if ($(this).data('Jcrop')) {
+        // The API can be requested this way (undocumented)
+        if (options === 'api') return $(this).data('Jcrop');
+        // Otherwise, we just reset the options...
+        else $(this).data('Jcrop').setOptions(options);
+      }
+      // If we haven't been attached, preload and attach
+      else {
+        if (this.tagName == 'IMG')
+          $.Jcrop.Loader(this,function(){
+            $(this).css({display:'block',visibility:'hidden'});
+            api = $.Jcrop(this, options);
+            if ($.isFunction(callback)) callback.call(api);
+          });
+        else {
+          $(this).css({display:'block',visibility:'hidden'});
+          api = $.Jcrop(this, options);
+          if ($.isFunction(callback)) callback.call(api);
+        }
+      }
+    });
+
+    // Return "this" so the object is chainable (jQuery-style)
+    return this;
+  };
+  //}}}
+  // $.Jcrop.Loader - basic image loader {{{
+
+  $.Jcrop.Loader = function(imgobj,success,error){
+    var $img = $(imgobj), img = $img[0];
+
+    function completeCheck(){
+      if (img.complete) {
+        $img.unbind('.jcloader');
+        if ($.isFunction(success)) success.call(img);
+      }
+      else window.setTimeout(completeCheck,50);
+    }
+
+    $img
+      .bind('load.jcloader',completeCheck)
+      .bind('error.jcloader',function(e){
+        $img.unbind('.jcloader');
+        if ($.isFunction(error)) error.call(img);
+      });
+
+    if (img.complete && $.isFunction(success)){
+      $img.unbind('.jcloader');
+      success.call(img);
+    }
+  };
+
+  //}}}
+  // Global Defaults {{{
+  $.Jcrop.defaults = {
+
+    // Basic Settings
+    allowSelect: true,
+    allowMove: true,
+    allowResize: true,
+
+    trackDocument: true,
+
+    // Styling Options
+    baseClass: 'jcrop',
+    addClass: null,
+    bgColor: 'black',
+    bgOpacity: 0.6,
+    bgFade: false,
+    borderOpacity: 0.4,
+    handleOpacity: 0.5,
+    handleSize: null,
+
+    aspectRatio: 0,
+    keySupport: true,
+    createHandles: ['n','s','e','w','nw','ne','se','sw'],
+    createDragbars: ['n','s','e','w'],
+    createBorders: ['n','s','e','w'],
+    drawBorders: true,
+    dragEdges: true,
+    fixedSupport: true,
+    touchSupport: null,
+
+    shade: null,
+
+    boxWidth: 0,
+    boxHeight: 0,
+    boundary: 2,
+    fadeTime: 400,
+    animationDelay: 20,
+    swingSpeed: 3,
+
+    minSelect: [0, 0],
+    maxSize: [0, 0],
+    minSize: [0, 0],
+
+    // Callbacks / Event Handlers
+    onChange: function () {},
+    onSelect: function () {},
+    onDblClick: function () {},
+    onRelease: function () {}
+  };
+
+  // }}}
+}(jQuery));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.Jcrop.min.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,22 @@
+/**
+ * jquery.Jcrop.min.js v0.9.12 (build:20130202)
+ * jQuery Image Cropping Plugin - released under MIT License
+ * Copyright (c) 2008-2013 Tapmodo Interactive LLC
+ * https://github.com/tapmodo/Jcrop
+ */
+(function(a){a.Jcrop=function(b,c){function i(a){return Math.round(a)+"px"}function j(a){return d.baseClass+"-"+a}function k(){return a.fx.step.hasOwnProperty("backgroundColor")}function l(b){var c=a(b).offset();return[c.left,c.top]}function m(a){return[a.pageX-e[0],a.pageY-e[1]]}function n(b){typeof b!="object"&&(b={}),d=a.extend(d,b),a.each(["onChange","onSelect","onRelease","onDblClick"],function(a,b){typeof d[b]!="function"&&(d[b]=function(){})})}function o(a,b,c){e=l(D),bc.setCursor(a==="move"?a:a+"-resize");if(a==="move")return bc.activateHandlers(q(b),v,c);var d=_.getFixed(),f=r(a),g=_.getCorner(r(f));_.setPressed(_.getCorner(f)),_.setCurrent(g),bc.activateHandlers(p(a,d),v,c)}function p(a,b){return function(c){if(!d.aspectRatio)switch(a){case"e":c[1]=b.y2;break;case"w":c[1]=b.y2;break;case"n":c[0]=b.x2;break;case"s":c[0]=b.x2}else switch(a){case"e":c[1]=b.y+1;break;case"w":c[1]=b.y+1;break;case"n":c[0]=b.x+1;break;case"s":c[0]=b.x+1}_.setCurrent(c),bb.update()}}function q(a){var b=a;return bd.watchKeys
+(),function(a){_.moveOffset([a[0]-b[0],a[1]-b[1]]),b=a,bb.update()}}function r(a){switch(a){case"n":return"sw";case"s":return"nw";case"e":return"nw";case"w":return"ne";case"ne":return"sw";case"nw":return"se";case"se":return"nw";case"sw":return"ne"}}function s(a){return function(b){return d.disabled?!1:a==="move"&&!d.allowMove?!1:(e=l(D),W=!0,o(a,m(b)),b.stopPropagation(),b.preventDefault(),!1)}}function t(a,b,c){var d=a.width(),e=a.height();d>b&&b>0&&(d=b,e=b/a.width()*a.height()),e>c&&c>0&&(e=c,d=c/a.height()*a.width()),T=a.width()/d,U=a.height()/e,a.width(d).height(e)}function u(a){return{x:a.x*T,y:a.y*U,x2:a.x2*T,y2:a.y2*U,w:a.w*T,h:a.h*U}}function v(a){var b=_.getFixed();b.w>d.minSelect[0]&&b.h>d.minSelect[1]?(bb.enableHandles(),bb.done()):bb.release(),bc.setCursor(d.allowSelect?"crosshair":"default")}function w(a){if(d.disabled)return!1;if(!d.allowSelect)return!1;W=!0,e=l(D),bb.disableHandles(),bc.setCursor("crosshair");var b=m(a);return _.setPressed(b),bb.update(),bc.activateHandlers(x,v,a.type.substring
+(0,5)==="touch"),bd.watchKeys(),a.stopPropagation(),a.preventDefault(),!1}function x(a){_.setCurrent(a),bb.update()}function y(){var b=a("<div></div>").addClass(j("tracker"));return g&&b.css({opacity:0,backgroundColor:"white"}),b}function be(a){G.removeClass().addClass(j("holder")).addClass(a)}function bf(a,b){function t(){window.setTimeout(u,l)}var c=a[0]/T,e=a[1]/U,f=a[2]/T,g=a[3]/U;if(X)return;var h=_.flipCoords(c,e,f,g),i=_.getFixed(),j=[i.x,i.y,i.x2,i.y2],k=j,l=d.animationDelay,m=h[0]-j[0],n=h[1]-j[1],o=h[2]-j[2],p=h[3]-j[3],q=0,r=d.swingSpeed;c=k[0],e=k[1],f=k[2],g=k[3],bb.animMode(!0);var s,u=function(){return function(){q+=(100-q)/r,k[0]=Math.round(c+q/100*m),k[1]=Math.round(e+q/100*n),k[2]=Math.round(f+q/100*o),k[3]=Math.round(g+q/100*p),q>=99.8&&(q=100),q<100?(bh(k),t()):(bb.done(),bb.animMode(!1),typeof b=="function"&&b.call(bs))}}();t()}function bg(a){bh([a[0]/T,a[1]/U,a[2]/T,a[3]/U]),d.onSelect.call(bs,u(_.getFixed())),bb.enableHandles()}function bh(a){_.setPressed([a[0],a[1]]),_.setCurrent([a[2],
+a[3]]),bb.update()}function bi(){return u(_.getFixed())}function bj(){return _.getFixed()}function bk(a){n(a),br()}function bl(){d.disabled=!0,bb.disableHandles(),bb.setCursor("default"),bc.setCursor("default")}function bm(){d.disabled=!1,br()}function bn(){bb.done(),bc.activateHandlers(null,null)}function bo(){G.remove(),A.show(),A.css("visibility","visible"),a(b).removeData("Jcrop")}function bp(a,b){bb.release(),bl();var c=new Image;c.onload=function(){var e=c.width,f=c.height,g=d.boxWidth,h=d.boxHeight;D.width(e).height(f),D.attr("src",a),H.attr("src",a),t(D,g,h),E=D.width(),F=D.height(),H.width(E).height(F),M.width(E+L*2).height(F+L*2),G.width(E).height(F),ba.resize(E,F),bm(),typeof b=="function"&&b.call(bs)},c.src=a}function bq(a,b,c){var e=b||d.bgColor;d.bgFade&&k()&&d.fadeTime&&!c?a.animate({backgroundColor:e},{queue:!1,duration:d.fadeTime}):a.css("backgroundColor",e)}function br(a){d.allowResize?a?bb.enableOnly():bb.enableHandles():bb.disableHandles(),bc.setCursor(d.allowSelect?"crosshair":"default"),bb
+.setCursor(d.allowMove?"move":"default"),d.hasOwnProperty("trueSize")&&(T=d.trueSize[0]/E,U=d.trueSize[1]/F),d.hasOwnProperty("setSelect")&&(bg(d.setSelect),bb.done(),delete d.setSelect),ba.refresh(),d.bgColor!=N&&(bq(d.shade?ba.getShades():G,d.shade?d.shadeColor||d.bgColor:d.bgColor),N=d.bgColor),O!=d.bgOpacity&&(O=d.bgOpacity,d.shade?ba.refresh():bb.setBgOpacity(O)),P=d.maxSize[0]||0,Q=d.maxSize[1]||0,R=d.minSize[0]||0,S=d.minSize[1]||0,d.hasOwnProperty("outerImage")&&(D.attr("src",d.outerImage),delete d.outerImage),bb.refresh()}var d=a.extend({},a.Jcrop.defaults),e,f=navigator.userAgent.toLowerCase(),g=/msie/.test(f),h=/msie [1-6]\./.test(f);typeof b!="object"&&(b=a(b)[0]),typeof c!="object"&&(c={}),n(c);var z={border:"none",visibility:"visible",margin:0,padding:0,position:"absolute",top:0,left:0},A=a(b),B=!0;if(b.tagName=="IMG"){if(A[0].width!=0&&A[0].height!=0)A.width(A[0].width),A.height(A[0].height);else{var C=new Image;C.src=A[0].src,A.width(C.width),A.height(C.height)}var D=A.clone().removeAttr("id").
+css(z).show();D.width(A.width()),D.height(A.height()),A.after(D).hide()}else D=A.css(z).show(),B=!1,d.shade===null&&(d.shade=!0);t(D,d.boxWidth,d.boxHeight);var E=D.width(),F=D.height(),G=a("<div />").width(E).height(F).addClass(j("holder")).css({position:"relative",backgroundColor:d.bgColor}).insertAfter(A).append(D);d.addClass&&G.addClass(d.addClass);var H=a("<div />"),I=a("<div />").width("100%").height("100%").css({zIndex:310,position:"absolute",overflow:"hidden"}),J=a("<div />").width("100%").height("100%").css("zIndex",320),K=a("<div />").css({position:"absolute",zIndex:600}).dblclick(function(){var a=_.getFixed();d.onDblClick.call(bs,a)}).insertBefore(D).append(I,J);B&&(H=a("<img />").attr("src",D.attr("src")).css(z).width(E).height(F),I.append(H)),h&&K.css({overflowY:"hidden"});var L=d.boundary,M=y().width(E+L*2).height(F+L*2).css({position:"absolute",top:i(-L),left:i(-L),zIndex:290}).mousedown(w),N=d.bgColor,O=d.bgOpacity,P,Q,R,S,T,U,V=!0,W,X,Y;e=l(D);var Z=function(){function a(){var a={},b=["touchstart"
+,"touchmove","touchend"],c=document.createElement("div"),d;try{for(d=0;d<b.length;d++){var e=b[d];e="on"+e;var f=e in c;f||(c.setAttribute(e,"return;"),f=typeof c[e]=="function"),a[b[d]]=f}return a.touchstart&&a.touchend&&a.touchmove}catch(g){return!1}}function b(){return d.touchSupport===!0||d.touchSupport===!1?d.touchSupport:a()}return{createDragger:function(a){return function(b){return d.disabled?!1:a==="move"&&!d.allowMove?!1:(e=l(D),W=!0,o(a,m(Z.cfilter(b)),!0),b.stopPropagation(),b.preventDefault(),!1)}},newSelection:function(a){return w(Z.cfilter(a))},cfilter:function(a){return a.pageX=a.originalEvent.changedTouches[0].pageX,a.pageY=a.originalEvent.changedTouches[0].pageY,a},isSupported:a,support:b()}}(),_=function(){function h(d){d=n(d),c=a=d[0],e=b=d[1]}function i(a){a=n(a),f=a[0]-c,g=a[1]-e,c=a[0],e=a[1]}function j(){return[f,g]}function k(d){var f=d[0],g=d[1];0>a+f&&(f-=f+a),0>b+g&&(g-=g+b),F<e+g&&(g+=F-(e+g)),E<c+f&&(f+=E-(c+f)),a+=f,c+=f,b+=g,e+=g}function l(a){var b=m();switch(a){case"ne":return[
+b.x2,b.y];case"nw":return[b.x,b.y];case"se":return[b.x2,b.y2];case"sw":return[b.x,b.y2]}}function m(){if(!d.aspectRatio)return p();var f=d.aspectRatio,g=d.minSize[0]/T,h=d.maxSize[0]/T,i=d.maxSize[1]/U,j=c-a,k=e-b,l=Math.abs(j),m=Math.abs(k),n=l/m,r,s,t,u;return h===0&&(h=E*10),i===0&&(i=F*10),n<f?(s=e,t=m*f,r=j<0?a-t:t+a,r<0?(r=0,u=Math.abs((r-a)/f),s=k<0?b-u:u+b):r>E&&(r=E,u=Math.abs((r-a)/f),s=k<0?b-u:u+b)):(r=c,u=l/f,s=k<0?b-u:b+u,s<0?(s=0,t=Math.abs((s-b)*f),r=j<0?a-t:t+a):s>F&&(s=F,t=Math.abs(s-b)*f,r=j<0?a-t:t+a)),r>a?(r-a<g?r=a+g:r-a>h&&(r=a+h),s>b?s=b+(r-a)/f:s=b-(r-a)/f):r<a&&(a-r<g?r=a-g:a-r>h&&(r=a-h),s>b?s=b+(a-r)/f:s=b-(a-r)/f),r<0?(a-=r,r=0):r>E&&(a-=r-E,r=E),s<0?(b-=s,s=0):s>F&&(b-=s-F,s=F),q(o(a,b,r,s))}function n(a){return a[0]<0&&(a[0]=0),a[1]<0&&(a[1]=0),a[0]>E&&(a[0]=E),a[1]>F&&(a[1]=F),[Math.round(a[0]),Math.round(a[1])]}function o(a,b,c,d){var e=a,f=c,g=b,h=d;return c<a&&(e=c,f=a),d<b&&(g=d,h=b),[e,g,f,h]}function p(){var d=c-a,f=e-b,g;return P&&Math.abs(d)>P&&(c=d>0?a+P:a-P),Q&&Math.abs
+(f)>Q&&(e=f>0?b+Q:b-Q),S/U&&Math.abs(f)<S/U&&(e=f>0?b+S/U:b-S/U),R/T&&Math.abs(d)<R/T&&(c=d>0?a+R/T:a-R/T),a<0&&(c-=a,a-=a),b<0&&(e-=b,b-=b),c<0&&(a-=c,c-=c),e<0&&(b-=e,e-=e),c>E&&(g=c-E,a-=g,c-=g),e>F&&(g=e-F,b-=g,e-=g),a>E&&(g=a-F,e-=g,b-=g),b>F&&(g=b-F,e-=g,b-=g),q(o(a,b,c,e))}function q(a){return{x:a[0],y:a[1],x2:a[2],y2:a[3],w:a[2]-a[0],h:a[3]-a[1]}}var a=0,b=0,c=0,e=0,f,g;return{flipCoords:o,setPressed:h,setCurrent:i,getOffset:j,moveOffset:k,getCorner:l,getFixed:m}}(),ba=function(){function f(a,b){e.left.css({height:i(b)}),e.right.css({height:i(b)})}function g(){return h(_.getFixed())}function h(a){e.top.css({left:i(a.x),width:i(a.w),height:i(a.y)}),e.bottom.css({top:i(a.y2),left:i(a.x),width:i(a.w),height:i(F-a.y2)}),e.right.css({left:i(a.x2),width:i(E-a.x2)}),e.left.css({width:i(a.x)})}function j(){return a("<div />").css({position:"absolute",backgroundColor:d.shadeColor||d.bgColor}).appendTo(c)}function k(){b||(b=!0,c.insertBefore(D),g(),bb.setBgOpacity(1,0,1),H.hide(),l(d.shadeColor||d.bgColor,1),bb.
+isAwake()?n(d.bgOpacity,1):n(1,1))}function l(a,b){bq(p(),a,b)}function m(){b&&(c.remove(),H.show(),b=!1,bb.isAwake()?bb.setBgOpacity(d.bgOpacity,1,1):(bb.setBgOpacity(1,1,1),bb.disableHandles()),bq(G,0,1))}function n(a,e){b&&(d.bgFade&&!e?c.animate({opacity:1-a},{queue:!1,duration:d.fadeTime}):c.css({opacity:1-a}))}function o(){d.shade?k():m(),bb.isAwake()&&n(d.bgOpacity)}function p(){return c.children()}var b=!1,c=a("<div />").css({position:"absolute",zIndex:240,opacity:0}),e={top:j(),left:j().height(F),right:j().height(F),bottom:j()};return{update:g,updateRaw:h,getShades:p,setBgColor:l,enable:k,disable:m,resize:f,refresh:o,opacity:n}}(),bb=function(){function k(b){var c=a("<div />").css({position:"absolute",opacity:d.borderOpacity}).addClass(j(b));return I.append(c),c}function l(b,c){var d=a("<div />").mousedown(s(b)).css({cursor:b+"-resize",position:"absolute",zIndex:c}).addClass("ord-"+b);return Z.support&&d.bind("touchstart.jcrop",Z.createDragger(b)),J.append(d),d}function m(a){var b=d.handleSize,e=l(a,c++
+).css({opacity:d.handleOpacity}).addClass(j("handle"));return b&&e.width(b).height(b),e}function n(a){return l(a,c++).addClass("jcrop-dragbar")}function o(a){var b;for(b=0;b<a.length;b++)g[a[b]]=n(a[b])}function p(a){var b,c;for(c=0;c<a.length;c++){switch(a[c]){case"n":b="hline";break;case"s":b="hline bottom";break;case"e":b="vline right";break;case"w":b="vline"}e[a[c]]=k(b)}}function q(a){var b;for(b=0;b<a.length;b++)f[a[b]]=m(a[b])}function r(a,b){d.shade||H.css({top:i(-b),left:i(-a)}),K.css({top:i(b),left:i(a)})}function t(a,b){K.width(Math.round(a)).height(Math.round(b))}function v(){var a=_.getFixed();_.setPressed([a.x,a.y]),_.setCurrent([a.x2,a.y2]),w()}function w(a){if(b)return x(a)}function x(a){var c=_.getFixed();t(c.w,c.h),r(c.x,c.y),d.shade&&ba.updateRaw(c),b||A(),a?d.onSelect.call(bs,u(c)):d.onChange.call(bs,u(c))}function z(a,c,e){if(!b&&!c)return;d.bgFade&&!e?D.animate({opacity:a},{queue:!1,duration:d.fadeTime}):D.css("opacity",a)}function A(){K.show(),d.shade?ba.opacity(O):z(O,!0),b=!0}function B
+(){F(),K.hide(),d.shade?ba.opacity(1):z(1),b=!1,d.onRelease.call(bs)}function C(){h&&J.show()}function E(){h=!0;if(d.allowResize)return J.show(),!0}function F(){h=!1,J.hide()}function G(a){a?(X=!0,F()):(X=!1,E())}function L(){G(!1),v()}var b,c=370,e={},f={},g={},h=!1;d.dragEdges&&a.isArray(d.createDragbars)&&o(d.createDragbars),a.isArray(d.createHandles)&&q(d.createHandles),d.drawBorders&&a.isArray(d.createBorders)&&p(d.createBorders),a(document).bind("touchstart.jcrop-ios",function(b){a(b.currentTarget).hasClass("jcrop-tracker")&&b.stopPropagation()});var M=y().mousedown(s("move")).css({cursor:"move",position:"absolute",zIndex:360});return Z.support&&M.bind("touchstart.jcrop",Z.createDragger("move")),I.append(M),F(),{updateVisible:w,update:x,release:B,refresh:v,isAwake:function(){return b},setCursor:function(a){M.css("cursor",a)},enableHandles:E,enableOnly:function(){h=!0},showHandles:C,disableHandles:F,animMode:G,setBgOpacity:z,done:L}}(),bc=function(){function f(b){M.css({zIndex:450}),b?a(document).bind("touchmove.jcrop"
+,k).bind("touchend.jcrop",l):e&&a(document).bind("mousemove.jcrop",h).bind("mouseup.jcrop",i)}function g(){M.css({zIndex:290}),a(document).unbind(".jcrop")}function h(a){return b(m(a)),!1}function i(a){return a.preventDefault(),a.stopPropagation(),W&&(W=!1,c(m(a)),bb.isAwake()&&d.onSelect.call(bs,u(_.getFixed())),g(),b=function(){},c=function(){}),!1}function j(a,d,e){return W=!0,b=a,c=d,f(e),!1}function k(a){return b(m(Z.cfilter(a))),!1}function l(a){return i(Z.cfilter(a))}function n(a){M.css("cursor",a)}var b=function(){},c=function(){},e=d.trackDocument;return e||M.mousemove(h).mouseup(i).mouseout(i),D.before(M),{activateHandlers:j,setCursor:n}}(),bd=function(){function e(){d.keySupport&&(b.show(),b.focus())}function f(a){b.hide()}function g(a,b,c){d.allowMove&&(_.moveOffset([b,c]),bb.updateVisible(!0)),a.preventDefault(),a.stopPropagation()}function i(a){if(a.ctrlKey||a.metaKey)return!0;Y=a.shiftKey?!0:!1;var b=Y?10:1;switch(a.keyCode){case 37:g(a,-b,0);break;case 39:g(a,b,0);break;case 38:g(a,0,-b);break;
+case 40:g(a,0,b);break;case 27:d.allowSelect&&bb.release();break;case 9:return!0}return!1}var b=a('<input type="radio" />').css({position:"fixed",left:"-120px",width:"12px"}).addClass("jcrop-keymgr"),c=a("<div />").css({position:"absolute",overflow:"hidden"}).append(b);return d.keySupport&&(b.keydown(i).blur(f),h||!d.fixedSupport?(b.css({position:"absolute",left:"-20px"}),c.append(b).insertBefore(D)):b.insertBefore(D)),{watchKeys:e}}();Z.support&&M.bind("touchstart.jcrop",Z.newSelection),J.hide(),br(!0);var bs={setImage:bp,animateTo:bf,setSelect:bg,setOptions:bk,tellSelect:bi,tellScaled:bj,setClass:be,disable:bl,enable:bm,cancel:bn,release:bb.release,destroy:bo,focus:bd.watchKeys,getBounds:function(){return[E*T,F*U]},getWidgetSize:function(){return[E,F]},getScaleFactor:function(){return[T,U]},getOptions:function(){return d},ui:{holder:G,selection:K}};return g&&G.bind("selectstart",function(){return!1}),A.data("Jcrop",bs),bs},a.fn.Jcrop=function(b,c){var d;return this.each(function(){if(a(this).data("Jcrop")){if(
+b==="api")return a(this).data("Jcrop");a(this).data("Jcrop").setOptions(b)}else this.tagName=="IMG"?a.Jcrop.Loader(this,function(){a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d)}):(a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d))}),this},a.Jcrop.Loader=function(b,c,d){function g(){f.complete?(e.unbind(".jcloader"),a.isFunction(c)&&c.call(f)):window.setTimeout(g,50)}var e=a(b),f=e[0];e.bind("load.jcloader",g).bind("error.jcloader",function(b){e.unbind(".jcloader"),a.isFunction(d)&&d.call(f)}),f.complete&&a.isFunction(c)&&(e.unbind(".jcloader"),c.call(f))},a.Jcrop.defaults={allowSelect:!0,allowMove:!0,allowResize:!0,trackDocument:!0,baseClass:"jcrop",addClass:null,bgColor:"black",bgOpacity:.6,bgFade:!1,borderOpacity:.4,handleOpacity:.5,handleSize:null,aspectRatio:0,keySupport:!0,createHandles:["n","s","e","w","nw","ne","se","sw"],createDragbars:["n","s","e","w"],createBorders:["n","s","e","w"],drawBorders:!0,dragEdges
+:!0,fixedSupport:!0,touchSupport:null,shade:null,boxWidth:0,boxHeight:0,boundary:2,fadeTime:400,animationDelay:20,swingSpeed:3,minSelect:[0,0],maxSize:[0,0],minSize:[0,0],onChange:function(){},onSelect:function(){},onDblClick:function(){},onRelease:function(){}}})(jQuery);
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.color.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,661 @@
+/*!
+ * jQuery Color Animations v2.0pre
+ * http://jquery.org/
+ *
+ * Copyright 2011 John Resig
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ */
+
+(function( jQuery, undefined ){
+	var stepHooks = "backgroundColor borderBottomColor borderLeftColor borderRightColor borderTopColor color outlineColor".split(" "),
+
+		// plusequals test for += 100 -= 100
+		rplusequals = /^([\-+])=\s*(\d+\.?\d*)/,
+		// a set of RE's that can match strings and generate color tuples.
+		stringParsers = [{
+				re: /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
+				parse: function( execResult ) {
+					return [
+						execResult[ 1 ],
+						execResult[ 2 ],
+						execResult[ 3 ],
+						execResult[ 4 ]
+					];
+				}
+			}, {
+				re: /rgba?\(\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
+				parse: function( execResult ) {
+					return [
+						2.55 * execResult[1],
+						2.55 * execResult[2],
+						2.55 * execResult[3],
+						execResult[ 4 ]
+					];
+				}
+			}, {
+				re: /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/,
+				parse: function( execResult ) {
+					return [
+						parseInt( execResult[ 1 ], 16 ),
+						parseInt( execResult[ 2 ], 16 ),
+						parseInt( execResult[ 3 ], 16 )
+					];
+				}
+			}, {
+				re: /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/,
+				parse: function( execResult ) {
+					return [
+						parseInt( execResult[ 1 ] + execResult[ 1 ], 16 ),
+						parseInt( execResult[ 2 ] + execResult[ 2 ], 16 ),
+						parseInt( execResult[ 3 ] + execResult[ 3 ], 16 )
+					];
+				}
+			}, {
+				re: /hsla?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/,
+				space: "hsla",
+				parse: function( execResult ) {
+					return [
+						execResult[1],
+						execResult[2] / 100,
+						execResult[3] / 100,
+						execResult[4]
+					];
+				}
+			}],
+
+		// jQuery.Color( )
+		color = jQuery.Color = function( color, green, blue, alpha ) {
+			return new jQuery.Color.fn.parse( color, green, blue, alpha );
+		},
+		spaces = {
+			rgba: {
+				cache: "_rgba",
+				props: {
+					red: {
+						idx: 0,
+						type: "byte",
+						empty: true
+					},
+					green: {
+						idx: 1,
+						type: "byte",
+						empty: true
+					},
+					blue: {
+						idx: 2,
+						type: "byte",
+						empty: true
+					},
+					alpha: {
+						idx: 3,
+						type: "percent",
+						def: 1
+					}
+				}
+			},
+			hsla: {
+				cache: "_hsla",
+				props: {
+					hue: {
+						idx: 0,
+						type: "degrees",
+						empty: true
+					},
+					saturation: {
+						idx: 1,
+						type: "percent",
+						empty: true
+					},
+					lightness: {
+						idx: 2,
+						type: "percent",
+						empty: true
+					}
+				}
+			}
+		},
+		propTypes = {
+			"byte": {
+				floor: true,
+				min: 0,
+				max: 255
+			},
+			"percent": {
+				min: 0,
+				max: 1
+			},
+			"degrees": {
+				mod: 360,
+				floor: true
+			}
+		},
+		rgbaspace = spaces.rgba.props,
+		support = color.support = {},
+
+		// colors = jQuery.Color.names
+		colors,
+
+		// local aliases of functions called often
+		each = jQuery.each;
+
+	spaces.hsla.props.alpha = rgbaspace.alpha;
+
+	function clamp( value, prop, alwaysAllowEmpty ) {
+		var type = propTypes[ prop.type ] || {},
+			allowEmpty = prop.empty || alwaysAllowEmpty;
+
+		if ( allowEmpty && value == null ) {
+			return null;
+		}
+		if ( prop.def && value == null ) {
+			return prop.def;
+		}
+		if ( type.floor ) {
+			value = ~~value;
+		} else {
+			value = parseFloat( value );
+		}
+		if ( value == null || isNaN( value ) ) {
+			return prop.def;
+		}
+		if ( type.mod ) {
+			value = value % type.mod;
+			// -10 -> 350
+			return value < 0 ? type.mod + value : value;
+		}
+
+		// for now all property types without mod have min and max
+		return type.min > value ? type.min : type.max < value ? type.max : value;
+	}
+
+	function stringParse( string ) {
+		var inst = color(),
+			rgba = inst._rgba = [];
+
+		string = string.toLowerCase();
+
+		each( stringParsers, function( i, parser ) {
+			var match = parser.re.exec( string ),
+				values = match && parser.parse( match ),
+				parsed,
+				spaceName = parser.space || "rgba",
+				cache = spaces[ spaceName ].cache;
+
+
+			if ( values ) {
+				parsed = inst[ spaceName ]( values );
+
+				// if this was an rgba parse the assignment might happen twice
+				// oh well....
+				inst[ cache ] = parsed[ cache ];
+				rgba = inst._rgba = parsed._rgba;
+
+				// exit each( stringParsers ) here because we matched
+				return false;
+			}
+		});
+
+		// Found a stringParser that handled it
+		if ( rgba.length !== 0 ) {
+
+			// if this came from a parsed string, force "transparent" when alpha is 0
+			// chrome, (and maybe others) return "transparent" as rgba(0,0,0,0)
+			if ( Math.max.apply( Math, rgba ) === 0 ) {
+				jQuery.extend( rgba, colors.transparent );
+			}
+			return inst;
+		}
+
+		// named colors / default - filter back through parse function
+		if ( string = colors[ string ] ) {
+			return string;
+		}
+	}
+
+	color.fn = color.prototype = {
+		constructor: color,
+		parse: function( red, green, blue, alpha ) {
+			if ( red === undefined ) {
+				this._rgba = [ null, null, null, null ];
+				return this;
+			}
+			if ( red instanceof jQuery || red.nodeType ) {
+				red = red instanceof jQuery ? red.css( green ) : jQuery( red ).css( green );
+				green = undefined;
+			}
+
+			var inst = this,
+				type = jQuery.type( red ),
+				rgba = this._rgba = [],
+				source;
+
+			// more than 1 argument specified - assume ( red, green, blue, alpha )
+			if ( green !== undefined ) {
+				red = [ red, green, blue, alpha ];
+				type = "array";
+			}
+
+			if ( type === "string" ) {
+				return this.parse( stringParse( red ) || colors._default );
+			}
+
+			if ( type === "array" ) {
+				each( rgbaspace, function( key, prop ) {
+					rgba[ prop.idx ] = clamp( red[ prop.idx ], prop );
+				});
+				return this;
+			}
+
+			if ( type === "object" ) {
+				if ( red instanceof color ) {
+					each( spaces, function( spaceName, space ) {
+						if ( red[ space.cache ] ) {
+							inst[ space.cache ] = red[ space.cache ].slice();
+						}
+					});
+				} else {
+					each( spaces, function( spaceName, space ) {
+						each( space.props, function( key, prop ) {
+							var cache = space.cache;
+
+							// if the cache doesn't exist, and we know how to convert
+							if ( !inst[ cache ] && space.to ) {
+
+								// if the value was null, we don't need to copy it
+								// if the key was alpha, we don't need to copy it either
+								if ( red[ key ] == null || key === "alpha") {
+									return;
+								}
+								inst[ cache ] = space.to( inst._rgba );
+							}
+
+							// this is the only case where we allow nulls for ALL properties.
+							// call clamp with alwaysAllowEmpty
+							inst[ cache ][ prop.idx ] = clamp( red[ key ], prop, true );
+						});
+					});
+				}
+				return this;
+			}
+		},
+		is: function( compare ) {
+			var is = color( compare ),
+				same = true,
+				myself = this;
+
+			each( spaces, function( _, space ) {
+				var isCache = is[ space.cache ],
+					localCache;
+				if (isCache) {
+					localCache = myself[ space.cache ] || space.to && space.to( myself._rgba ) || [];
+					each( space.props, function( _, prop ) {
+						if ( isCache[ prop.idx ] != null ) {
+							same = ( isCache[ prop.idx ] === localCache[ prop.idx ] );
+							return same;
+						}
+					});
+				}
+				return same;
+			});
+			return same;
+		},
+		_space: function() {
+			var used = [],
+				inst = this;
+			each( spaces, function( spaceName, space ) {
+				if ( inst[ space.cache ] ) {
+					used.push( spaceName );
+				}
+			});
+			return used.pop();
+		},
+		transition: function( other, distance ) {
+			var end = color( other ),
+				spaceName = end._space(),
+				space = spaces[ spaceName ],
+				start = this[ space.cache ] || space.to( this._rgba ),
+				result = start.slice();
+
+			end = end[ space.cache ];
+			each( space.props, function( key, prop ) {
+				var index = prop.idx,
+					startValue = start[ index ],
+					endValue = end[ index ],
+					type = propTypes[ prop.type ] || {};
+
+				// if null, don't override start value
+				if ( endValue === null ) {
+					return;
+				}
+				// if null - use end
+				if ( startValue === null ) {
+					result[ index ] = endValue;
+				} else {
+					if ( type.mod ) {
+						if ( endValue - startValue > type.mod / 2 ) {
+							startValue += type.mod;
+						} else if ( startValue - endValue > type.mod / 2 ) {
+							startValue -= type.mod;
+						}
+					}
+					result[ prop.idx ] = clamp( ( endValue - startValue ) * distance + startValue, prop );
+				}
+			});
+			return this[ spaceName ]( result );
+		},
+		blend: function( opaque ) {
+			// if we are already opaque - return ourself
+			if ( this._rgba[ 3 ] === 1 ) {
+				return this;
+			}
+
+			var rgb = this._rgba.slice(),
+				a = rgb.pop(),
+				blend = color( opaque )._rgba;
+
+			return color( jQuery.map( rgb, function( v, i ) {
+				return ( 1 - a ) * blend[ i ] + a * v;
+			}));
+		},
+		toRgbaString: function() {
+			var prefix = "rgba(",
+				rgba = jQuery.map( this._rgba, function( v, i ) {
+					return v == null ? ( i > 2 ? 1 : 0 ) : v;
+				});
+
+			if ( rgba[ 3 ] === 1 ) {
+				rgba.pop();
+				prefix = "rgb(";
+			}
+
+			return prefix + rgba.join(",") + ")";
+		},
+		toHslaString: function() {
+			var prefix = "hsla(",
+				hsla = jQuery.map( this.hsla(), function( v, i ) {
+					if ( v == null ) {
+						v = i > 2 ? 1 : 0;
+					}
+
+					// catch 1 and 2
+					if ( i && i < 3 ) {
+						v = Math.round( v * 100 ) + "%";
+					}
+					return v;
+				});
+
+			if ( hsla[ 3 ] === 1 ) {
+				hsla.pop();
+				prefix = "hsl(";
+			}
+			return prefix + hsla.join(",") + ")";
+		},
+		toHexString: function( includeAlpha ) {
+			var rgba = this._rgba.slice(),
+				alpha = rgba.pop();
+
+			if ( includeAlpha ) {
+				rgba.push( ~~( alpha * 255 ) );
+			}
+
+			return "#" + jQuery.map( rgba, function( v, i ) {
+
+				// default to 0 when nulls exist
+				v = ( v || 0 ).toString( 16 );
+				return v.length === 1 ? "0" + v : v;
+			}).join("");
+		},
+		toString: function() {
+			return this._rgba[ 3 ] === 0 ? "transparent" : this.toRgbaString();
+		}
+	};
+	color.fn.parse.prototype = color.fn;
+
+	// hsla conversions adapted from:
+	// http://www.google.com/codesearch/p#OAMlx_jo-ck/src/third_party/WebKit/Source/WebCore/inspector/front-end/Color.js&d=7&l=193
+
+	function hue2rgb( p, q, h ) {
+		h = ( h + 1 ) % 1;
+		if ( h * 6 < 1 ) {
+			return p + (q - p) * 6 * h;
+		}
+		if ( h * 2 < 1) {
+			return q;
+		}
+		if ( h * 3 < 2 ) {
+			return p + (q - p) * ((2/3) - h) * 6;
+		}
+		return p;
+	}
+
+	spaces.hsla.to = function ( rgba ) {
+		if ( rgba[ 0 ] == null || rgba[ 1 ] == null || rgba[ 2 ] == null ) {
+			return [ null, null, null, rgba[ 3 ] ];
+		}
+		var r = rgba[ 0 ] / 255,
+			g = rgba[ 1 ] / 255,
+			b = rgba[ 2 ] / 255,
+			a = rgba[ 3 ],
+			max = Math.max( r, g, b ),
+			min = Math.min( r, g, b ),
+			diff = max - min,
+			add = max + min,
+			l = add * 0.5,
+			h, s;
+
+		if ( min === max ) {
+			h = 0;
+		} else if ( r === max ) {
+			h = ( 60 * ( g - b ) / diff ) + 360;
+		} else if ( g === max ) {
+			h = ( 60 * ( b - r ) / diff ) + 120;
+		} else {
+			h = ( 60 * ( r - g ) / diff ) + 240;
+		}
+
+		if ( l === 0 || l === 1 ) {
+			s = l;
+		} else if ( l <= 0.5 ) {
+			s = diff / add;
+		} else {
+			s = diff / ( 2 - add );
+		}
+		return [ Math.round(h) % 360, s, l, a == null ? 1 : a ];
+	};
+
+	spaces.hsla.from = function ( hsla ) {
+		if ( hsla[ 0 ] == null || hsla[ 1 ] == null || hsla[ 2 ] == null ) {
+			return [ null, null, null, hsla[ 3 ] ];
+		}
+		var h = hsla[ 0 ] / 360,
+			s = hsla[ 1 ],
+			l = hsla[ 2 ],
+			a = hsla[ 3 ],
+			q = l <= 0.5 ? l * ( 1 + s ) : l + s - l * s,
+			p = 2 * l - q,
+			r, g, b;
+
+		return [
+			Math.round( hue2rgb( p, q, h + ( 1 / 3 ) ) * 255 ),
+			Math.round( hue2rgb( p, q, h ) * 255 ),
+			Math.round( hue2rgb( p, q, h - ( 1 / 3 ) ) * 255 ),
+			a
+		];
+	};
+
+
+	each( spaces, function( spaceName, space ) {
+		var props = space.props,
+			cache = space.cache,
+			to = space.to,
+			from = space.from;
+
+		// makes rgba() and hsla()
+		color.fn[ spaceName ] = function( value ) {
+
+			// generate a cache for this space if it doesn't exist
+			if ( to && !this[ cache ] ) {
+				this[ cache ] = to( this._rgba );
+			}
+			if ( value === undefined ) {
+				return this[ cache ].slice();
+			}
+
+			var type = jQuery.type( value ),
+				arr = ( type === "array" || type === "object" ) ? value : arguments,
+				local = this[ cache ].slice(),
+				ret;
+
+			each( props, function( key, prop ) {
+				var val = arr[ type === "object" ? key : prop.idx ];
+				if ( val == null ) {
+					val = local[ prop.idx ];
+				}
+				local[ prop.idx ] = clamp( val, prop );
+			});
+
+			if ( from ) {
+				ret = color( from( local ) );
+				ret[ cache ] = local;
+				return ret;
+			} else {
+				return color( local );
+			}
+		};
+
+		// makes red() green() blue() alpha() hue() saturation() lightness()
+		each( props, function( key, prop ) {
+			// alpha is included in more than one space
+			if ( color.fn[ key ] ) {
+				return;
+			}
+			color.fn[ key ] = function( value ) {
+				var vtype = jQuery.type( value ),
+					fn = ( key === 'alpha' ? ( this._hsla ? 'hsla' : 'rgba' ) : spaceName ),
+					local = this[ fn ](),
+					cur = local[ prop.idx ],
+					match;
+
+				if ( vtype === "undefined" ) {
+					return cur;
+				}
+
+				if ( vtype === "function" ) {
+					value = value.call( this, cur );
+					vtype = jQuery.type( value );
+				}
+				if ( value == null && prop.empty ) {
+					return this;
+				}
+				if ( vtype === "string" ) {
+					match = rplusequals.exec( value );
+					if ( match ) {
+						value = cur + parseFloat( match[ 2 ] ) * ( match[ 1 ] === "+" ? 1 : -1 );
+					}
+				}
+				local[ prop.idx ] = value;
+				return this[ fn ]( local );
+			};
+		});
+	});
+
+	// add .fx.step functions
+	each( stepHooks, function( i, hook ) {
+		jQuery.cssHooks[ hook ] = {
+			set: function( elem, value ) {
+				var parsed, backgroundColor, curElem;
+
+				if ( jQuery.type( value ) !== 'string' || ( parsed = stringParse( value ) ) )
+				{
+					value = color( parsed || value );
+					if ( !support.rgba && value._rgba[ 3 ] !== 1 ) {
+						curElem = hook === "backgroundColor" ? elem.parentNode : elem;
+						do {
+							backgroundColor = jQuery.curCSS( curElem, "backgroundColor" );
+						} while (
+							( backgroundColor === "" || backgroundColor === "transparent" ) &&
+							( curElem = curElem.parentNode ) &&
+							curElem.style
+						);
+
+						value = value.blend( backgroundColor && backgroundColor !== "transparent" ?
+							backgroundColor :
+							"_default" );
+					}
+
+					value = value.toRgbaString();
+				}
+				elem.style[ hook ] = value;
+			}
+		};
+		jQuery.fx.step[ hook ] = function( fx ) {
+			if ( !fx.colorInit ) {
+				fx.start = color( fx.elem, hook );
+				fx.end = color( fx.end );
+				fx.colorInit = true;
+			}
+			jQuery.cssHooks[ hook ].set( fx.elem, fx.start.transition( fx.end, fx.pos ) );
+		};
+	});
+
+	// detect rgba support
+	jQuery(function() {
+		var div = document.createElement( "div" ),
+			div_style = div.style;
+
+		div_style.cssText = "background-color:rgba(1,1,1,.5)";
+		support.rgba = div_style.backgroundColor.indexOf( "rgba" ) > -1;
+	});
+
+	// Some named colors to work with
+	// From Interface by Stefan Petre
+	// http://interface.eyecon.ro/
+	colors = jQuery.Color.names = {
+		aqua: "#00ffff",
+		azure: "#f0ffff",
+		beige: "#f5f5dc",
+		black: "#000000",
+		blue: "#0000ff",
+		brown: "#a52a2a",
+		cyan: "#00ffff",
+		darkblue: "#00008b",
+		darkcyan: "#008b8b",
+		darkgrey: "#a9a9a9",
+		darkgreen: "#006400",
+		darkkhaki: "#bdb76b",
+		darkmagenta: "#8b008b",
+		darkolivegreen: "#556b2f",
+		darkorange: "#ff8c00",
+		darkorchid: "#9932cc",
+		darkred: "#8b0000",
+		darksalmon: "#e9967a",
+		darkviolet: "#9400d3",
+		fuchsia: "#ff00ff",
+		gold: "#ffd700",
+		green: "#008000",
+		indigo: "#4b0082",
+		khaki: "#f0e68c",
+		lightblue: "#add8e6",
+		lightcyan: "#e0ffff",
+		lightgreen: "#90ee90",
+		lightgrey: "#d3d3d3",
+		lightpink: "#ffb6c1",
+		lightyellow: "#ffffe0",
+		lime: "#00ff00",
+		magenta: "#ff00ff",
+		maroon: "#800000",
+		navy: "#000080",
+		olive: "#808000",
+		orange: "#ffa500",
+		pink: "#ffc0cb",
+		purple: "#800080",
+		violet: "#800080",
+		red: "#ff0000",
+		silver: "#c0c0c0",
+		white: "#ffffff",
+		yellow: "#ffff00",
+		transparent: [ null, null, null, 0 ],
+		_default: "#ffffff"
+	};
+})( jQuery );
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/lib/tapmodo-Jcrop-1902fbc/js/jquery.min.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,4 @@
+/*! jQuery v1.9.0 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license */(function(e,t){"use strict";function n(e){var t=e.length,n=st.type(e);return st.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}function r(e){var t=Tt[e]={};return st.each(e.match(lt)||[],function(e,n){t[n]=!0}),t}function i(e,n,r,i){if(st.acceptData(e)){var o,a,s=st.expando,u="string"==typeof n,l=e.nodeType,c=l?st.cache:e,f=l?e[s]:e[s]&&s;if(f&&c[f]&&(i||c[f].data)||!u||r!==t)return f||(l?e[s]=f=K.pop()||st.guid++:f=s),c[f]||(c[f]={},l||(c[f].toJSON=st.noop)),("object"==typeof n||"function"==typeof n)&&(i?c[f]=st.extend(c[f],n):c[f].data=st.extend(c[f].data,n)),o=c[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[st.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[st.camelCase(n)])):a=o,a}}function o(e,t,n){if(st.acceptData(e)){var r,i,o,a=e.nodeType,u=a?st.cache:e,l=a?e[st.expando]:st.expando;if(u[l]){if(t&&(r=n?u[l]:u[l].data)){st.isArray(t)?t=t.concat(st.map(t,st.camelCase)):t in r?t=[t]:(t=st.camelCase(t),t=t in r?[t]:t.split(" "));for(i=0,o=t.length;o>i;i++)delete r[t[i]];if(!(n?s:st.isEmptyObject)(r))return}(n||(delete u[l].data,s(u[l])))&&(a?st.cleanData([e],!0):st.support.deleteExpando||u!=u.window?delete u[l]:u[l]=null)}}}function a(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(Nt,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:wt.test(r)?st.parseJSON(r):r}catch(o){}st.data(e,n,r)}else r=t}return r}function s(e){var t;for(t in e)if(("data"!==t||!st.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}function u(){return!0}function l(){return!1}function c(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}function f(e,t,n){if(t=t||0,st.isFunction(t))return st.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return st.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=st.grep(e,function(e){return 1===e.nodeType});if(Wt.test(t))return st.filter(t,r,!n);t=st.filter(t,r)}return st.grep(e,function(e){return st.inArray(e,t)>=0===n})}function p(e){var t=zt.split("|"),n=e.createDocumentFragment();if(n.createElement)for(;t.length;)n.createElement(t.pop());return n}function d(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function h(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function g(e){var t=nn.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function m(e,t){for(var n,r=0;null!=(n=e[r]);r++)st._data(n,"globalEval",!t||st._data(t[r],"globalEval"))}function y(e,t){if(1===t.nodeType&&st.hasData(e)){var n,r,i,o=st._data(e),a=st._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)st.event.add(t,n,s[n][r])}a.data&&(a.data=st.extend({},a.data))}}function v(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!st.support.noCloneEvent&&t[st.expando]){r=st._data(t);for(i in r.events)st.removeEvent(t,i,r.handle);t.removeAttribute(st.expando)}"script"===n&&t.text!==e.text?(h(t).text=e.text,g(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),st.support.html5Clone&&e.innerHTML&&!st.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Zt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}function b(e,n){var r,i,o=0,a=e.getElementsByTagName!==t?e.getElementsByTagName(n||"*"):e.querySelectorAll!==t?e.querySelectorAll(n||"*"):t;if(!a)for(a=[],r=e.childNodes||e;null!=(i=r[o]);o++)!n||st.nodeName(i,n)?a.push(i):st.merge(a,b(i,n));return n===t||n&&st.nodeName(e,n)?st.merge([e],a):a}function x(e){Zt.test(e.type)&&(e.defaultChecked=e.checked)}function T(e,t){if(t in e)return t;for(var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Nn.length;i--;)if(t=Nn[i]+n,t in e)return t;return r}function w(e,t){return e=t||e,"none"===st.css(e,"display")||!st.contains(e.ownerDocument,e)}function N(e,t){for(var n,r=[],i=0,o=e.length;o>i;i++)n=e[i],n.style&&(r[i]=st._data(n,"olddisplay"),t?(r[i]||"none"!==n.style.display||(n.style.display=""),""===n.style.display&&w(n)&&(r[i]=st._data(n,"olddisplay",S(n.nodeName)))):r[i]||w(n)||st._data(n,"olddisplay",st.css(n,"display")));for(i=0;o>i;i++)n=e[i],n.style&&(t&&"none"!==n.style.display&&""!==n.style.display||(n.style.display=t?r[i]||"":"none"));return e}function C(e,t,n){var r=mn.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function k(e,t,n,r,i){for(var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;4>o;o+=2)"margin"===n&&(a+=st.css(e,n+wn[o],!0,i)),r?("content"===n&&(a-=st.css(e,"padding"+wn[o],!0,i)),"margin"!==n&&(a-=st.css(e,"border"+wn[o]+"Width",!0,i))):(a+=st.css(e,"padding"+wn[o],!0,i),"padding"!==n&&(a+=st.css(e,"border"+wn[o]+"Width",!0,i)));return a}function E(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=ln(e),a=st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=un(e,t,o),(0>i||null==i)&&(i=e.style[t]),yn.test(i))return i;r=a&&(st.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+k(e,t,n||(a?"border":"content"),r,o)+"px"}function S(e){var t=V,n=bn[e];return n||(n=A(e,t),"none"!==n&&n||(cn=(cn||st("<iframe frameborder='0' width='0' height='0'/>").css("cssText","display:block !important")).appendTo(t.documentElement),t=(cn[0].contentWindow||cn[0].contentDocument).document,t.write("<!doctype html><html><body>"),t.close(),n=A(e,t),cn.detach()),bn[e]=n),n}function A(e,t){var n=st(t.createElement(e)).appendTo(t.body),r=st.css(n[0],"display");return n.remove(),r}function j(e,t,n,r){var i;if(st.isArray(t))st.each(t,function(t,i){n||kn.test(e)?r(e,i):j(e+"["+("object"==typeof i?t:"")+"]",i,n,r)});else if(n||"object"!==st.type(t))r(e,t);else for(i in t)j(e+"["+i+"]",t[i],n,r)}function D(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(lt)||[];if(st.isFunction(n))for(;r=o[i++];)"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function L(e,n,r,i){function o(u){var l;return a[u]=!0,st.each(e[u]||[],function(e,u){var c=u(n,r,i);return"string"!=typeof c||s||a[c]?s?!(l=c):t:(n.dataTypes.unshift(c),o(c),!1)}),l}var a={},s=e===$n;return o(n.dataTypes[0])||!a["*"]&&o("*")}function H(e,n){var r,i,o=st.ajaxSettings.flatOptions||{};for(r in n)n[r]!==t&&((o[r]?e:i||(i={}))[r]=n[r]);return i&&st.extend(!0,e,i),e}function M(e,n,r){var i,o,a,s,u=e.contents,l=e.dataTypes,c=e.responseFields;for(o in c)o in r&&(n[c[o]]=r[o]);for(;"*"===l[0];)l.shift(),i===t&&(i=e.mimeType||n.getResponseHeader("Content-Type"));if(i)for(o in u)if(u[o]&&u[o].test(i)){l.unshift(o);break}if(l[0]in r)a=l[0];else{for(o in r){if(!l[0]||e.converters[o+" "+l[0]]){a=o;break}s||(s=o)}a=a||s}return a?(a!==l[0]&&l.unshift(a),r[a]):t}function q(e,t){var n,r,i,o,a={},s=0,u=e.dataTypes.slice(),l=u[0];if(e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u[1])for(n in e.converters)a[n.toLowerCase()]=e.converters[n];for(;i=u[++s];)if("*"!==i){if("*"!==l&&l!==i){if(n=a[l+" "+i]||a["* "+i],!n)for(r in a)if(o=r.split(" "),o[1]===i&&(n=a[l+" "+o[0]]||a["* "+o[0]])){n===!0?n=a[r]:a[r]!==!0&&(i=o[0],u.splice(s--,0,i));break}if(n!==!0)if(n&&e["throws"])t=n(t);else try{t=n(t)}catch(c){return{state:"parsererror",error:n?c:"No conversion from "+l+" to "+i}}}l=i}return{state:"success",data:t}}function _(){try{return new e.XMLHttpRequest}catch(t){}}function F(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(t){}}function O(){return setTimeout(function(){Qn=t}),Qn=st.now()}function B(e,t){st.each(t,function(t,n){for(var r=(rr[t]||[]).concat(rr["*"]),i=0,o=r.length;o>i;i++)if(r[i].call(e,t,n))return})}function P(e,t,n){var r,i,o=0,a=nr.length,s=st.Deferred().always(function(){delete u.elem}),u=function(){if(i)return!1;for(var t=Qn||O(),n=Math.max(0,l.startTime+l.duration-t),r=n/l.duration||0,o=1-r,a=0,u=l.tweens.length;u>a;a++)l.tweens[a].run(o);return s.notifyWith(e,[l,o,n]),1>o&&u?n:(s.resolveWith(e,[l]),!1)},l=s.promise({elem:e,props:st.extend({},t),opts:st.extend(!0,{specialEasing:{}},n),originalProperties:t,originalOptions:n,startTime:Qn||O(),duration:n.duration,tweens:[],createTween:function(t,n){var r=st.Tween(e,l.opts,t,n,l.opts.specialEasing[t]||l.opts.easing);return l.tweens.push(r),r},stop:function(t){var n=0,r=t?l.tweens.length:0;if(i)return this;for(i=!0;r>n;n++)l.tweens[n].run(1);return t?s.resolveWith(e,[l,t]):s.rejectWith(e,[l,t]),this}}),c=l.props;for(R(c,l.opts.specialEasing);a>o;o++)if(r=nr[o].call(l,e,c,l.opts))return r;return B(l,c),st.isFunction(l.opts.start)&&l.opts.start.call(e,l),st.fx.timer(st.extend(u,{elem:e,anim:l,queue:l.opts.queue})),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always)}function R(e,t){var n,r,i,o,a;for(n in e)if(r=st.camelCase(n),i=t[r],o=e[n],st.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),a=st.cssHooks[r],a&&"expand"in a){o=a.expand(o),delete e[r];for(n in o)n in e||(e[n]=o[n],t[n]=i)}else t[r]=i}function W(e,t,n){var r,i,o,a,s,u,l,c,f,p=this,d=e.style,h={},g=[],m=e.nodeType&&w(e);n.queue||(c=st._queueHooks(e,"fx"),null==c.unqueued&&(c.unqueued=0,f=c.empty.fire,c.empty.fire=function(){c.unqueued||f()}),c.unqueued++,p.always(function(){p.always(function(){c.unqueued--,st.queue(e,"fx").length||c.empty.fire()})})),1===e.nodeType&&("height"in t||"width"in t)&&(n.overflow=[d.overflow,d.overflowX,d.overflowY],"inline"===st.css(e,"display")&&"none"===st.css(e,"float")&&(st.support.inlineBlockNeedsLayout&&"inline"!==S(e.nodeName)?d.zoom=1:d.display="inline-block")),n.overflow&&(d.overflow="hidden",st.support.shrinkWrapBlocks||p.done(function(){d.overflow=n.overflow[0],d.overflowX=n.overflow[1],d.overflowY=n.overflow[2]}));for(r in t)if(o=t[r],Zn.exec(o)){if(delete t[r],u=u||"toggle"===o,o===(m?"hide":"show"))continue;g.push(r)}if(a=g.length){s=st._data(e,"fxshow")||st._data(e,"fxshow",{}),"hidden"in s&&(m=s.hidden),u&&(s.hidden=!m),m?st(e).show():p.done(function(){st(e).hide()}),p.done(function(){var t;st._removeData(e,"fxshow");for(t in h)st.style(e,t,h[t])});for(r=0;a>r;r++)i=g[r],l=p.createTween(i,m?s[i]:0),h[i]=s[i]||st.style(e,i),i in s||(s[i]=l.start,m&&(l.end=l.start,l.start="width"===i||"height"===i?1:0))}}function $(e,t,n,r,i){return new $.prototype.init(e,t,n,r,i)}function I(e,t){var n,r={height:e},i=0;for(t=t?1:0;4>i;i+=2-t)n=wn[i],r["margin"+n]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}function z(e){return st.isWindow(e)?e:9===e.nodeType?e.defaultView||e.parentWindow:!1}var X,U,V=e.document,Y=e.location,J=e.jQuery,G=e.$,Q={},K=[],Z="1.9.0",et=K.concat,tt=K.push,nt=K.slice,rt=K.indexOf,it=Q.toString,ot=Q.hasOwnProperty,at=Z.trim,st=function(e,t){return new st.fn.init(e,t,X)},ut=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,lt=/\S+/g,ct=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,ft=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,pt=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,dt=/^[\],:{}\s]*$/,ht=/(?:^|:|,)(?:\s*\[)+/g,gt=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,mt=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,yt=/^-ms-/,vt=/-([\da-z])/gi,bt=function(e,t){return t.toUpperCase()},xt=function(){V.addEventListener?(V.removeEventListener("DOMContentLoaded",xt,!1),st.ready()):"complete"===V.readyState&&(V.detachEvent("onreadystatechange",xt),st.ready())};st.fn=st.prototype={jquery:Z,constructor:st,init:function(e,n,r){var i,o;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:ft.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof st?n[0]:n,st.merge(this,st.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:V,!0)),pt.test(i[1])&&st.isPlainObject(n))for(i in n)st.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(o=V.getElementById(i[2]),o&&o.parentNode){if(o.id!==i[2])return r.find(e);this.length=1,this[0]=o}return this.context=V,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):st.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),st.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return nt.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=st.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return st.each(this,e,t)},ready:function(e){return st.ready.promise().done(e),this},slice:function(){return this.pushStack(nt.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(st.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:tt,sort:[].sort,splice:[].splice},st.fn.init.prototype=st.fn,st.extend=st.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||st.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(e=arguments[u]))for(n in e)r=s[n],i=e[n],s!==i&&(c&&i&&(st.isPlainObject(i)||(o=st.isArray(i)))?(o?(o=!1,a=r&&st.isArray(r)?r:[]):a=r&&st.isPlainObject(r)?r:{},s[n]=st.extend(c,a,i)):i!==t&&(s[n]=i));return s},st.extend({noConflict:function(t){return e.$===st&&(e.$=G),t&&e.jQuery===st&&(e.jQuery=J),st},isReady:!1,readyWait:1,holdReady:function(e){e?st.readyWait++:st.ready(!0)},ready:function(e){if(e===!0?!--st.readyWait:!st.isReady){if(!V.body)return setTimeout(st.ready);st.isReady=!0,e!==!0&&--st.readyWait>0||(U.resolveWith(V,[st]),st.fn.trigger&&st(V).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===st.type(e)},isArray:Array.isArray||function(e){return"array"===st.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?Q[it.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==st.type(e)||e.nodeType||st.isWindow(e))return!1;try{if(e.constructor&&!ot.call(e,"constructor")&&!ot.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||ot.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||V;var r=pt.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=st.buildFragment([e],t,i),i&&st(i).remove(),st.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=st.trim(n),n&&dt.test(n.replace(gt,"@").replace(mt,"]").replace(ht,"")))?Function("return "+n)():(st.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||st.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&st.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(yt,"ms-").replace(vt,bt)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,r){var i,o=0,a=e.length,s=n(e);if(r){if(s)for(;a>o&&(i=t.apply(e[o],r),i!==!1);o++);else for(o in e)if(i=t.apply(e[o],r),i===!1)break}else if(s)for(;a>o&&(i=t.call(e[o],o,e[o]),i!==!1);o++);else for(o in e)if(i=t.call(e[o],o,e[o]),i===!1)break;return e},trim:at&&!at.call("\ufeff\u00a0")?function(e){return null==e?"":at.call(e)}:function(e){return null==e?"":(e+"").replace(ct,"")},makeArray:function(e,t){var r=t||[];return null!=e&&(n(Object(e))?st.merge(r,"string"==typeof e?[e]:e):tt.call(r,e)),r},inArray:function(e,t,n){var r;if(t){if(rt)return rt.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else for(;n[o]!==t;)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,r){var i,o=0,a=e.length,s=n(e),u=[];if(s)for(;a>o;o++)i=t(e[o],o,r),null!=i&&(u[u.length]=i);else for(o in e)i=t(e[o],o,r),null!=i&&(u[u.length]=i);return et.apply([],u)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(r=e[n],n=e,e=r),st.isFunction(e)?(i=nt.call(arguments,2),o=function(){return e.apply(n||this,i.concat(nt.call(arguments)))},o.guid=e.guid=e.guid||st.guid++,o):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===st.type(r)){o=!0;for(u in r)st.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,st.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(st(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),st.ready.promise=function(t){if(!U)if(U=st.Deferred(),"complete"===V.readyState)setTimeout(st.ready);else if(V.addEventListener)V.addEventListener("DOMContentLoaded",xt,!1),e.addEventListener("load",st.ready,!1);else{V.attachEvent("onreadystatechange",xt),e.attachEvent("onload",st.ready);var n=!1;try{n=null==e.frameElement&&V.documentElement}catch(r){}n&&n.doScroll&&function i(){if(!st.isReady){try{n.doScroll("left")}catch(e){return setTimeout(i,50)}st.ready()}}()}return U.promise(t)},st.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){Q["[object "+t+"]"]=t.toLowerCase()}),X=st(V);var Tt={};st.Callbacks=function(e){e="string"==typeof e?Tt[e]||r(e):st.extend({},e);var n,i,o,a,s,u,l=[],c=!e.once&&[],f=function(t){for(n=e.memory&&t,i=!0,u=a||0,a=0,s=l.length,o=!0;l&&s>u;u++)if(l[u].apply(t[0],t[1])===!1&&e.stopOnFalse){n=!1;break}o=!1,l&&(c?c.length&&f(c.shift()):n?l=[]:p.disable())},p={add:function(){if(l){var t=l.length;(function r(t){st.each(t,function(t,n){var i=st.type(n);"function"===i?e.unique&&p.has(n)||l.push(n):n&&n.length&&"string"!==i&&r(n)})})(arguments),o?s=l.length:n&&(a=t,f(n))}return this},remove:function(){return l&&st.each(arguments,function(e,t){for(var n;(n=st.inArray(t,l,n))>-1;)l.splice(n,1),o&&(s>=n&&s--,u>=n&&u--)}),this},has:function(e){return st.inArray(e,l)>-1},empty:function(){return l=[],this},disable:function(){return l=c=n=t,this},disabled:function(){return!l},lock:function(){return c=t,n||p.disable(),this},locked:function(){return!c},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!l||i&&!c||(o?c.push(t):f(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},st.extend({Deferred:function(e){var t=[["resolve","done",st.Callbacks("once memory"),"resolved"],["reject","fail",st.Callbacks("once memory"),"rejected"],["notify","progress",st.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return st.Deferred(function(n){st.each(t,function(t,o){var a=o[0],s=st.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&st.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?st.extend(e,r):r}},i={};return r.pipe=r.then,st.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t,n,r,i=0,o=nt.call(arguments),a=o.length,s=1!==a||e&&st.isFunction(e.promise)?a:0,u=1===s?e:st.Deferred(),l=function(e,n,r){return function(i){n[e]=this,r[e]=arguments.length>1?nt.call(arguments):i,r===t?u.notifyWith(n,r):--s||u.resolveWith(n,r)}};if(a>1)for(t=Array(a),n=Array(a),r=Array(a);a>i;i++)o[i]&&st.isFunction(o[i].promise)?o[i].promise().done(l(i,r,o)).fail(u.reject).progress(l(i,n,t)):--s;return s||u.resolveWith(r,o),u.promise()}}),st.support=function(){var n,r,i,o,a,s,u,l,c,f,p=V.createElement("div");if(p.setAttribute("className","t"),p.innerHTML="  <link/><table></table><a href='/a'>a</a><input type='checkbox'/>",r=p.getElementsByTagName("*"),i=p.getElementsByTagName("a")[0],!r||!i||!r.length)return{};o=V.createElement("select"),a=o.appendChild(V.createElement("option")),s=p.getElementsByTagName("input")[0],i.style.cssText="top:1px;float:left;opacity:.5",n={getSetAttribute:"t"!==p.className,leadingWhitespace:3===p.firstChild.nodeType,tbody:!p.getElementsByTagName("tbody").length,htmlSerialize:!!p.getElementsByTagName("link").length,style:/top/.test(i.getAttribute("style")),hrefNormalized:"/a"===i.getAttribute("href"),opacity:/^0.5/.test(i.style.opacity),cssFloat:!!i.style.cssFloat,checkOn:!!s.value,optSelected:a.selected,enctype:!!V.createElement("form").enctype,html5Clone:"<:nav></:nav>"!==V.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===V.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},s.checked=!0,n.noCloneChecked=s.cloneNode(!0).checked,o.disabled=!0,n.optDisabled=!a.disabled;try{delete p.test}catch(d){n.deleteExpando=!1}s=V.createElement("input"),s.setAttribute("value",""),n.input=""===s.getAttribute("value"),s.value="t",s.setAttribute("type","radio"),n.radioValue="t"===s.value,s.setAttribute("checked","t"),s.setAttribute("name","t"),u=V.createDocumentFragment(),u.appendChild(s),n.appendChecked=s.checked,n.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,p.attachEvent&&(p.attachEvent("onclick",function(){n.noCloneEvent=!1}),p.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})p.setAttribute(l="on"+f,"t"),n[f+"Bubbles"]=l in e||p.attributes[l].expando===!1;return p.style.backgroundClip="content-box",p.cloneNode(!0).style.backgroundClip="",n.clearCloneStyle="content-box"===p.style.backgroundClip,st(function(){var r,i,o,a="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",s=V.getElementsByTagName("body")[0];s&&(r=V.createElement("div"),r.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",s.appendChild(r).appendChild(p),p.innerHTML="<table><tr><td></td><td>t</td></tr></table>",o=p.getElementsByTagName("td"),o[0].style.cssText="padding:0;margin:0;border:0;display:none",c=0===o[0].offsetHeight,o[0].style.display="",o[1].style.display="none",n.reliableHiddenOffsets=c&&0===o[0].offsetHeight,p.innerHTML="",p.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",n.boxSizing=4===p.offsetWidth,n.doesNotIncludeMarginInBodyOffset=1!==s.offsetTop,e.getComputedStyle&&(n.pixelPosition="1%"!==(e.getComputedStyle(p,null)||{}).top,n.boxSizingReliable="4px"===(e.getComputedStyle(p,null)||{width:"4px"}).width,i=p.appendChild(V.createElement("div")),i.style.cssText=p.style.cssText=a,i.style.marginRight=i.style.width="0",p.style.width="1px",n.reliableMarginRight=!parseFloat((e.getComputedStyle(i,null)||{}).marginRight)),p.style.zoom!==t&&(p.innerHTML="",p.style.cssText=a+"width:1px;padding:1px;display:inline;zoom:1",n.inlineBlockNeedsLayout=3===p.offsetWidth,p.style.display="block",p.innerHTML="<div></div>",p.firstChild.style.width="5px",n.shrinkWrapBlocks=3!==p.offsetWidth,s.style.zoom=1),s.removeChild(r),r=p=o=i=null)}),r=o=u=a=i=s=null,n}();var wt=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,Nt=/([A-Z])/g;st.extend({cache:{},expando:"jQuery"+(Z+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?st.cache[e[st.expando]]:e[st.expando],!!e&&!s(e)},data:function(e,t,n){return i(e,t,n,!1)},removeData:function(e,t){return o(e,t,!1)},_data:function(e,t,n){return i(e,t,n,!0)},_removeData:function(e,t){return o(e,t,!0)},acceptData:function(e){var t=e.nodeName&&st.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),st.fn.extend({data:function(e,n){var r,i,o=this[0],s=0,u=null;if(e===t){if(this.length&&(u=st.data(o),1===o.nodeType&&!st._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>s;s++)i=r[s].name,i.indexOf("data-")||(i=st.camelCase(i.substring(5)),a(o,i,u[i]));st._data(o,"parsedAttrs",!0)}return u}return"object"==typeof e?this.each(function(){st.data(this,e)}):st.access(this,function(n){return n===t?o?a(o,e,st.data(o,e)):null:(this.each(function(){st.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){st.removeData(this,e)})}}),st.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=st._data(e,n),r&&(!i||st.isArray(r)?i=st._data(e,n,st.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=st.queue(e,t),r=n.length,i=n.shift(),o=st._queueHooks(e,t),a=function(){st.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return st._data(e,n)||st._data(e,n,{empty:st.Callbacks("once memory").add(function(){st._removeData(e,t+"queue"),st._removeData(e,n)})})}}),st.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?st.queue(this[0],e):n===t?this:this.each(function(){var t=st.queue(this,e,n);st._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&st.dequeue(this,e)})},dequeue:function(e){return this.each(function(){st.dequeue(this,e)})},delay:function(e,t){return e=st.fx?st.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=st.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};for("string"!=typeof e&&(n=e,e=t),e=e||"fx";s--;)r=st._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var Ct,kt,Et=/[\t\r\n]/g,St=/\r/g,At=/^(?:input|select|textarea|button|object)$/i,jt=/^(?:a|area)$/i,Dt=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,Lt=/^(?:checked|selected)$/i,Ht=st.support.getSetAttribute,Mt=st.support.input;st.fn.extend({attr:function(e,t){return st.access(this,st.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){st.removeAttr(this,e)})},prop:function(e,t){return st.access(this,st.prop,e,t,arguments.length>1)},removeProp:function(e){return e=st.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(st.isFunction(e))return this.each(function(t){st(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(lt)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(Et," "):" ")){for(o=0;i=t[o++];)0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=st.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(st.isFunction(e))return this.each(function(t){st(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(lt)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(Et," "):"")){for(o=0;i=t[o++];)for(;r.indexOf(" "+i+" ")>=0;)r=r.replace(" "+i+" "," ");n.className=e?st.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return st.isFunction(e)?this.each(function(n){st(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n)for(var i,o=0,a=st(this),s=t,u=e.match(lt)||[];i=u[o++];)s=r?s:!a.hasClass(i),a[s?"addClass":"removeClass"](i);else("undefined"===n||"boolean"===n)&&(this.className&&st._data(this,"__className__",this.className),this.className=this.className||e===!1?"":st._data(this,"__className__")||"")})},hasClass:function(e){for(var t=" "+e+" ",n=0,r=this.length;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(Et," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=st.isFunction(e),this.each(function(r){var o,a=st(this);1===this.nodeType&&(o=i?e.call(this,r,a.val()):e,null==o?o="":"number"==typeof o?o+="":st.isArray(o)&&(o=st.map(o,function(e){return null==e?"":e+""})),n=st.valHooks[this.type]||st.valHooks[this.nodeName.toLowerCase()],n&&"set"in n&&n.set(this,o,"value")!==t||(this.value=o))});if(o)return n=st.valHooks[o.type]||st.valHooks[o.nodeName.toLowerCase()],n&&"get"in n&&(r=n.get(o,"value"))!==t?r:(r=o.value,"string"==typeof r?r.replace(St,""):null==r?"":r)}}}),st.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){for(var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(st.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&st.nodeName(n.parentNode,"optgroup"))){if(t=st(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=st.makeArray(t);return st(e).find("option").each(function(){this.selected=st.inArray(st(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return e.getAttribute===t?st.prop(e,n,r):(a=1!==s||!st.isXMLDoc(e),a&&(n=n.toLowerCase(),o=st.attrHooks[n]||(Dt.test(n)?kt:Ct)),r===t?o&&a&&"get"in o&&null!==(i=o.get(e,n))?i:(e.getAttribute!==t&&(i=e.getAttribute(n)),null==i?t:i):null!==r?o&&a&&"set"in o&&(i=o.set(e,r,n))!==t?i:(e.setAttribute(n,r+""),r):(st.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(lt);if(o&&1===e.nodeType)for(;n=o[i++];)r=st.propFix[n]||n,Dt.test(n)?!Ht&&Lt.test(n)?e[st.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:st.attr(e,n,""),e.removeAttribute(Ht?n:r)},attrHooks:{type:{set:function(e,t){if(!st.support.radioValue&&"radio"===t&&st.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!st.isXMLDoc(e),a&&(n=st.propFix[n]||n,o=st.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):At.test(e.nodeName)||jt.test(e.nodeName)&&e.href?0:t}}}}),kt={get:function(e,n){var r=st.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?Mt&&Ht?null!=i:Lt.test(n)?e[st.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?st.removeAttr(e,n):Mt&&Ht||!Lt.test(n)?e.setAttribute(!Ht&&st.propFix[n]||n,n):e[st.camelCase("default-"+n)]=e[n]=!0,n}},Mt&&Ht||(st.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return st.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t
+},set:function(e,n,r){return st.nodeName(e,"input")?(e.defaultValue=n,t):Ct&&Ct.set(e,n,r)}}),Ht||(Ct=st.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},st.attrHooks.contenteditable={get:Ct.get,set:function(e,t,n){Ct.set(e,""===t?!1:t,n)}},st.each(["width","height"],function(e,n){st.attrHooks[n]=st.extend(st.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),st.support.hrefNormalized||(st.each(["href","src","width","height"],function(e,n){st.attrHooks[n]=st.extend(st.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),st.each(["href","src"],function(e,t){st.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),st.support.style||(st.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),st.support.optSelected||(st.propHooks.selected=st.extend(st.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),st.support.enctype||(st.propFix.enctype="encoding"),st.support.checkOn||st.each(["radio","checkbox"],function(){st.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),st.each(["radio","checkbox"],function(){st.valHooks[this]=st.extend(st.valHooks[this],{set:function(e,n){return st.isArray(n)?e.checked=st.inArray(st(e).val(),n)>=0:t}})});var qt=/^(?:input|select|textarea)$/i,_t=/^key/,Ft=/^(?:mouse|contextmenu)|click/,Ot=/^(?:focusinfocus|focusoutblur)$/,Bt=/^([^.]*)(?:\.(.+)|)$/;st.event={global:{},add:function(e,n,r,i,o){var a,s,u,l,c,f,p,d,h,g,m,y=3!==e.nodeType&&8!==e.nodeType&&st._data(e);if(y){for(r.handler&&(a=r,r=a.handler,o=a.selector),r.guid||(r.guid=st.guid++),(l=y.events)||(l=y.events={}),(s=y.handle)||(s=y.handle=function(e){return st===t||e&&st.event.triggered===e.type?t:st.event.dispatch.apply(s.elem,arguments)},s.elem=e),n=(n||"").match(lt)||[""],c=n.length;c--;)u=Bt.exec(n[c])||[],h=m=u[1],g=(u[2]||"").split(".").sort(),p=st.event.special[h]||{},h=(o?p.delegateType:p.bindType)||h,p=st.event.special[h]||{},f=st.extend({type:h,origType:m,data:i,handler:r,guid:r.guid,selector:o,needsContext:o&&st.expr.match.needsContext.test(o),namespace:g.join(".")},a),(d=l[h])||(d=l[h]=[],d.delegateCount=0,p.setup&&p.setup.call(e,i,g,s)!==!1||(e.addEventListener?e.addEventListener(h,s,!1):e.attachEvent&&e.attachEvent("on"+h,s))),p.add&&(p.add.call(e,f),f.handler.guid||(f.handler.guid=r.guid)),o?d.splice(d.delegateCount++,0,f):d.push(f),st.event.global[h]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,m=st.hasData(e)&&st._data(e);if(m&&(u=m.events)){for(t=(t||"").match(lt)||[""],l=t.length;l--;)if(s=Bt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){for(f=st.event.special[d]||{},d=(r?f.delegateType:f.bindType)||d,p=u[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;o--;)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&f.teardown.call(e,h,m.handle)!==!1||st.removeEvent(e,d,m.handle),delete u[d])}else for(d in u)st.event.remove(e,d+t[l],n,r,!0);st.isEmptyObject(u)&&(delete m.handle,st._removeData(e,"events"))}},trigger:function(n,r,i,o){var a,s,u,l,c,f,p,d=[i||V],h=n.type||n,g=n.namespace?n.namespace.split("."):[];if(s=u=i=i||V,3!==i.nodeType&&8!==i.nodeType&&!Ot.test(h+st.event.triggered)&&(h.indexOf(".")>=0&&(g=h.split("."),h=g.shift(),g.sort()),c=0>h.indexOf(":")&&"on"+h,n=n[st.expando]?n:new st.Event(h,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=g.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:st.makeArray(r,[n]),p=st.event.special[h]||{},o||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!o&&!p.noBubble&&!st.isWindow(i)){for(l=p.delegateType||h,Ot.test(l+h)||(s=s.parentNode);s;s=s.parentNode)d.push(s),u=s;u===(i.ownerDocument||V)&&d.push(u.defaultView||u.parentWindow||e)}for(a=0;(s=d[a++])&&!n.isPropagationStopped();)n.type=a>1?l:p.bindType||h,f=(st._data(s,"events")||{})[n.type]&&st._data(s,"handle"),f&&f.apply(s,r),f=c&&s[c],f&&st.acceptData(s)&&f.apply&&f.apply(s,r)===!1&&n.preventDefault();if(n.type=h,!(o||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===h&&st.nodeName(i,"a")||!st.acceptData(i)||!c||!i[h]||st.isWindow(i))){u=i[c],u&&(i[c]=null),st.event.triggered=h;try{i[h]()}catch(m){}st.event.triggered=t,u&&(i[c]=u)}return n.result}},dispatch:function(e){e=st.event.fix(e);var n,r,i,o,a,s=[],u=nt.call(arguments),l=(st._data(this,"events")||{})[e.type]||[],c=st.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){for(s=st.event.handlers.call(this,e,l),n=0;(o=s[n++])&&!e.isPropagationStopped();)for(e.currentTarget=o.elem,r=0;(a=o.handlers[r++])&&!e.isImmediatePropagationStopped();)(!e.namespace_re||e.namespace_re.test(a.namespace))&&(e.handleObj=a,e.data=a.data,i=((st.event.special[a.origType]||{}).handle||a.handler).apply(o.elem,u),i!==t&&(e.result=i)===!1&&(e.preventDefault(),e.stopPropagation()));return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(l.disabled!==!0||"click"!==e.type){for(i=[],r=0;u>r;r++)a=n[r],o=a.selector+" ",i[o]===t&&(i[o]=a.needsContext?st(o,this).index(l)>=0:st.find(o,this,null,[l]).length),i[o]&&i.push(a);i.length&&s.push({elem:l,handlers:i})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[st.expando])return e;var t,n,r=e,i=st.event.fixHooks[e.type]||{},o=i.props?this.props.concat(i.props):this.props;for(e=new st.Event(r),t=o.length;t--;)n=o[t],e[n]=r[n];return e.target||(e.target=r.srcElement||V),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,i.filter?i.filter(e,r):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,o,a=n.button,s=n.fromElement;return null==e.pageX&&null!=n.clientX&&(r=e.target.ownerDocument||V,i=r.documentElement,o=r.body,e.pageX=n.clientX+(i&&i.scrollLeft||o&&o.scrollLeft||0)-(i&&i.clientLeft||o&&o.clientLeft||0),e.pageY=n.clientY+(i&&i.scrollTop||o&&o.scrollTop||0)-(i&&i.clientTop||o&&o.clientTop||0)),!e.relatedTarget&&s&&(e.relatedTarget=s===e.target?n.toElement:s),e.which||a===t||(e.which=1&a?1:2&a?3:4&a?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return st.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==V.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===V.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=st.extend(new st.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?st.event.trigger(i,null,t):st.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},st.removeEvent=V.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,n,r){var i="on"+n;e.detachEvent&&(e[i]===t&&(e[i]=null),e.detachEvent(i,r))},st.Event=function(e,n){return this instanceof st.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?u:l):this.type=e,n&&st.extend(this,n),this.timeStamp=e&&e.timeStamp||st.now(),this[st.expando]=!0,t):new st.Event(e,n)},st.Event.prototype={isDefaultPrevented:l,isPropagationStopped:l,isImmediatePropagationStopped:l,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=u,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=u,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=u,this.stopPropagation()}},st.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){st.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!st.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),st.support.submitBubbles||(st.event.special.submit={setup:function(){return st.nodeName(this,"form")?!1:(st.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=st.nodeName(n,"input")||st.nodeName(n,"button")?n.form:t;r&&!st._data(r,"submitBubbles")&&(st.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),st._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&st.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return st.nodeName(this,"form")?!1:(st.event.remove(this,"._submit"),t)}}),st.support.changeBubbles||(st.event.special.change={setup:function(){return qt.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(st.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),st.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),st.event.simulate("change",this,e,!0)})),!1):(st.event.add(this,"beforeactivate._change",function(e){var t=e.target;qt.test(t.nodeName)&&!st._data(t,"changeBubbles")&&(st.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||st.event.simulate("change",this.parentNode,e,!0)}),st._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return st.event.remove(this,"._change"),!qt.test(this.nodeName)}}),st.support.focusinBubbles||st.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){st.event.simulate(t,e.target,st.event.fix(e),!0)};st.event.special[t]={setup:function(){0===n++&&V.addEventListener(e,r,!0)},teardown:function(){0===--n&&V.removeEventListener(e,r,!0)}}}),st.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(s in e)this.on(s,n,r,e[s],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=l;else if(!i)return this;return 1===o&&(a=i,i=function(e){return st().off(e),a.apply(this,arguments)},i.guid=a.guid||(a.guid=st.guid++)),this.each(function(){st.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,st(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=l),this.each(function(){st.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){st.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?st.event.trigger(e,n,r,!0):t},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),st.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(e,t){st.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)},_t.test(t)&&(st.event.fixHooks[t]=st.event.keyHooks),Ft.test(t)&&(st.event.fixHooks[t]=st.event.mouseHooks)}),function(e,t){function n(e){return ht.test(e+"")}function r(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>C.cacheLength&&delete e[t.shift()],e[n]=r}}function i(e){return e[P]=!0,e}function o(e){var t=L.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function a(e,t,n,r){var i,o,a,s,u,l,c,d,h,g;if((t?t.ownerDocument||t:R)!==L&&D(t),t=t||L,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!M&&!r){if(i=gt.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&O(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return Q.apply(n,K.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&W.getByClassName&&t.getElementsByClassName)return Q.apply(n,K.call(t.getElementsByClassName(a),0)),n}if(W.qsa&&!q.test(e)){if(c=!0,d=P,h=t,g=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){for(l=f(e),(c=t.getAttribute("id"))?d=c.replace(vt,"\\$&"):t.setAttribute("id",d),d="[id='"+d+"'] ",u=l.length;u--;)l[u]=d+p(l[u]);h=dt.test(e)&&t.parentNode||t,g=l.join(",")}if(g)try{return Q.apply(n,K.call(h.querySelectorAll(g),0)),n}catch(m){}finally{c||t.removeAttribute("id")}}}return x(e.replace(at,"$1"),t,n,r)}function s(e,t){for(var n=e&&t&&e.nextSibling;n;n=n.nextSibling)if(n===t)return-1;return e?1:-1}function u(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function l(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function c(e){return i(function(t){return t=+t,i(function(n,r){for(var i,o=e([],n.length,t),a=o.length;a--;)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function f(e,t){var n,r,i,o,s,u,l,c=X[e+" "];if(c)return t?0:c.slice(0);for(s=e,u=[],l=C.preFilter;s;){(!n||(r=ut.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(i=[])),n=!1,(r=lt.exec(s))&&(n=r.shift(),i.push({value:n,type:r[0].replace(at," ")}),s=s.slice(n.length));for(o in C.filter)!(r=pt[o].exec(s))||l[o]&&!(r=l[o](r))||(n=r.shift(),i.push({value:n,type:o,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?a.error(e):X(e,u).slice(0)}function p(e){for(var t=0,n=e.length,r="";n>t;t++)r+=e[t].value;return r}function d(e,t,n){var r=t.dir,i=n&&"parentNode"===t.dir,o=I++;return t.first?function(t,n,o){for(;t=t[r];)if(1===t.nodeType||i)return e(t,n,o)}:function(t,n,a){var s,u,l,c=$+" "+o;if(a){for(;t=t[r];)if((1===t.nodeType||i)&&e(t,n,a))return!0}else for(;t=t[r];)if(1===t.nodeType||i)if(l=t[P]||(t[P]={}),(u=l[r])&&u[0]===c){if((s=u[1])===!0||s===N)return s===!0}else if(u=l[r]=[c],u[1]=e(t,n,a)||N,u[1]===!0)return!0}}function h(e){return e.length>1?function(t,n,r){for(var i=e.length;i--;)if(!e[i](t,n,r))return!1;return!0}:e[0]}function g(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function m(e,t,n,r,o,a){return r&&!r[P]&&(r=m(r)),o&&!o[P]&&(o=m(o,a)),i(function(i,a,s,u){var l,c,f,p=[],d=[],h=a.length,m=i||b(t||"*",s.nodeType?[s]:s,[]),y=!e||!i&&t?m:g(m,p,e,s,u),v=n?o||(i?e:h||r)?[]:a:y;if(n&&n(y,v,s,u),r)for(l=g(v,d),r(l,[],s,u),c=l.length;c--;)(f=l[c])&&(v[d[c]]=!(y[d[c]]=f));if(i){if(o||e){if(o){for(l=[],c=v.length;c--;)(f=v[c])&&l.push(y[c]=f);o(null,v=[],l,u)}for(c=v.length;c--;)(f=v[c])&&(l=o?Z.call(i,f):p[c])>-1&&(i[l]=!(a[l]=f))}}else v=g(v===a?v.splice(h,v.length):v),o?o(null,a,v,u):Q.apply(a,v)})}function y(e){for(var t,n,r,i=e.length,o=C.relative[e[0].type],a=o||C.relative[" "],s=o?1:0,u=d(function(e){return e===t},a,!0),l=d(function(e){return Z.call(t,e)>-1},a,!0),c=[function(e,n,r){return!o&&(r||n!==j)||((t=n).nodeType?u(e,n,r):l(e,n,r))}];i>s;s++)if(n=C.relative[e[s].type])c=[d(h(c),n)];else{if(n=C.filter[e[s].type].apply(null,e[s].matches),n[P]){for(r=++s;i>r&&!C.relative[e[r].type];r++);return m(s>1&&h(c),s>1&&p(e.slice(0,s-1)).replace(at,"$1"),n,r>s&&y(e.slice(s,r)),i>r&&y(e=e.slice(r)),i>r&&p(e))}c.push(n)}return h(c)}function v(e,t){var n=0,r=t.length>0,o=e.length>0,s=function(i,s,u,l,c){var f,p,d,h=[],m=0,y="0",v=i&&[],b=null!=c,x=j,T=i||o&&C.find.TAG("*",c&&s.parentNode||s),w=$+=null==x?1:Math.E;for(b&&(j=s!==L&&s,N=n);null!=(f=T[y]);y++){if(o&&f){for(p=0;d=e[p];p++)if(d(f,s,u)){l.push(f);break}b&&($=w,N=++n)}r&&((f=!d&&f)&&m--,i&&v.push(f))}if(m+=y,r&&y!==m){for(p=0;d=t[p];p++)d(v,h,s,u);if(i){if(m>0)for(;y--;)v[y]||h[y]||(h[y]=G.call(l));h=g(h)}Q.apply(l,h),b&&!i&&h.length>0&&m+t.length>1&&a.uniqueSort(l)}return b&&($=w,j=x),v};return r?i(s):s}function b(e,t,n){for(var r=0,i=t.length;i>r;r++)a(e,t[r],n);return n}function x(e,t,n,r){var i,o,a,s,u,l=f(e);if(!r&&1===l.length){if(o=l[0]=l[0].slice(0),o.length>2&&"ID"===(a=o[0]).type&&9===t.nodeType&&!M&&C.relative[o[1].type]){if(t=C.find.ID(a.matches[0].replace(xt,Tt),t)[0],!t)return n;e=e.slice(o.shift().value.length)}for(i=pt.needsContext.test(e)?-1:o.length-1;i>=0&&(a=o[i],!C.relative[s=a.type]);i--)if((u=C.find[s])&&(r=u(a.matches[0].replace(xt,Tt),dt.test(o[0].type)&&t.parentNode||t))){if(o.splice(i,1),e=r.length&&p(o),!e)return Q.apply(n,K.call(r,0)),n;break}}return S(e,l)(r,t,M,n,dt.test(e)),n}function T(){}var w,N,C,k,E,S,A,j,D,L,H,M,q,_,F,O,B,P="sizzle"+-new Date,R=e.document,W={},$=0,I=0,z=r(),X=r(),U=r(),V=typeof t,Y=1<<31,J=[],G=J.pop,Q=J.push,K=J.slice,Z=J.indexOf||function(e){for(var t=0,n=this.length;n>t;t++)if(this[t]===e)return t;return-1},et="[\\x20\\t\\r\\n\\f]",tt="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",nt=tt.replace("w","w#"),rt="([*^$|!~]?=)",it="\\["+et+"*("+tt+")"+et+"*(?:"+rt+et+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+nt+")|)|)"+et+"*\\]",ot=":("+tt+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+it.replace(3,8)+")*)|.*)\\)|)",at=RegExp("^"+et+"+|((?:^|[^\\\\])(?:\\\\.)*)"+et+"+$","g"),ut=RegExp("^"+et+"*,"+et+"*"),lt=RegExp("^"+et+"*([\\x20\\t\\r\\n\\f>+~])"+et+"*"),ct=RegExp(ot),ft=RegExp("^"+nt+"$"),pt={ID:RegExp("^#("+tt+")"),CLASS:RegExp("^\\.("+tt+")"),NAME:RegExp("^\\[name=['\"]?("+tt+")['\"]?\\]"),TAG:RegExp("^("+tt.replace("w","w*")+")"),ATTR:RegExp("^"+it),PSEUDO:RegExp("^"+ot),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+et+"*(even|odd|(([+-]|)(\\d*)n|)"+et+"*(?:([+-]|)"+et+"*(\\d+)|))"+et+"*\\)|)","i"),needsContext:RegExp("^"+et+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+et+"*((?:-\\d)?\\d*)"+et+"*\\)|)(?=[^-]|$)","i")},dt=/[\x20\t\r\n\f]*[+~]/,ht=/\{\s*\[native code\]\s*\}/,gt=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,mt=/^(?:input|select|textarea|button)$/i,yt=/^h\d$/i,vt=/'|\\/g,bt=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,xt=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,Tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{K.call(H.childNodes,0)[0].nodeType}catch(wt){K=function(e){for(var t,n=[];t=this[e];e++)n.push(t);return n}}E=a.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},D=a.setDocument=function(e){var r=e?e.ownerDocument||e:R;return r!==L&&9===r.nodeType&&r.documentElement?(L=r,H=r.documentElement,M=E(r),W.tagNameNoComments=o(function(e){return e.appendChild(r.createComment("")),!e.getElementsByTagName("*").length}),W.attributes=o(function(e){e.innerHTML="<select></select>";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),W.getByClassName=o(function(e){return e.innerHTML="<div class='hidden e'></div><div class='hidden'></div>",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),W.getByName=o(function(e){e.id=P+0,e.innerHTML="<a name='"+P+"'></a><div name='"+P+"'></div>",H.insertBefore(e,H.firstChild);var t=r.getElementsByName&&r.getElementsByName(P).length===2+r.getElementsByName(P+0).length;return W.getIdNotName=!r.getElementById(P),H.removeChild(e),t}),C.attrHandle=o(function(e){return e.innerHTML="<a href='#'></a>",e.firstChild&&typeof e.firstChild.getAttribute!==V&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},W.getIdNotName?(C.find.ID=function(e,t){if(typeof t.getElementById!==V&&!M){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},C.filter.ID=function(e){var t=e.replace(xt,Tt);return function(e){return e.getAttribute("id")===t}}):(C.find.ID=function(e,n){if(typeof n.getElementById!==V&&!M){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==V&&r.getAttributeNode("id").value===e?[r]:t:[]}},C.filter.ID=function(e){var t=e.replace(xt,Tt);return function(e){var n=typeof e.getAttributeNode!==V&&e.getAttributeNode("id");return n&&n.value===t}}),C.find.TAG=W.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==V?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){for(;n=o[i];i++)1===n.nodeType&&r.push(n);return r}return o},C.find.NAME=W.getByName&&function(e,n){return typeof n.getElementsByName!==V?n.getElementsByName(name):t},C.find.CLASS=W.getByClassName&&function(e,n){return typeof n.getElementsByClassName===V||M?t:n.getElementsByClassName(e)},_=[],q=[":focus"],(W.qsa=n(r.querySelectorAll))&&(o(function(e){e.innerHTML="<select><option selected=''></option></select>",e.querySelectorAll("[selected]").length||q.push("\\["+et+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||q.push(":checked")}),o(function(e){e.innerHTML="<input type='hidden' i=''/>",e.querySelectorAll("[i^='']").length&&q.push("[*^$]="+et+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),q.push(",.*:")})),(W.matchesSelector=n(F=H.matchesSelector||H.mozMatchesSelector||H.webkitMatchesSelector||H.oMatchesSelector||H.msMatchesSelector))&&o(function(e){W.disconnectedMatch=F.call(e,"div"),F.call(e,"[s!='']:x"),_.push("!=",ot)}),q=RegExp(q.join("|")),_=RegExp(_.join("|")),O=n(H.contains)||H.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},B=H.compareDocumentPosition?function(e,t){var n;return e===t?(A=!0,0):(n=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&n||e.parentNode&&11===e.parentNode.nodeType?e===r||O(R,e)?-1:t===r||O(R,t)?1:0:4&n?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var n,i=0,o=e.parentNode,a=t.parentNode,u=[e],l=[t];if(e===t)return A=!0,0;if(e.sourceIndex&&t.sourceIndex)return(~t.sourceIndex||Y)-(O(R,e)&&~e.sourceIndex||Y);if(!o||!a)return e===r?-1:t===r?1:o?-1:a?1:0;if(o===a)return s(e,t);for(n=e;n=n.parentNode;)u.unshift(n);for(n=t;n=n.parentNode;)l.unshift(n);for(;u[i]===l[i];)i++;return i?s(u[i],l[i]):u[i]===R?-1:l[i]===R?1:0},A=!1,[0,0].sort(B),W.detectDuplicates=A,L):L},a.matches=function(e,t){return a(e,null,null,t)},a.matchesSelector=function(e,t){if((e.ownerDocument||e)!==L&&D(e),t=t.replace(bt,"='$1']"),!(!W.matchesSelector||M||_&&_.test(t)||q.test(t)))try{var n=F.call(e,t);if(n||W.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return a(t,L,null,[e]).length>0},a.contains=function(e,t){return(e.ownerDocument||e)!==L&&D(e),O(e,t)},a.attr=function(e,t){var n;return(e.ownerDocument||e)!==L&&D(e),M||(t=t.toLowerCase()),(n=C.attrHandle[t])?n(e):M||W.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},a.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},a.uniqueSort=function(e){var t,n=[],r=1,i=0;if(A=!W.detectDuplicates,e.sort(B),A){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));for(;i--;)e.splice(n[i],1)}return e},k=a.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=k(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=k(t);return n},C=a.selectors={cacheLength:50,createPseudo:i,match:pt,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(xt,Tt),e[3]=(e[4]||e[5]||"").replace(xt,Tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||a.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&a.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return pt.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&ct.test(n)&&(t=f(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(xt,Tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=z[e+" "];return t||(t=RegExp("(^|"+et+")"+e+"("+et+"|$)"))&&z(e,function(e){return t.test(e.className||typeof e.getAttribute!==V&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=a.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.substr(i.length-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.substr(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){for(;g;){for(f=t;f=f[g];)if(s?f.nodeName.toLowerCase()===y:1===f.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){for(c=m[P]||(m[P]={}),l=c[e]||[],d=l[0]===$&&l[1],p=l[0]===$&&l[2],f=d&&m.childNodes[d];f=++d&&f&&f[g]||(p=d=0)||h.pop();)if(1===f.nodeType&&++p&&f===t){c[e]=[$,d,p];break}}else if(v&&(l=(t[P]||(t[P]={}))[e])&&l[0]===$)p=l[1];else for(;(f=++d&&f&&f[g]||(p=d=0)||h.pop())&&((s?f.nodeName.toLowerCase()!==y:1!==f.nodeType)||!++p||(v&&((f[P]||(f[P]={}))[e]=[$,p]),f!==t)););return p-=i,p===r||0===p%r&&p/r>=0}}},PSEUDO:function(e,t){var n,r=C.pseudos[e]||C.setFilters[e.toLowerCase()]||a.error("unsupported pseudo: "+e);return r[P]?r(t):r.length>1?(n=[e,e,"",t],C.setFilters.hasOwnProperty(e.toLowerCase())?i(function(e,n){for(var i,o=r(e,t),a=o.length;a--;)i=Z.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:i(function(e){var t=[],n=[],r=S(e.replace(at,"$1"));return r[P]?i(function(e,t,n,i){for(var o,a=r(e,null,i,[]),s=e.length;s--;)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:i(function(e){return function(t){return a(e,t).length>0}}),contains:i(function(e){return function(t){return(t.textContent||t.innerText||k(t)).indexOf(e)>-1}}),lang:i(function(e){return ft.test(e||"")||a.error("unsupported lang: "+e),e=e.replace(xt,Tt).toLowerCase(),function(t){var n;do if(n=M?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===H},focus:function(e){return e===L.activeElement&&(!L.hasFocus||L.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!C.pseudos.empty(e)},header:function(e){return yt.test(e.nodeName)},input:function(e){return mt.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:c(function(){return[0]}),last:c(function(e,t){return[t-1]}),eq:c(function(e,t,n){return[0>n?n+t:n]}),even:c(function(e,t){for(var n=0;t>n;n+=2)e.push(n);return e}),odd:c(function(e,t){for(var n=1;t>n;n+=2)e.push(n);return e}),lt:c(function(e,t,n){for(var r=0>n?n+t:n;--r>=0;)e.push(r);return e}),gt:c(function(e,t,n){for(var r=0>n?n+t:n;t>++r;)e.push(r);return e})}};for(w in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})C.pseudos[w]=u(w);for(w in{submit:!0,reset:!0})C.pseudos[w]=l(w);S=a.compile=function(e,t){var n,r=[],i=[],o=U[e+" "];if(!o){for(t||(t=f(e)),n=t.length;n--;)o=y(t[n]),o[P]?r.push(o):i.push(o);o=U(e,v(i,r))}return o},C.pseudos.nth=C.pseudos.eq,C.filters=T.prototype=C.pseudos,C.setFilters=new T,D(),a.attr=st.attr,st.find=a,st.expr=a.selectors,st.expr[":"]=st.expr.pseudos,st.unique=a.uniqueSort,st.text=a.getText,st.isXMLDoc=a.isXML,st.contains=a.contains}(e);var Pt=/Until$/,Rt=/^(?:parents|prev(?:Until|All))/,Wt=/^.[^:#\[\.,]*$/,$t=st.expr.match.needsContext,It={children:!0,contents:!0,next:!0,prev:!0};st.fn.extend({find:function(e){var t,n,r;if("string"!=typeof e)return r=this,this.pushStack(st(e).filter(function(){for(t=0;r.length>t;t++)if(st.contains(r[t],this))return!0}));for(n=[],t=0;this.length>t;t++)st.find(e,this[t],n);return n=this.pushStack(st.unique(n)),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=st(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(st.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(f(this,e,!1))},filter:function(e){return this.pushStack(f(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?$t.test(e)?st(e,this.context).index(this[0])>=0:st.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){for(var n,r=0,i=this.length,o=[],a=$t.test(e)||"string"!=typeof e?st(e,t||this.context):0;i>r;r++)for(n=this[r];n&&n.ownerDocument&&n!==t&&11!==n.nodeType;){if(a?a.index(n)>-1:st.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}return this.pushStack(o.length>1?st.unique(o):o)},index:function(e){return e?"string"==typeof e?st.inArray(this[0],st(e)):st.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?st(e,t):st.makeArray(e&&e.nodeType?[e]:e),r=st.merge(this.get(),n);return this.pushStack(st.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),st.fn.andSelf=st.fn.addBack,st.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return st.dir(e,"parentNode")},parentsUntil:function(e,t,n){return st.dir(e,"parentNode",n)},next:function(e){return c(e,"nextSibling")},prev:function(e){return c(e,"previousSibling")
+},nextAll:function(e){return st.dir(e,"nextSibling")},prevAll:function(e){return st.dir(e,"previousSibling")},nextUntil:function(e,t,n){return st.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return st.dir(e,"previousSibling",n)},siblings:function(e){return st.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return st.sibling(e.firstChild)},contents:function(e){return st.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:st.merge([],e.childNodes)}},function(e,t){st.fn[e]=function(n,r){var i=st.map(this,t,n);return Pt.test(e)||(r=n),r&&"string"==typeof r&&(i=st.filter(r,i)),i=this.length>1&&!It[e]?st.unique(i):i,this.length>1&&Rt.test(e)&&(i=i.reverse()),this.pushStack(i)}}),st.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?st.find.matchesSelector(t[0],e)?[t[0]]:[]:st.find.matches(e,t)},dir:function(e,n,r){for(var i=[],o=e[n];o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!st(o).is(r));)1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});var zt="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",Xt=/ jQuery\d+="(?:null|\d+)"/g,Ut=RegExp("<(?:"+zt+")[\\s/>]","i"),Vt=/^\s+/,Yt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,Jt=/<([\w:]+)/,Gt=/<tbody/i,Qt=/<|&#?\w+;/,Kt=/<(?:script|style|link)/i,Zt=/^(?:checkbox|radio)$/i,en=/checked\s*(?:[^=]|=\s*.checked.)/i,tn=/^$|\/(?:java|ecma)script/i,nn=/^true\/(.*)/,rn=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,on={option:[1,"<select multiple='multiple'>","</select>"],legend:[1,"<fieldset>","</fieldset>"],area:[1,"<map>","</map>"],param:[1,"<object>","</object>"],thead:[1,"<table>","</table>"],tr:[2,"<table><tbody>","</tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:st.support.htmlSerialize?[0,"",""]:[1,"X<div>","</div>"]},an=p(V),sn=an.appendChild(V.createElement("div"));on.optgroup=on.option,on.tbody=on.tfoot=on.colgroup=on.caption=on.thead,on.th=on.td,st.fn.extend({text:function(e){return st.access(this,function(e){return e===t?st.text(this):this.empty().append((this[0]&&this[0].ownerDocument||V).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(st.isFunction(e))return this.each(function(t){st(this).wrapAll(e.call(this,t))});if(this[0]){var t=st(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){for(var e=this;e.firstChild&&1===e.firstChild.nodeType;)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return st.isFunction(e)?this.each(function(t){st(this).wrapInner(e.call(this,t))}):this.each(function(){var t=st(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=st.isFunction(e);return this.each(function(n){st(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){st.nodeName(this,"body")||st(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){for(var n,r=0;null!=(n=this[r]);r++)(!e||st.filter(e,[n]).length>0)&&(t||1!==n.nodeType||st.cleanData(b(n)),n.parentNode&&(t&&st.contains(n.ownerDocument,n)&&m(b(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){for(var e,t=0;null!=(e=this[t]);t++){for(1===e.nodeType&&st.cleanData(b(e,!1));e.firstChild;)e.removeChild(e.firstChild);e.options&&st.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return st.clone(this,e,t)})},html:function(e){return st.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(Xt,""):t;if(!("string"!=typeof e||Kt.test(e)||!st.support.htmlSerialize&&Ut.test(e)||!st.support.leadingWhitespace&&Vt.test(e)||on[(Jt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(Yt,"<$1></$2>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(st.cleanData(b(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=st.isFunction(e);return t||"string"==typeof e||(e=st(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;(n&&1===this.nodeType||11===this.nodeType)&&(st(this).remove(),t?t.parentNode.insertBefore(e,t):n.appendChild(e))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=et.apply([],e);var i,o,a,s,u,l,c=0,f=this.length,p=this,m=f-1,y=e[0],v=st.isFunction(y);if(v||!(1>=f||"string"!=typeof y||st.support.checkClone)&&en.test(y))return this.each(function(i){var o=p.eq(i);v&&(e[0]=y.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(f&&(i=st.buildFragment(e,this[0].ownerDocument,!1,this),o=i.firstChild,1===i.childNodes.length&&(i=o),o)){for(n=n&&st.nodeName(o,"tr"),a=st.map(b(i,"script"),h),s=a.length;f>c;c++)u=i,c!==m&&(u=st.clone(u,!0,!0),s&&st.merge(a,b(u,"script"))),r.call(n&&st.nodeName(this[c],"table")?d(this[c],"tbody"):this[c],u,c);if(s)for(l=a[a.length-1].ownerDocument,st.map(a,g),c=0;s>c;c++)u=a[c],tn.test(u.type||"")&&!st._data(u,"globalEval")&&st.contains(l,u)&&(u.src?st.ajax({url:u.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):st.globalEval((u.text||u.textContent||u.innerHTML||"").replace(rn,"")));i=o=null}return this}}),st.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){st.fn[e]=function(e){for(var n,r=0,i=[],o=st(e),a=o.length-1;a>=r;r++)n=r===a?this:this.clone(!0),st(o[r])[t](n),tt.apply(i,n.get());return this.pushStack(i)}}),st.extend({clone:function(e,t,n){var r,i,o,a,s,u=st.contains(e.ownerDocument,e);if(st.support.html5Clone||st.isXMLDoc(e)||!Ut.test("<"+e.nodeName+">")?s=e.cloneNode(!0):(sn.innerHTML=e.outerHTML,sn.removeChild(s=sn.firstChild)),!(st.support.noCloneEvent&&st.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||st.isXMLDoc(e)))for(r=b(s),i=b(e),a=0;null!=(o=i[a]);++a)r[a]&&v(o,r[a]);if(t)if(n)for(i=i||b(e),r=r||b(s),a=0;null!=(o=i[a]);a++)y(o,r[a]);else y(e,s);return r=b(s,"script"),r.length>0&&m(r,!u&&b(e,"script")),r=i=o=null,s},buildFragment:function(e,t,n,r){for(var i,o,a,s,u,l,c,f=e.length,d=p(t),h=[],g=0;f>g;g++)if(o=e[g],o||0===o)if("object"===st.type(o))st.merge(h,o.nodeType?[o]:o);else if(Qt.test(o)){for(s=s||d.appendChild(t.createElement("div")),a=(Jt.exec(o)||["",""])[1].toLowerCase(),u=on[a]||on._default,s.innerHTML=u[1]+o.replace(Yt,"<$1></$2>")+u[2],c=u[0];c--;)s=s.lastChild;if(!st.support.leadingWhitespace&&Vt.test(o)&&h.push(t.createTextNode(Vt.exec(o)[0])),!st.support.tbody)for(o="table"!==a||Gt.test(o)?"<table>"!==u[1]||Gt.test(o)?0:s:s.firstChild,c=o&&o.childNodes.length;c--;)st.nodeName(l=o.childNodes[c],"tbody")&&!l.childNodes.length&&o.removeChild(l);for(st.merge(h,s.childNodes),s.textContent="";s.firstChild;)s.removeChild(s.firstChild);s=d.lastChild}else h.push(t.createTextNode(o));for(s&&d.removeChild(s),st.support.appendChecked||st.grep(b(h,"input"),x),g=0;o=h[g++];)if((!r||-1===st.inArray(o,r))&&(i=st.contains(o.ownerDocument,o),s=b(d.appendChild(o),"script"),i&&m(s),n))for(c=0;o=s[c++];)tn.test(o.type||"")&&n.push(o);return s=null,d},cleanData:function(e,n){for(var r,i,o,a,s=0,u=st.expando,l=st.cache,c=st.support.deleteExpando,f=st.event.special;null!=(o=e[s]);s++)if((n||st.acceptData(o))&&(i=o[u],r=i&&l[i])){if(r.events)for(a in r.events)f[a]?st.event.remove(o,a):st.removeEvent(o,a,r.handle);l[i]&&(delete l[i],c?delete o[u]:o.removeAttribute!==t?o.removeAttribute(u):o[u]=null,K.push(i))}}});var un,ln,cn,fn=/alpha\([^)]*\)/i,pn=/opacity\s*=\s*([^)]*)/,dn=/^(top|right|bottom|left)$/,hn=/^(none|table(?!-c[ea]).+)/,gn=/^margin/,mn=RegExp("^("+ut+")(.*)$","i"),yn=RegExp("^("+ut+")(?!px)[a-z%]+$","i"),vn=RegExp("^([+-])=("+ut+")","i"),bn={BODY:"block"},xn={position:"absolute",visibility:"hidden",display:"block"},Tn={letterSpacing:0,fontWeight:400},wn=["Top","Right","Bottom","Left"],Nn=["Webkit","O","Moz","ms"];st.fn.extend({css:function(e,n){return st.access(this,function(e,n,r){var i,o,a={},s=0;if(st.isArray(n)){for(i=ln(e),o=n.length;o>s;s++)a[n[s]]=st.css(e,n[s],!1,i);return a}return r!==t?st.style(e,n,r):st.css(e,n)},e,n,arguments.length>1)},show:function(){return N(this,!0)},hide:function(){return N(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:w(this))?st(this).show():st(this).hide()})}}),st.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=un(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":st.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=st.camelCase(n),l=e.style;if(n=st.cssProps[u]||(st.cssProps[u]=T(l,u)),s=st.cssHooks[n]||st.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=vn.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(st.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||st.cssNumber[u]||(r+="px"),st.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=st.camelCase(n);return n=st.cssProps[u]||(st.cssProps[u]=T(e.style,u)),s=st.cssHooks[n]||st.cssHooks[u],s&&"get"in s&&(o=s.get(e,!0,r)),o===t&&(o=un(e,n,i)),"normal"===o&&n in Tn&&(o=Tn[n]),r?(a=parseFloat(o),r===!0||st.isNumeric(a)?a||0:o):o},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(ln=function(t){return e.getComputedStyle(t,null)},un=function(e,n,r){var i,o,a,s=r||ln(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||st.contains(e.ownerDocument,e)||(u=st.style(e,n)),yn.test(u)&&gn.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):V.documentElement.currentStyle&&(ln=function(e){return e.currentStyle},un=function(e,n,r){var i,o,a,s=r||ln(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),yn.test(u)&&!dn.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u}),st.each(["height","width"],function(e,n){st.cssHooks[n]={get:function(e,r,i){return r?0===e.offsetWidth&&hn.test(st.css(e,"display"))?st.swap(e,xn,function(){return E(e,n,i)}):E(e,n,i):t},set:function(e,t,r){var i=r&&ln(e);return C(e,t,r?k(e,n,r,st.support.boxSizing&&"border-box"===st.css(e,"boxSizing",!1,i),i):0)}}}),st.support.opacity||(st.cssHooks.opacity={get:function(e,t){return pn.test((t&&e.currentStyle?e.currentStyle.filter:e.style.filter)||"")?.01*parseFloat(RegExp.$1)+"":t?"1":""},set:function(e,t){var n=e.style,r=e.currentStyle,i=st.isNumeric(t)?"alpha(opacity="+100*t+")":"",o=r&&r.filter||n.filter||"";n.zoom=1,(t>=1||""===t)&&""===st.trim(o.replace(fn,""))&&n.removeAttribute&&(n.removeAttribute("filter"),""===t||r&&!r.filter)||(n.filter=fn.test(o)?o.replace(fn,i):o+" "+i)}}),st(function(){st.support.reliableMarginRight||(st.cssHooks.marginRight={get:function(e,n){return n?st.swap(e,{display:"inline-block"},un,[e,"marginRight"]):t}}),!st.support.pixelPosition&&st.fn.position&&st.each(["top","left"],function(e,n){st.cssHooks[n]={get:function(e,r){return r?(r=un(e,n),yn.test(r)?st(e).position()[n]+"px":r):t}}})}),st.expr&&st.expr.filters&&(st.expr.filters.hidden=function(e){return 0===e.offsetWidth&&0===e.offsetHeight||!st.support.reliableHiddenOffsets&&"none"===(e.style&&e.style.display||st.css(e,"display"))},st.expr.filters.visible=function(e){return!st.expr.filters.hidden(e)}),st.each({margin:"",padding:"",border:"Width"},function(e,t){st.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];4>r;r++)i[e+wn[r]+t]=o[r]||o[r-2]||o[0];return i}},gn.test(e)||(st.cssHooks[e+t].set=C)});var Cn=/%20/g,kn=/\[\]$/,En=/\r?\n/g,Sn=/^(?:submit|button|image|reset)$/i,An=/^(?:input|select|textarea|keygen)/i;st.fn.extend({serialize:function(){return st.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=st.prop(this,"elements");return e?st.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!st(this).is(":disabled")&&An.test(this.nodeName)&&!Sn.test(e)&&(this.checked||!Zt.test(e))}).map(function(e,t){var n=st(this).val();return null==n?null:st.isArray(n)?st.map(n,function(e){return{name:t.name,value:e.replace(En,"\r\n")}}):{name:t.name,value:n.replace(En,"\r\n")}}).get()}}),st.param=function(e,n){var r,i=[],o=function(e,t){t=st.isFunction(t)?t():null==t?"":t,i[i.length]=encodeURIComponent(e)+"="+encodeURIComponent(t)};if(n===t&&(n=st.ajaxSettings&&st.ajaxSettings.traditional),st.isArray(e)||e.jquery&&!st.isPlainObject(e))st.each(e,function(){o(this.name,this.value)});else for(r in e)j(r,e[r],n,o);return i.join("&").replace(Cn,"+")};var jn,Dn,Ln=st.now(),Hn=/\?/,Mn=/#.*$/,qn=/([?&])_=[^&]*/,_n=/^(.*?):[ \t]*([^\r\n]*)\r?$/gm,Fn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,On=/^(?:GET|HEAD)$/,Bn=/^\/\//,Pn=/^([\w.+-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/,Rn=st.fn.load,Wn={},$n={},In="*/".concat("*");try{Dn=Y.href}catch(zn){Dn=V.createElement("a"),Dn.href="",Dn=Dn.href}jn=Pn.exec(Dn.toLowerCase())||[],st.fn.load=function(e,n,r){if("string"!=typeof e&&Rn)return Rn.apply(this,arguments);var i,o,a,s=this,u=e.indexOf(" ");return u>=0&&(i=e.slice(u,e.length),e=e.slice(0,u)),st.isFunction(n)?(r=n,n=t):n&&"object"==typeof n&&(o="POST"),s.length>0&&st.ajax({url:e,type:o,dataType:"html",data:n}).done(function(e){a=arguments,s.html(i?st("<div>").append(st.parseHTML(e)).find(i):e)}).complete(r&&function(e,t){s.each(r,a||[e.responseText,t,e])}),this},st.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){st.fn[t]=function(e){return this.on(t,e)}}),st.each(["get","post"],function(e,n){st[n]=function(e,r,i,o){return st.isFunction(r)&&(o=o||i,i=r,r=t),st.ajax({url:e,type:n,dataType:o,data:r,success:i})}}),st.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Dn,type:"GET",isLocal:Fn.test(jn[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":In,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":e.String,"text html":!0,"text json":st.parseJSON,"text xml":st.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?H(H(e,st.ajaxSettings),t):H(st.ajaxSettings,e)},ajaxPrefilter:D(Wn),ajaxTransport:D($n),ajax:function(e,n){function r(e,n,r,s){var l,f,v,b,T,N=n;2!==x&&(x=2,u&&clearTimeout(u),i=t,a=s||"",w.readyState=e>0?4:0,r&&(b=M(p,w,r)),e>=200&&300>e||304===e?(p.ifModified&&(T=w.getResponseHeader("Last-Modified"),T&&(st.lastModified[o]=T),T=w.getResponseHeader("etag"),T&&(st.etag[o]=T)),304===e?(l=!0,N="notmodified"):(l=q(p,b),N=l.state,f=l.data,v=l.error,l=!v)):(v=N,(e||!N)&&(N="error",0>e&&(e=0))),w.status=e,w.statusText=(n||N)+"",l?g.resolveWith(d,[f,N,w]):g.rejectWith(d,[w,N,v]),w.statusCode(y),y=t,c&&h.trigger(l?"ajaxSuccess":"ajaxError",[w,p,l?f:v]),m.fireWith(d,[w,N]),c&&(h.trigger("ajaxComplete",[w,p]),--st.active||st.event.trigger("ajaxStop")))}"object"==typeof e&&(n=e,e=t),n=n||{};var i,o,a,s,u,l,c,f,p=st.ajaxSetup({},n),d=p.context||p,h=p.context&&(d.nodeType||d.jquery)?st(d):st.event,g=st.Deferred(),m=st.Callbacks("once memory"),y=p.statusCode||{},v={},b={},x=0,T="canceled",w={readyState:0,getResponseHeader:function(e){var t;if(2===x){if(!s)for(s={};t=_n.exec(a);)s[t[1].toLowerCase()]=t[2];t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===x?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return x||(e=b[n]=b[n]||e,v[e]=t),this},overrideMimeType:function(e){return x||(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(2>x)for(t in e)y[t]=[y[t],e[t]];else w.always(e[w.status]);return this},abort:function(e){var t=e||T;return i&&i.abort(t),r(0,t),this}};if(g.promise(w).complete=m.add,w.success=w.done,w.error=w.fail,p.url=((e||p.url||Dn)+"").replace(Mn,"").replace(Bn,jn[1]+"//"),p.type=n.method||n.type||p.method||p.type,p.dataTypes=st.trim(p.dataType||"*").toLowerCase().match(lt)||[""],null==p.crossDomain&&(l=Pn.exec(p.url.toLowerCase()),p.crossDomain=!(!l||l[1]===jn[1]&&l[2]===jn[2]&&(l[3]||("http:"===l[1]?80:443))==(jn[3]||("http:"===jn[1]?80:443)))),p.data&&p.processData&&"string"!=typeof p.data&&(p.data=st.param(p.data,p.traditional)),L(Wn,p,n,w),2===x)return w;c=p.global,c&&0===st.active++&&st.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!On.test(p.type),o=p.url,p.hasContent||(p.data&&(o=p.url+=(Hn.test(o)?"&":"?")+p.data,delete p.data),p.cache===!1&&(p.url=qn.test(o)?o.replace(qn,"$1_="+Ln++):o+(Hn.test(o)?"&":"?")+"_="+Ln++)),p.ifModified&&(st.lastModified[o]&&w.setRequestHeader("If-Modified-Since",st.lastModified[o]),st.etag[o]&&w.setRequestHeader("If-None-Match",st.etag[o])),(p.data&&p.hasContent&&p.contentType!==!1||n.contentType)&&w.setRequestHeader("Content-Type",p.contentType),w.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+In+"; q=0.01":""):p.accepts["*"]);for(f in p.headers)w.setRequestHeader(f,p.headers[f]);if(p.beforeSend&&(p.beforeSend.call(d,w,p)===!1||2===x))return w.abort();T="abort";for(f in{success:1,error:1,complete:1})w[f](p[f]);if(i=L($n,p,n,w)){w.readyState=1,c&&h.trigger("ajaxSend",[w,p]),p.async&&p.timeout>0&&(u=setTimeout(function(){w.abort("timeout")},p.timeout));try{x=1,i.send(v,r)}catch(N){if(!(2>x))throw N;r(-1,N)}}else r(-1,"No Transport");return w},getScript:function(e,n){return st.get(e,t,n,"script")},getJSON:function(e,t,n){return st.get(e,t,n,"json")}}),st.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(e){return st.globalEval(e),e}}}),st.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),st.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=V.head||st("head")[0]||V.documentElement;return{send:function(t,i){n=V.createElement("script"),n.async=!0,e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,t){(t||!n.readyState||/loaded|complete/.test(n.readyState))&&(n.onload=n.onreadystatechange=null,n.parentNode&&n.parentNode.removeChild(n),n=null,t||i(200,"success"))},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(t,!0)}}}});var Xn=[],Un=/(=)\?(?=&|$)|\?\?/;st.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xn.pop()||st.expando+"_"+Ln++;return this[e]=!0,e}}),st.ajaxPrefilter("json jsonp",function(n,r,i){var o,a,s,u=n.jsonp!==!1&&(Un.test(n.url)?"url":"string"==typeof n.data&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Un.test(n.data)&&"data");return u||"jsonp"===n.dataTypes[0]?(o=n.jsonpCallback=st.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,u?n[u]=n[u].replace(Un,"$1"+o):n.jsonp!==!1&&(n.url+=(Hn.test(n.url)?"&":"?")+n.jsonp+"="+o),n.converters["script json"]=function(){return s||st.error(o+" was not called"),s[0]},n.dataTypes[0]="json",a=e[o],e[o]=function(){s=arguments},i.always(function(){e[o]=a,n[o]&&(n.jsonpCallback=r.jsonpCallback,Xn.push(o)),s&&st.isFunction(a)&&a(s[0]),s=a=t}),"script"):t});var Vn,Yn,Jn=0,Gn=e.ActiveXObject&&function(){var e;for(e in Vn)Vn[e](t,!0)};st.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&_()||F()}:_,Yn=st.ajaxSettings.xhr(),st.support.cors=!!Yn&&"withCredentials"in Yn,Yn=st.support.ajax=!!Yn,Yn&&st.ajaxTransport(function(n){if(!n.crossDomain||st.support.cors){var r;return{send:function(i,o){var a,s,u=n.xhr();if(n.username?u.open(n.type,n.url,n.async,n.username,n.password):u.open(n.type,n.url,n.async),n.xhrFields)for(s in n.xhrFields)u[s]=n.xhrFields[s];n.mimeType&&u.overrideMimeType&&u.overrideMimeType(n.mimeType),n.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");try{for(s in i)u.setRequestHeader(s,i[s])}catch(l){}u.send(n.hasContent&&n.data||null),r=function(e,i){var s,l,c,f,p;try{if(r&&(i||4===u.readyState))if(r=t,a&&(u.onreadystatechange=st.noop,Gn&&delete Vn[a]),i)4!==u.readyState&&u.abort();else{f={},s=u.status,p=u.responseXML,c=u.getAllResponseHeaders(),p&&p.documentElement&&(f.xml=p),"string"==typeof u.responseText&&(f.text=u.responseText);try{l=u.statusText}catch(d){l=""}s||!n.isLocal||n.crossDomain?1223===s&&(s=204):s=f.text?200:404}}catch(h){i||o(-1,h)}f&&o(s,l,f,c)},n.async?4===u.readyState?setTimeout(r):(a=++Jn,Gn&&(Vn||(Vn={},st(e).unload(Gn)),Vn[a]=r),u.onreadystatechange=r):r()},abort:function(){r&&r(t,!0)}}}});var Qn,Kn,Zn=/^(?:toggle|show|hide)$/,er=RegExp("^(?:([+-])=|)("+ut+")([a-z%]*)$","i"),tr=/queueHooks$/,nr=[W],rr={"*":[function(e,t){var n,r,i=this.createTween(e,t),o=er.exec(t),a=i.cur(),s=+a||0,u=1,l=20;if(o){if(n=+o[2],r=o[3]||(st.cssNumber[e]?"":"px"),"px"!==r&&s){s=st.css(i.elem,e,!0)||n||1;do u=u||".5",s/=u,st.style(i.elem,e,s+r);while(u!==(u=i.cur()/a)&&1!==u&&--l)}i.unit=r,i.start=s,i.end=o[1]?s+(o[1]+1)*n:n}return i}]};st.Animation=st.extend(P,{tweener:function(e,t){st.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");for(var n,r=0,i=e.length;i>r;r++)n=e[r],rr[n]=rr[n]||[],rr[n].unshift(t)},prefilter:function(e,t){t?nr.unshift(e):nr.push(e)}}),st.Tween=$,$.prototype={constructor:$,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||"swing",this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(st.cssNumber[n]?"":"px")},cur:function(){var e=$.propHooks[this.prop];return e&&e.get?e.get(this):$.propHooks._default.get(this)},run:function(e){var t,n=$.propHooks[this.prop];return this.pos=t=this.options.duration?st.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):$.propHooks._default.set(this),this}},$.prototype.init.prototype=$.prototype,$.propHooks={_default:{get:function(e){var t;return null==e.elem[e.prop]||e.elem.style&&null!=e.elem.style[e.prop]?(t=st.css(e.elem,e.prop,"auto"),t&&"auto"!==t?t:0):e.elem[e.prop]},set:function(e){st.fx.step[e.prop]?st.fx.step[e.prop](e):e.elem.style&&(null!=e.elem.style[st.cssProps[e.prop]]||st.cssHooks[e.prop])?st.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},$.propHooks.scrollTop=$.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},st.each(["toggle","show","hide"],function(e,t){var n=st.fn[t];st.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(I(t,!0),e,r,i)}}),st.fn.extend({fadeTo:function(e,t,n,r){return this.filter(w).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=st.isEmptyObject(e),o=st.speed(t,n,r),a=function(){var t=P(this,st.extend({},e),o);a.finish=function(){t.stop(!0)},(i||st._data(this,"finish"))&&t.stop(!0)};return a.finish=a,i||o.queue===!1?this.each(a):this.queue(o.queue,a)},stop:function(e,n,r){var i=function(e){var t=e.stop;delete e.stop,t(r)};return"string"!=typeof e&&(r=n,n=e,e=t),n&&e!==!1&&this.queue(e||"fx",[]),this.each(function(){var t=!0,n=null!=e&&e+"queueHooks",o=st.timers,a=st._data(this);if(n)a[n]&&a[n].stop&&i(a[n]);else for(n in a)a[n]&&a[n].stop&&tr.test(n)&&i(a[n]);for(n=o.length;n--;)o[n].elem!==this||null!=e&&o[n].queue!==e||(o[n].anim.stop(r),t=!1,o.splice(n,1));(t||!r)&&st.dequeue(this,e)})},finish:function(e){return e!==!1&&(e=e||"fx"),this.each(function(){var t,n=st._data(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=st.timers,a=r?r.length:0;for(n.finish=!0,st.queue(this,e,[]),i&&i.cur&&i.cur.finish&&i.cur.finish.call(this),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;a>t;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}}),st.each({slideDown:I("show"),slideUp:I("hide"),slideToggle:I("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){st.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),st.speed=function(e,t,n){var r=e&&"object"==typeof e?st.extend({},e):{complete:n||!n&&t||st.isFunction(e)&&e,duration:e,easing:n&&t||t&&!st.isFunction(t)&&t};return r.duration=st.fx.off?0:"number"==typeof r.duration?r.duration:r.duration in st.fx.speeds?st.fx.speeds[r.duration]:st.fx.speeds._default,(null==r.queue||r.queue===!0)&&(r.queue="fx"),r.old=r.complete,r.complete=function(){st.isFunction(r.old)&&r.old.call(this),r.queue&&st.dequeue(this,r.queue)},r},st.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2}},st.timers=[],st.fx=$.prototype.init,st.fx.tick=function(){var e,n=st.timers,r=0;for(Qn=st.now();n.length>r;r++)e=n[r],e()||n[r]!==e||n.splice(r--,1);n.length||st.fx.stop(),Qn=t},st.fx.timer=function(e){e()&&st.timers.push(e)&&st.fx.start()},st.fx.interval=13,st.fx.start=function(){Kn||(Kn=setInterval(st.fx.tick,st.fx.interval))},st.fx.stop=function(){clearInterval(Kn),Kn=null},st.fx.speeds={slow:600,fast:200,_default:400},st.fx.step={},st.expr&&st.expr.filters&&(st.expr.filters.animated=function(e){return st.grep(st.timers,function(t){return e===t.elem}).length}),st.fn.offset=function(e){if(arguments.length)return e===t?this:this.each(function(t){st.offset.setOffset(this,e,t)});var n,r,i={top:0,left:0},o=this[0],a=o&&o.ownerDocument;if(a)return n=a.documentElement,st.contains(n,o)?(o.getBoundingClientRect!==t&&(i=o.getBoundingClientRect()),r=z(a),{top:i.top+(r.pageYOffset||n.scrollTop)-(n.clientTop||0),left:i.left+(r.pageXOffset||n.scrollLeft)-(n.clientLeft||0)}):i},st.offset={setOffset:function(e,t,n){var r=st.css(e,"position");"static"===r&&(e.style.position="relative");var i,o,a=st(e),s=a.offset(),u=st.css(e,"top"),l=st.css(e,"left"),c=("absolute"===r||"fixed"===r)&&st.inArray("auto",[u,l])>-1,f={},p={};c?(p=a.position(),i=p.top,o=p.left):(i=parseFloat(u)||0,o=parseFloat(l)||0),st.isFunction(t)&&(t=t.call(e,n,s)),null!=t.top&&(f.top=t.top-s.top+i),null!=t.left&&(f.left=t.left-s.left+o),"using"in t?t.using.call(e,f):a.css(f)}},st.fn.extend({position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===st.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),st.nodeName(e[0],"html")||(n=e.offset()),n.top+=st.css(e[0],"borderTopWidth",!0),n.left+=st.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-st.css(r,"marginTop",!0),left:t.left-n.left-st.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent||V.documentElement;e&&!st.nodeName(e,"html")&&"static"===st.css(e,"position");)e=e.offsetParent;return e||V.documentElement})}}),st.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);st.fn[e]=function(i){return st.access(this,function(e,i,o){var a=z(e);return o===t?a?n in a?a[n]:a.document.documentElement[i]:e[i]:(a?a.scrollTo(r?st(a).scrollLeft():o,r?o:st(a).scrollTop()):e[i]=o,t)},e,i,arguments.length,null)}}),st.each({Height:"height",Width:"width"},function(e,n){st.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){st.fn[i]=function(i,o){var a=arguments.length&&(r||"boolean"!=typeof i),s=r||(i===!0||o===!0?"margin":"border");return st.access(this,function(n,r,i){var o;return st.isWindow(n)?n.document.documentElement["client"+e]:9===n.nodeType?(o=n.documentElement,Math.max(n.body["scroll"+e],o["scroll"+e],n.body["offset"+e],o["offset"+e],o["client"+e])):i===t?st.css(n,r,s):st.style(n,r,i,s)},n,a?i:t,a,null)}})}),e.jQuery=e.$=st,"function"==typeof define&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return st})})(window);
+//@ sourceMappingURL=jquery.min.map
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-collector-client.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,49 @@
+<link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+
+<dom-module id="light9-collector-client">
+  <template>
+    <iron-ajax url="/collector/attrs" method="PUT" id="put"></iron-ajax>
+    <span>{{status}} ([[sent]] sent)</span>
+  </template>
+  <script>
+   Polymer({
+       is: "light9-collector-client",
+       properties: {
+           status: {type: String, value: 'init'},
+           clientSession: {value: ""+Date.now()},
+           self: {type: Object, notify: true},
+           sent: {type: Number, value: 0},
+       },
+       ready: function() {
+           this.self = this;
+           var self = this;
+           this.lastSent = [];
+           
+           self.$.put.addEventListener(
+               'error', function() { self.status = 'err'; });
+           self.$.put.addEventListener(
+               'request', function() { self.status = 'send'; });
+           self.$.put.addEventListener(
+               'response', function() { self.status = 'ok'; });
+           // collector gives up on clients after 10sec
+           setInterval(self.ping.bind(self), 9000);
+           self.status = 'ready';
+       },               
+       ping: function() {
+           this.send(this.lastSent);
+       },
+       send: function(settings) {
+           this.$.put.body = JSON.stringify({
+               "settings": settings,
+               "client": window.location.href,
+               "clientSession": this.clientSession,
+               "sendTime": Date.now() / 1000
+           });
+           this.$.put.generateRequest();
+           this.sent += 1;
+           this.lastSent = settings.slice();
+       }
+   });
+  </script>
+</dom-module>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-color-picker.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,106 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValueMap } from "lit";
+import { customElement, property, queryAsync, state } from "lit/decorators.js";
+import color from "onecolor";
+import { ClientCoord, pickerFloat } from "./floating_color_picker";
+export { Slider } from "@material/mwc-slider";
+
+const log = debug("control.color");
+type int8 = number;
+
+@customElement("light9-color-picker")
+export class Light9ColorPicker extends LitElement {
+  static styles = [
+    css`
+      :host {
+        position: relative;
+        display: flex;
+        align-items: center;
+        flex-wrap: wrap;
+        user-select: none;
+      }
+
+      #swatch {
+        display: inline-block;
+        width: 50px;
+        height: 30px;
+        margin-right: 3px;
+        border: 1px solid #333;
+      }
+
+      mwc-slider {
+        width: 160px;
+      }
+
+      #vee {
+        display: flex;
+        align-items: center;
+      }
+    `,
+  ];
+  render() {
+    return html`
+      <div id="swatch" style="background-color: ${this.color}; border-color: ${this.hueSatColor}" @mousedown=${this.startFloatingPick}></div>
+      <span id="vee"> V: <mwc-slider id="value" .value=${this.value} step="1" min="0" max="255" @input=${this.onVSliderChange}></mwc-slider> </span>
+    `;
+  }
+
+  // Selected color. Read/write. Equal to value*hueSatColor. Never null.
+  @property() color: string = "#000";
+
+  @state() hueSatColor: string = "#fff"; // always full value
+  @state() value: int8 = 0;
+
+  @queryAsync("#swatch") swatchEl!: Promise<HTMLElement>;
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    pickerFloat.pageInit();
+  }
+  update(changedProperties: PropertyValueMap<this>) {
+    super.update(changedProperties);
+    if (changedProperties.has("color")) {
+      this.setColor(this.color);
+    }
+    if (changedProperties.has("value") || changedProperties.has("hueSatColor")) {
+      this.updateColorFromHSV();
+
+      this.dispatchEvent(new CustomEvent("input", { detail: { value: this.color } }));
+
+      this.swatchEl.then((sw) => {
+        sw.style.borderColor = this.hueSatColor;
+      });
+    }
+  }
+
+  private updateColorFromHSV() {
+    this.color = color(this.hueSatColor)
+      .value(this.value / 255)
+      .hex();
+  }
+
+  private onVSliderChange(ev: CustomEvent) {
+    this.value = ev.detail.value;
+  }
+
+  // for outside users of the component
+  setColor(col: string) {
+    if (col === null) throw new Error("col===null");
+    if (typeof col !== "string") throw new Error("typeof col=" + typeof col);
+    this.value = color(col).value() * 255;
+
+    // don't update this if only the value changed, or we desaturate
+    this.hueSatColor = color(col).value(1).hex();
+  }
+
+  private startFloatingPick(ev: MouseEvent) {
+    if (this.value < (20 as int8)) {
+      log("boost");
+      this.value = 255 as int8;
+      this.updateColorFromHSV();
+    }
+    pickerFloat.startPick(new ClientCoord(ev.clientX, ev.clientY), this.color, (hsc: string) => {
+      this.hueSatColor = hsc;
+    });
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-color-picker_test.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,41 @@
+<!doctype html>
+<html>
+  <head>
+    <title>light9-color-picker test</title>
+    <meta charset="utf-8">
+    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+    <script src="/node_modules/mocha/mocha.js"></script>
+    <script src="/node_modules/chai/chai.js"></script>
+    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
+    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
+
+    <link rel="import" href="light9-color-picker.html">
+  </head>
+  <body>
+    <div id="mocha"><p><a href=".">Index</a></p></div>
+    <div id="messages"></div>
+    <div id="fixtures">
+      <dom-bind>
+        <template>
+          <light9-color-picker id="pick" color="{{color}}"></light9-color-picker>
+        </template>
+      </dom-bind>
+    </div>
+    
+    <script>
+     mocha.setup('bdd');
+     const assert = chai.assert;
+     
+     describe("RainbowCanvas", () => {
+       it("loads rainbow", (done) => {
+         const rc = new RainbowCanvas('/colorpick_rainbow_large.png', [400, 200]);
+         rc.onLoad(() => {
+           assert.equal(rc.colorAt([200, 100]), '#ff38eb');
+           done();
+         });
+       });
+     }); 
+     mocha.run();
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-music.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,73 @@
+log = debug('music')
+
+# port of light9/curvecalc/musicaccess.py
+coffeeElementSetup(class Music extends Polymer.Element
+  @is: "light9-music",
+  @getter_properties:
+    status: { type: String, notify: true }
+    statusTitle: { type: String, notify: true }
+    turboSign: { type: String, notify: true }
+    
+    duration: { type: Number, notify: true }
+    song: { type: String, notify: true }
+    # It does not yet work to write back to the playing/t
+    # properties. See seekPlayOrPause.
+    playing: { type: Boolean, notify: true }
+    t: { type: Number, notify: true }
+    
+  ready: ->
+    super.ready()
+    @turboUntil = 0
+    @poll()
+    setInterval(@estimateTimeLoop.bind(@), 30)
+
+  onError: (e) ->
+    req = @$.getTime.lastRequest
+    @status = "✘"
+    @statusTitle = "GET "+req.url+ " -> " + req.status + " " + req.statusText
+    setTimeout(@poll.bind(@), 2000)
+    
+  estimateTimeLoop: ->
+    if @playing
+      @t = @remoteT + (Date.now() - @remoteAsOfMs) / 1000
+    else
+      @t = @remoteT
+    
+  poll: ->
+    if not @$?.getTime?
+      setTimeout(@poll.bind(@), 200)
+      return
+    clearTimeout(@nextPoll) if @nextPoll
+    @$.getTime.generateRequest()
+    @status = "♫"
+    
+  onResponse: ->
+    @status = " "
+    @lastResponse = @$.getTime.lastResponse
+    now = Date.now()
+    if !@lastResponse.playing && @lastResponse.t != @remoteT
+      # likely seeking in another tool
+      @turboUntil = now + 1000
+    if now < @turboUntil
+      @turboSign = "⚡"
+      delay = 20
+    else
+      @turboSign = " "
+      delay = 700
+    
+    @nextPoll = setTimeout(@poll.bind(@), delay)
+    @duration = @lastResponse.duration
+    @playing = @lastResponse.playing
+    @song = @lastResponse.song
+
+    @remoteT = @lastResponse.t
+    @remoteAsOfMs = now
+
+  seekPlayOrPause: (t) ->
+    @$.seek.body = {t: t}
+    @$.seek.generateRequest()
+    
+    @turboUntil = Date.now() + 1000
+    @poll()
+)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-music.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,26 @@
+<link rel="import" href="/lib/polymer/polymer-element.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+
+<!-- remote control of ascoltami -->
+<dom-module id="light9-music">
+  <template>
+    <style>
+     span {
+         font-family: monospace;
+         white-space: pre;
+         background: black;
+         color: white;
+         border: 1px solid #2782ad;
+         font-size: 12px;
+     }
+    </style>
+    <iron-ajax id="getTime" on-response="onResponse" on-error="onError" url="/ascoltami/time"></iron-ajax>
+    <iron-ajax id="seek"
+               method="POST"
+               url="/ascoltami/seekPlayOrPause"
+               content-type="application/json"></iron-ajax>
+    <span>[[status]][[turboSign]]</span>
+  </template>
+  <script src="coffee_element.js"></script>
+  <script src="light9-music.js"></script>
+</dom-module>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-timeline-audio.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,114 @@
+import { debug } from "debug";
+import { html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "./RdfdbSyncedGraph";
+import { SyncedGraph } from "./SyncedGraph";
+
+const log = debug("audio");
+
+export interface Zoom {
+  duration: number | null;
+  t1: number;
+  t2: number;
+}
+
+function nodeHasChanged(newVal?: NamedNode, oldVal?: NamedNode): boolean {
+  if (newVal === undefined && oldVal === undefined) {
+    return false;
+  }
+  if (newVal === undefined || oldVal === undefined) {
+    return true;
+  }
+  return !newVal.equals(oldVal);
+}
+
+// (potentially-zoomed) spectrogram view
+@customElement("light9-timeline-audio")
+export class Light9TimelineAudio extends LitElement {
+  graph!: SyncedGraph;
+  render() {
+    return html`
+      <style>
+        :host {
+          display: block;
+          /* shouldn't be seen, but black is correct for 'no
+         audio'. Maybe loading stripes would be better */
+          background: #202322;
+        }
+        div {
+          width: 100%;
+          height: 100%;
+          overflow: hidden;
+        }
+        img {
+          height: 100%;
+          position: relative;
+          transition: left 0.1s linear;
+        }
+      </style>
+      <div>
+        <img src=${this.imgSrc} style="width: ${this.imgWidth}; left: ${this.imgLeft}" />
+      </div>
+    `;
+  }
+  @property({ hasChanged: nodeHasChanged }) show!: NamedNode;
+  @property({ hasChanged: nodeHasChanged }) song!: NamedNode;
+  @property() zoom: Zoom = { duration: null, t1: 0, t2: 1 };
+  @state() imgSrc: string = "#";
+  @state() imgWidth: string = "0"; // css
+  @state() imgLeft: string = "0"; // css
+
+  constructor() {
+    super();
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+    });
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("song") || changedProperties.has("show")) {
+      if (this.song && this.show) {
+        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song);
+      }
+    }
+    if (changedProperties.has("zoom")) {
+      this.imgWidth = this._imgWidth(this.zoom);
+      this.imgLeft = this._imgLeft(this.zoom);
+    }
+  }
+
+  setImgSrc() {
+    try {
+      var root = this.graph.stringValue(this.show, this.graph.Uri(":spectrogramUrlRoot"));
+    } catch (e) {
+      return;
+    }
+
+    try {
+      var filename = this.graph.stringValue(this.song, this.graph.Uri(":songFilename"));
+    } catch (e) {
+      return;
+    }
+
+    this.imgSrc = root + "/" + filename.replace(".wav", ".png").replace(".ogg", ".png");
+    log(`imgSrc ${this.imgSrc}`);
+  }
+
+  _imgWidth(zoom: Zoom): string {
+    if (!zoom.duration) {
+      return "100%";
+    }
+
+    return 100 / ((zoom.t2 - zoom.t1) / zoom.duration) + "%";
+  }
+  _imgLeft(zoom: Zoom): string {
+    if (!zoom.duration) {
+      return "0";
+    }
+
+    var percentPerSec = 100 / (zoom.t2 - zoom.t1);
+    return -percentPerSec * zoom.t1 + "%";
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-vidref-live.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,65 @@
+import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
+import { rounding }  from '/node_modules/significant-rounding/index.js';
+import './light9-vidref-replay.js';
+
+import debug from '/lib/debug/debug-build-es6.js';
+const log = debug('live');
+
+class Light9VidrefLive extends LitElement {
+    
+    static get properties() {
+        return {
+            description: { type: String },
+            enabled: { type: Boolean }
+        };
+    }
+    
+    constructor() {
+        super();
+        this.live = null;
+    }
+    
+    onEnabled() {
+        if (this.shadowRoot.querySelector('#enabled').checked) {
+            
+            this.live = reconnectingWebSocket(
+                'live', (msg) => {
+                    this.shadowRoot.querySelector('#live').src = 'data:image/jpeg;base64,' + msg.jpeg;
+                    this.description = msg.description;
+                });
+            this.shadowRoot.querySelector('#liveWidget').style.display = 'block';
+        } else {
+            if (this.live) {
+                this.live.disconnect();
+                this.live = null;
+                this.shadowRoot.querySelector('#liveWidget').style.display = 'none';
+            }
+        }
+    }
+
+    disconnectedCallback() {
+        log('bye');
+        //close socket
+        
+    }
+
+    static get styles() {
+        return css`
+        :host {
+            display: inline-block;
+        }
+#live {
+border: 4px solid orange;
+}
+        `;
+    }
+    
+    render() {
+        return html`
+  <label><input type="checkbox" id="enabled" ?checked="${this.enabled}" @change="${this.onEnabled}">Show live</label>
+  <div id="liveWidget" style="display: none"><img id="live" ></div>
+`;
+
+    }
+}
+customElements.define('light9-vidref-live', Light9VidrefLive);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-vidref-replay-stack.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,183 @@
+import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
+import debug from '/lib/debug/debug-build-es6.js';
+import _ from '/lib/underscore/underscore-min-es6.js';
+import { rounding }  from '/node_modules/significant-rounding/index.js';
+
+const log = debug('stack');
+
+class Light9VidrefReplayStack extends LitElement {
+    
+    static get properties() {
+        return {
+            songTime: { type: Number, attribute: false }, // from musicState.t but higher res
+            musicState: { type: Object, attribute: false },
+            players: { type: Array, attribute: false },
+            size: { type: String, attribute: true }
+        };
+    }
+
+    constructor() {
+        super();
+        this.musicState = {};
+    }
+
+    setVideoTimesFromSongTime() {
+        this.shadowRoot.querySelectorAll('light9-vidref-replay').forEach(
+            (r) => {
+                r.setVideoTimeFromSongTime(this.songTime, this.musicState.playing);
+            });
+    }
+    nudgeTime(dt) {
+        this.songTime += dt;
+        log('song now', this.songTime);
+    }
+    fineTime() {
+        if (this.musicState.playing) {
+            const sinceLastUpdate = (Date.now() - this.musicState.reportTime) / 1000;
+            this.songTime = sinceLastUpdate + this.musicState.tStart;
+        } else if (this.lastFineTimePlayingState)  {
+            this.songTime = this.musicState.t;
+        }
+        this.lastFineTimePlayingState = this.musicState.playing;
+        requestAnimationFrame(this.fineTime.bind(this));
+    }
+
+    updated(changedProperties) {
+        if (changedProperties.has('songTime')) {
+            this.setVideoTimesFromSongTime();
+        }
+    }
+
+    firstUpdated() {
+        this.songTimeRangeInput = this.shadowRoot.querySelector('#songTime');
+
+        const ws = reconnectingWebSocket('../ascoltami/time/stream',
+                                         this.receivedSongAndTime.bind(this));
+        reconnectingWebSocket('../vidref/time/stream', this.receivedRemoteScrubbedTime.bind(this));
+        // bug: upon connecting, clear this.song
+        this.fineTime();
+    }
+
+    receivedSongAndTime(msg) {
+        this.musicState = msg;
+        this.musicState.reportTime = Date.now();
+        this.musicState.tStart = this.musicState.t;            
+
+        this.songTimeRangeInput.max = this.musicState.duration;
+
+        if (this.musicState.song != this.song) {
+            this.song = this.musicState.song;
+            this.getReplayMapForSong(this.song);
+        }
+    }
+
+    receivedRemoteScrubbedTime(msg) {
+        this.songTime = msg.st;
+
+        // This doesn't work completely since it will keep getting
+        // updates from ascoltami slow updates.
+        if (msg.song != this.song) {
+            this.song = msg.song;
+            this.getReplayMapForSong(this.song);
+        }
+    }
+        
+    getReplayMapForSong(song) {
+        const u = new URL(window.location.href);
+        u.pathname = '/vidref/replayMap'
+        u.searchParams.set('song', song);
+        u.searchParams.set('maxClips', this.size == "small" ? '1' : '3');
+        fetch(u.toString()).then((resp) => {
+            if (resp.ok) {
+                resp.json().then((msg) => {
+                    this.players = msg.map(this.makeClipRow.bind(this));
+                    this.updateComplete.then(this.setupClipRows.bind(this, msg));
+                });
+            }
+        });          
+    }
+    
+    setupClipRows(msg) {
+        const nodes = this.shadowRoot.querySelectorAll('light9-vidref-replay');
+        nodes.forEach((node, i) => {
+            node.uri = msg[i].uri;
+            node.videoUrl = msg[i].videoUrl;
+            node.songToVideo = msg[i].songToVideo;
+        });
+        this.setVideoTimesFromSongTime();
+    }
+    
+    makeClipRow(clip) {
+        return html`<light9-vidref-replay @clips-changed="${this.onClipsChanged}" size="${this.size}"></light9-vidref-replay>`;
+    }
+    
+    onClipsChanged(ev) {
+        this.getReplayMapForSong(this.song);
+    }
+    
+    disconnectedCallback() {
+        log('bye');
+        //close socket
+    }
+
+    userMovedSongTime(ev) {
+        const st = this.songTimeRangeInput.valueAsNumber;
+        this.songTime = st;
+
+        fetch('/ascoltami/seekPlayOrPause', {
+            method: 'POST',
+            body: JSON.stringify({scrub: st}),
+        });
+    }
+
+    static get styles() {
+        return css`
+        :host {
+           display: inline-block;
+        }
+        #songTime {
+            width: 100%;
+        }
+        #clips {
+            display: flex;
+            flex-direction: column;
+        }
+        a {
+            color: rgb(97, 97, 255);
+        }
+        #songTime {
+            font-size: 27px;
+        }
+        light9-vidref-replay {
+            margin: 5px;
+        }
+        `;
+    }
+    
+    render() {
+        const songTimeRange = this.size != "small" ? html`<input id="songTime" type="range" 
+           .value="${this.songTime}" 
+           @input="${this.userMovedSongTime}" 
+           min="0" max="0" step=".001"></div>
+  <div><a href="${this.musicState.song}">${this.musicState.song}</a></div>` : '';
+
+
+        const globalCommands = this.size != 'small' ? html`
+  <div>
+    <button @click="${this.onClipsChanged}">Refresh clips for song</button>
+  </div>
+` : '';
+        return html`
+  <div>
+    ${songTimeRange}
+  <div id="songTime">showing song time ${rounding(this.songTime, 3)}</div>
+  <div>clips:</div>
+  <div id="clips">
+    ${this.players}
+  </div>
+  ${globalCommands}
+`;
+
+    }
+}
+customElements.define('light9-vidref-replay-stack', Light9VidrefReplayStack);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/light9-vidref-replay.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,142 @@
+import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
+import debug from '/lib/debug/debug-build-es6.js';
+import _ from '/lib/underscore/underscore-min-es6.js';
+import { rounding }  from '/node_modules/significant-rounding/index.js';
+
+const log = debug('replay');
+
+class Light9VidrefReplay extends LitElement {
+    
+    static get properties() {
+        return {
+            uri: { type: String },
+            videoUrl: { type: String },
+            songToVideo: { type: Object },
+            videoTime: { type: Number },
+            outVideoCurrentTime: { type: Number },
+            timeErr: { type: Number },
+            playRate: { type: Number },
+            size: { type: String, attribute: true }
+        };
+    }
+
+    estimateRate() {
+        const n = this.songToVideo.length;
+        const x0 = Math.round(n * .3);
+        const x1 = Math.round(n * .6);
+        const pt0 = this.songToVideo[x0];
+        const pt1 = this.songToVideo[x1];
+        return (pt1[1] - pt0[1]) / (pt1[0] - pt0[0]);
+    }
+
+    setVideoTimeFromSongTime(songTime, isPlaying) {
+        if (!this.songToVideo || !this.outVideo || this.outVideo.readyState < 1) {
+            return;
+        }
+        const i = _.sortedIndex(this.songToVideo, [songTime],
+                                (row) => { return row[0]; });
+        if (i == 0 || i > this.songToVideo.length - 1) {
+            isPlaying = false;
+        }
+        
+        this.videoTime = this.songToVideo[Math.max(0, i - 1)][1];
+
+        this.outVideoCurrentTime = this.outVideo.currentTime;
+        
+        if (isPlaying) {
+            if (this.outVideo.paused) {
+                this.outVideo.play();
+                this.setRate(this.estimateRate());
+            }
+            const err = this.outVideo.currentTime - this.videoTime;
+            this.timeErr = err;
+
+            if (Math.abs(err) > window.thresh) {
+                this.outVideo.currentTime = this.videoTime;
+                const p = window.p;
+                if (err > 0) {
+                    this.setRate(this.playRate - err * p);
+                } else {
+                    this.setRate(this.playRate - err * p);
+                }
+            }
+        } else {
+            this.outVideo.pause();
+            this.outVideoCurrentTime = this.outVideo.currentTime = this.videoTime;
+            this.timeErr = 0;
+        }
+    }
+
+    setRate(r) {
+        this.playRate = Math.max(.1, Math.min(4, r));
+        this.outVideo.playbackRate = this.playRate;
+    }
+
+    firstUpdated() {
+        this.outVideo = this.shadowRoot.querySelector('#replay');
+        this.playRate = this.outVideo.playbackRate = 1.0;
+    }
+
+    onDelete() {
+        const u = new URL(window.location.href);
+        u.pathname = '/vidref/clips'
+        u.searchParams.set('uri', this.uri);
+        fetch(u.toString(), {method: 'DELETE'}).then((resp) => {
+            let event = new CustomEvent('clips-changed', {detail: {}});
+            this.dispatchEvent(event);
+        });
+    }
+
+    static get styles() {
+        return css`
+        :host {
+            border: 2px solid #46a79f;
+            display: flex;
+            flex-direction: column;
+        }
+        div {
+            padding: 5px;
+        }
+        .num {
+            display: inline-block;
+            width: 4em;
+            color: #29ffa0;
+        }
+        a {
+            color: rgb(97, 97, 255);
+        }
+        video {
+            width: 100%;
+        }
+        `;
+    }
+    
+    render() {
+        let details = '';
+        if (this.size != 'small') {
+            details = html`
+  <div>
+    take is <a href="${this.uri}">${this.uri}</a> 
+    (${Object.keys(this.songToVideo).length} frames)
+    <button @click="${this.onDelete}">Delete</button>
+  </div>
+  <!-- here, put a little canvas showing what coverage we have with the 
+       actual/goal time cursors -->
+  <div>
+    video time should be <span class="num">${this.videoTime} </span>
+    actual = <span class="num">${rounding(this.outVideoCurrentTime, 3, 3, true)}</span>, 
+    err = <span class="num">${rounding(this.timeErr, 3, 4, true)}</span>
+    rate = <span class="num">${rounding(this.playRate, 3, 3, true)}</span>
+  </div>
+            `;
+        }
+        return html`
+          <video id="replay" class="size-${this.size}" src="${this.videoUrl}"></video>
+          ${details}
+        `;
+
+    }
+}
+customElements.define('light9-vidref-replay', Light9VidrefReplay);
+window.thresh=.3
+window.p=.3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/Effect.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,277 @@
+import debug from "debug";
+import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
+import { some } from "underscore";
+import { Patch } from "../patch";
+import { SyncedGraph } from "../SyncedGraph";
+import { shortShow } from "../show_specific";
+import { SubEvent } from "sub-events";
+
+// todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
+type Color = string;
+export type ControlValue = number | Color | NamedNode;
+
+const log = debug("effect");
+
+function isUri(x: Term | number | string): x is NamedNode {
+  return typeof x == "object" && x.termType == "NamedNode";
+}
+
+// todo: eliminate this. address the scaling when we actually scale
+// stuff, instead of making a mess of every setting
+function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
+  const U = graph.U();
+  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
+  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
+    return U(":value");
+  } else {
+    return U(":value");
+  }
+}
+
+// also see resourcedisplay's version of this
+function effContext(graph: SyncedGraph, uri: NamedNode): NamedNode {
+  return graph.Uri(uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
+}
+
+export function newEffect(graph: SyncedGraph): NamedNode {
+  // wrong- this should be our editor's scratch effect, promoted to a
+  // real one when you name it.
+  const uri = graph.nextNumberedResource(graph.Uri("http://light9.bigasterisk.com/effect/effect"));
+
+  const effect = new Effect(graph, uri);
+  const U = graph.U();
+  const ctx = effContext(graph, uri);
+  const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => graph.Quad(s, p, o, ctx);
+
+  const addQuads = [
+    quad(uri, U("rdf:type"), U(":Effect")),
+    quad(uri, U("rdfs:label"), graph.Literal(uri.value.replace(/.*\//, ""))),
+    quad(uri, U(":publishAttr"), U(":strength")),
+    quad(uri, U(":effectFunction"), U(":effectFunction/scale")),
+  ];
+  const patch = new Patch([], addQuads);
+  log("init new effect", patch);
+  graph.applyAndSendPatch(patch);
+
+  return effect.uri;
+}
+
+// effect settings data; r/w sync with the graph
+export class Effect {
+  // :effect1 a Effect; :setting ?eset . ?eset :effectAttr :deviceSettings; :value ?dset . ?dset :device ..
+  private eset?: NamedNode;
+  private dsettings: Array<{ dset: NamedNode; device: NamedNode; deviceAttr: NamedNode; value: ControlValue }> = [];
+
+  private ctxForEffect: NamedNode;
+  settingsChanged: SubEvent<void> = new SubEvent();
+
+  constructor(public graph: SyncedGraph, public uri: NamedNode) {
+    this.ctxForEffect = effContext(this.graph, this.uri);
+    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
+  }
+
+  private getExistingEset(): NamedNode | null {
+    const U = this.graph.U();
+    for (let eset of this.graph.objects(this.uri, U(":setting"))) {
+      if (this.graph.uriValue(eset as Quad_Subject, U(":effectAttr")).equals(U(":deviceSettings"))) {
+        return eset as NamedNode;
+      }
+    }
+    return null;
+  }
+  private getExistingEsetValueNode(): NamedNode | null {
+    const U = this.graph.U();
+    const eset = this.getExistingEset();
+    if (eset === null) return null;
+    try {
+      return this.graph.uriValue(eset, U(":value"));
+    } catch (e) {
+      return null;
+    }
+  }
+  private patchForANewEset(): { p: Patch; eset: NamedNode } {
+    const U = this.graph.U();
+    const eset = this.graph.nextNumberedResource(U(":e_set"));
+    return {
+      eset: eset,
+      p: new Patch(
+        [],
+        [
+          //
+          new Quad(this.uri, U(":setting"), eset, this.ctxForEffect),
+          new Quad(eset, U(":effectAttr"), U(":deviceSettings"), this.ctxForEffect),
+        ]
+      ),
+    };
+  }
+
+  private rebuildSettingsFromGraph(patch?: Patch) {
+    const U = this.graph.U();
+
+    log("syncFromGraph", this.uri);
+
+    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
+    const newSettings = [];
+
+    const deviceSettingsNode = this.getExistingEsetValueNode();
+    if (deviceSettingsNode !== null) {
+      for (let dset of Array.from(this.graph.objects(deviceSettingsNode, U(":setting"))) as NamedNode[]) {
+        //   // log(`  setting ${setting.value}`);
+        //   if (!isUri(dset)) throw new Error();
+        let value: ControlValue;
+        const device = this.graph.uriValue(dset, U(":device"));
+        const deviceAttr = this.graph.uriValue(dset, U(":deviceAttr"));
+
+        const pred = valuePred(this.graph, deviceAttr);
+        try {
+          value = this.graph.uriValue(dset, pred);
+          if (!(value as NamedNode).id.match(/^http/)) {
+            throw new Error("not uri");
+          }
+        } catch (error) {
+          try {
+            value = this.graph.floatValue(dset, pred);
+          } catch (error1) {
+            value = this.graph.stringValue(dset, pred); // this may find multi values and throw
+          }
+        }
+        //   log(`change: graph contains ${deviceAttr.value} ${value}`);
+
+        newSettings.push({ dset, device, deviceAttr, value });
+      }
+    }
+    this.dsettings = newSettings;
+    log(`settings is rebuilt to length ${this.dsettings.length}`);
+    this.settingsChanged.emit(); // maybe one emitter per dev+attr?
+    // this.onValuesChanged();
+  }
+
+  currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
+    for (let s of this.dsettings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        return s.value;
+      }
+    }
+    return null;
+  }
+
+  // change this object now, but return the patch to be applied to the graph so it can be coalesced.
+  edit(device: NamedNode, deviceAttr: NamedNode, newValue: ControlValue | null): Patch {
+    log(`edit: value=${newValue}`);
+    let existingSetting: NamedNode | null = null;
+    let result = new Patch([], []);
+
+    for (let s of this.dsettings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        if (existingSetting !== null) {
+          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
+          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.dset}`);
+          result = result.update(this.removeEffectSetting(s.dset));
+        }
+        existingSetting = s.dset;
+      }
+    }
+
+    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
+      if (existingSetting === null) {
+        result = result.update(this.addEffectSetting(device, deviceAttr, newValue));
+      } else {
+        result = result.update(this.patchExistingDevSetting(existingSetting, deviceAttr, newValue));
+      }
+    } else {
+      if (existingSetting !== null) {
+        result = result.update(this.removeEffectSetting(existingSetting));
+      }
+    }
+    return result;
+  }
+
+  shouldBeStored(deviceAttr: NamedNode, value: ControlValue | null): boolean {
+    // this is a bug for zoom=0, since collector will default it to
+    // stick at the last setting if we don't explicitly send the
+    // 0. rx/ry similar though not the exact same deal because of
+    // their remap.
+    return value != null && value !== 0 && value !== "#000000";
+  }
+
+  private addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  _addEffectSetting", deviceAttr.value, value);
+    const U = (x: string) => this.graph.Uri(x);
+    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
+
+    let patch = new Patch([], []);
+
+    let eset = this.getExistingEset();
+    if (eset === null) {
+      const ret = this.patchForANewEset();
+      patch = patch.update(ret.p);
+      eset = ret.eset;
+    }
+
+    let dsValue;
+    try {
+      dsValue = this.graph.uriValue(eset, U(":value"));
+    } catch (e) {
+      dsValue = this.graph.nextNumberedResource(U(":ds_val"));
+      patch = patch.update(new Patch([], [quad(eset, U(":value"), dsValue)]));
+    }
+
+    const dset = this.graph.nextNumberedResource(this.uri.value + "_set");
+
+    patch = patch.update(
+      new Patch(
+        [],
+        [
+          quad(dsValue, U(":setting"), dset),
+          quad(dset, U(":device"), device),
+          quad(dset, U(":deviceAttr"), deviceAttr),
+          quad(dset, valuePred(this.graph, deviceAttr), this.nodeForValue(value)),
+        ]
+      )
+    );
+    log("  save", patch);
+    this.dsettings.push({ dset, device, deviceAttr, value });
+    return patch;
+  }
+
+  private patchExistingDevSetting(devSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  patch existing", devSetting.value);
+    return this.graph.getObjectPatch(
+      devSetting, //
+      valuePred(this.graph, deviceAttr),
+      this.nodeForValue(value),
+      this.ctxForEffect
+    );
+  }
+
+  private removeEffectSetting(effectSetting: NamedNode): Patch {
+    const U = (x: string) => this.graph.Uri(x);
+    log("  _removeEffectSetting", effectSetting.value);
+
+    const eset = this.getExistingEset();
+    if (eset === null) throw "unexpected";
+    const dsValue = this.graph.uriValue(eset, U(":value"));
+    if (dsValue === null) throw "unexpected";
+    const toDel = [this.graph.Quad(dsValue, U(":setting"), effectSetting, this.ctxForEffect)];
+    for (let q of this.graph.subjectStatements(effectSetting)) {
+      toDel.push(q);
+    }
+    return new Patch(toDel, []);
+  }
+
+  clearAllSettings() {
+    for (let s of this.dsettings) {
+      this.graph.applyAndSendPatch(this.removeEffectSetting(s.dset));
+    }
+  }
+
+  private nodeForValue(value: ControlValue): NamedNode | Literal {
+    if (value === null) {
+      throw new Error("no value");
+    }
+    if (isUri(value)) {
+      return value;
+    }
+    return this.graph.prettyLiteral(value);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/Light9AttrControl.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,195 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { Literal, NamedNode } from "n3";
+import { SubEvent } from "sub-events";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { ControlValue, Effect } from "./Effect";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+export { Slider } from "@material/mwc-slider";
+export { Light9ColorPicker } from "../light9-color-picker";
+export { Light9Listbox } from "./Light9Listbox";
+const log = debug("settings.dev.attr");
+
+type DataTypeNames = "scalar" | "color" | "choice";
+const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
+
+// UI for one device attr (of any type).
+@customElement("light9-attr-control")
+export class Light9AttrControl extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      #colorControls {
+        display: flex;
+        align-items: center;
+      }
+      #colorControls > * {
+        margin: 0 3px;
+      }
+      :host {
+      }
+      mwc-slider {
+        width: 250px;
+      }
+    `,
+  ];
+
+  @property() deviceAttrRow: DeviceAttrRow | null = null;
+  @state() dataType: DataTypeNames = "scalar";
+  @property() effect: Effect | null = null;
+  @property() enableChange: boolean = false;
+  @property() value: ControlValue | null = null; // e.g. color string
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      if (this.deviceAttrRow === null) throw new Error();
+    });
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    setTimeout(() => {
+      // only needed once per page layout
+      this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
+    }, 1);
+  }
+
+  render() {
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.dataType == "scalar") {
+      const v = this.value || 0;
+      return html`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `;
+    } else if ((this.dataType = "color")) {
+      const v = this.value || "#000";
+      return html`
+        <div id="colorControls">
+          <button @click=${this.goBlack}>0.0</button>
+          <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker>
+        </div>
+      `;
+    } else if (this.dataType == "choice") {
+      return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `;
+    }
+  }
+
+  updated(changedProperties: PropertyValues<this>) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has("deviceAttrRow")) {
+      this.onDeviceAttrRowProperty();
+    }
+    if (changedProperties.has("effect")) {
+      this.onEffectProperty();
+    }
+    if (changedProperties.has("value")) {
+      this.onValueProperty();
+    }
+  }
+
+  private onValueProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    if (!this.graph) {
+      log('ignoring value change- no graph yet')
+      return;
+    }
+    if (this.effect === null) {
+      this.value = null;
+    } else {
+      const p = this.effect.edit(
+        //
+        this.deviceAttrRow.device,
+        this.deviceAttrRow.uri,
+        this.value
+      );
+      if (!p.isEmpty()) {
+        log("Effect told us to graph.patch this:\n", p.dump());
+        this.graph.applyAndSendPatch(p);
+      }
+    }
+  }
+
+  private onEffectProperty() {
+    if (this.effect === null) {
+      log('no effect obj yet')
+      return;
+    }
+    // effect will read graph changes on its own, but emit an event when it does
+    this.effect.settingsChanged.subscribe(() => {
+      this.effectSettingsChanged();
+    });
+    this.effectSettingsChanged();
+  }
+
+  private effectSettingsChanged() {
+    // something in the settings graph is new
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.effect === null) throw new Error();
+    // log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    const v = this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    this.onGraphValueChanged(v);
+  }
+
+  private onDeviceAttrRowProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    const d = this.deviceAttrRow.dataType;
+    if (d.equals(makeType("scalar"))) {
+      this.dataType = "scalar";
+    } else if (d.equals(makeType("color"))) {
+      this.dataType = "color";
+    } else if (d.equals(makeType("choice"))) {
+      this.dataType = "choice";
+    }
+  }
+
+  onValueInput(ev: CustomEvent) {
+    if (ev.detail === undefined) {
+      // not sure what this is, but it seems to be followed by good events
+      return;
+    }
+    // log(ev.type, ev.detail.value);
+    this.value = ev.detail.value;
+    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
+  }
+
+  onGraphValueChanged(v: ControlValue | null) {
+    if (this.deviceAttrRow === null) throw new Error();
+    // log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
+    // this.enableChange = false;
+    if (this.dataType == "scalar") {
+      if (v !== null) {
+        this.value = v;
+      } else {
+        this.value = 0;
+      }
+    } else if (this.dataType == "color") {
+      this.value = v;
+    }
+  }
+
+  goBlack() {
+    this.value = "#000000";
+  }
+
+  onChoice(value: any) {
+    // if (value != null) {
+    //   value = this.graph.Uri(value);
+    // } else {
+    //   value = null;
+    // }
+  }
+
+  onChange(value: any) {
+    // if (typeof value === "number" && isNaN(value)) {
+    //   return;
+    // } // let onChoice do it
+    // //log('change: control tells graph', @deviceAttrRow.uri.value, value)
+    // if (value === undefined) {
+    //   value = null;
+    // }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/Light9DeviceControl.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,210 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { unique } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Choice } from "./Light9Listbox";
+import { Light9AttrControl } from "./Light9AttrControl";
+import { Effect } from "./Effect";
+export { ResourceDisplay } from "../ResourceDisplay";
+export { Light9AttrControl };
+const log = debug("settings.dev");
+
+export interface DeviceAttrRow {
+  uri: NamedNode; //devattr
+  device: NamedNode;
+  attrClasses: string; // the css kind
+  dataType: NamedNode;
+  choices: Choice[];
+  // choiceSize: number;
+  // max: number;
+}
+
+// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
+@customElement("light9-device-control")
+export class Light9DeviceControl extends LitElement {
+  graph!: SyncedGraph;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+      .device {
+        border: 2px solid #151e2d;
+        margin: 4px;
+        padding: 1px;
+        background: #171717; /* deviceClass gradient added later */
+        break-inside: avoid-column;
+        width: 335px;
+      }
+      .deviceAttr {
+        border-top: 1px solid #272727;
+        padding-bottom: 2px;
+        display: flex;
+      }
+      .deviceAttr > span {
+      }
+      .deviceAttr > light9-live-control {
+        flex-grow: 1;
+      }
+      h2 {
+        font-size: 110%;
+        padding: 4px;
+        margin-top: 0;
+        margin-bottom: 0;
+      }
+      .device,
+      h2 {
+        border-top-right-radius: 15px;
+      }
+
+      #mainLabel {
+        font-size: 120%;
+        color: #9ab8fd;
+        text-decoration: initial;
+      }
+      .device.selected h2 {
+        outline: 3px solid #ffff0047;
+      }
+      .deviceAttr.selected {
+        background: #cada1829;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <div class="device ${this.devClasses}">
+        <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}>
+          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
+          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
+        </h2>
+
+        ${this.deviceAttrs.map(
+          (dattr: DeviceAttrRow) => html`
+            <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
+              <span>
+                attr
+                <resource-display minor .uri=${dattr.uri}></resource-display>
+              </span>
+              <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}>
+              </light9-attr-control>
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  @property() uri!: NamedNode;
+  @property() effect!: Effect;
+
+  @property() devClasses: string = ""; // the css kind
+  @property() deviceAttrs: DeviceAttrRow[] = [];
+  @property() deviceClass: NamedNode | null = null;
+  @property() selectedAttrs: Set<NamedNode> = new Set();
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
+    });
+    this.selectedAttrs = new Set();
+  }
+
+  _bgStyle(deviceClass: NamedNode | null): string {
+    if (!deviceClass) return "";
+    let hash = 0;
+    const u = deviceClass.value;
+    for (let i = u.length - 10; i < u.length; i++) {
+      hash += u.charCodeAt(i);
+    }
+    const hue = (hash * 8) % 360;
+    const accent = `hsl(${hue}, 49%, 22%)`;
+    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
+  }
+
+  setDeviceSelected(isSel: any) {
+    this.devClasses = isSel ? "selected" : "";
+  }
+
+  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
+    if (isSel) {
+      this.selectedAttrs.add(devAttr);
+    } else {
+      this.selectedAttrs.delete(devAttr);
+    }
+  }
+
+  syncDeviceAttrsFromGraph(patch?: Patch) {
+    const U = this.graph.U();
+    if (patch && !patch.containsAnyPreds([U("rdf:type"), U(":deviceAttr"), U(":dataType"), U(":choice")])) {
+      return;
+    }
+    try {
+      this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
+    } catch (e) {
+      // what's likely is we're going through a graph reload and the graph
+      // is gone but the controls remain
+    }
+    this.deviceAttrs = [];
+    Array.from(unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(":deviceAttr"))))).map((da: NamedNode) =>
+      this.deviceAttrs.push(this.attrRow(da))
+    );
+    this.requestUpdate();
+  }
+
+  attrRow(devAttr: NamedNode): DeviceAttrRow {
+    let x: NamedNode;
+    const U = (x: string) => this.graph.Uri(x);
+    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
+    const daRow = {
+      uri: devAttr,
+      device: this.uri,
+      dataType,
+      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
+      choices: [] as Choice[],
+      choiceSize: 0,
+      max: 1,
+    };
+     if (dataType.equals(U(":choice"))) {
+      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
+      daRow.choices = (() => {
+        const result = [];
+        for (x of Array.from(choiceUris)) {
+          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
+        }
+        return result;
+      })();
+      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
+    } else {
+      daRow.max = 1;
+      if (dataType.equals(U(":angle"))) {
+        // varies
+        daRow.max = 1;
+      }
+    }
+    return daRow;
+  }
+
+  clear() {
+    // why can't we just set their values ? what's diff about
+    // the clear state, and should it be represented with `null` value?
+    throw new Error();
+    // Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
+  }
+
+  onClick(ev: any) {
+    log("click", this.uri);
+    // select, etc
+  }
+
+  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
+    log("attr click", this.uri, ev.model.dattr.uri);
+    // select
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/Light9DeviceSettings.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,153 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { sortBy, uniq } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Effect, newEffect } from "./Effect";
+export { EditChoice } from "../EditChoice";
+export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
+const log = debug("settings");
+
+@customElement("light9-device-settings")
+export class Light9DeviceSettings extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      #preview {
+        width: 100%;
+      }
+      #deviceControls {
+        flex-grow: 1;
+        position: relative;
+        width: 100%;
+        overflow-y: auto;
+      }
+
+      light9-device-control > div {
+        break-inside: avoid-column;
+      }
+      light9-device-control {
+        vertical-align: top;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>effect DeviceSettings</h1>
+
+      <div id="save">
+        <div>
+          <button @click=${this.newEffect}>New effect</button>
+          <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice>
+          <button @click=${this.clearAll}>clear settings in this effect</button>
+        </div>
+      </div>
+
+      <div id="deviceControls">
+        ${this.devices.map(
+          (device: NamedNode) => html`
+            <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  devices: Array<NamedNode> = [];
+  @property() currentEffect: Effect | null = null;
+  okToWriteUrl: boolean = false;
+
+  constructor() {
+    super();
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this), "findDevices");
+      this.setEffectFromUrl();
+    });
+  }
+
+  onEffectChoice2(ev: CustomEvent) {
+    const uri = ev.detail.newValue as NamedNode;
+    this.setCurrentEffect(uri);
+  }
+  setCurrentEffect(uri: NamedNode) {
+    if (uri === null) {
+      this.currentEffect = null;
+      // todo: wipe the UI settings
+    } else {
+      this.currentEffect = new Effect(this.graph, uri);
+    }
+  }
+
+  updated(changedProperties: PropertyValues<this>) {
+    log("ctls udpated", changedProperties);
+    if (changedProperties.has("currentEffect")) {
+      log(`effectChoice to ${this.currentEffect?.uri?.value}`);
+      this.writeToUrl(this.currentEffect?.uri);
+    }
+    // this.graphToControls?.debugDump();
+  }
+
+  // Note that this doesn't fetch setting values, so it only should get rerun
+  // upon (rarer) changes to the devices etc. todo: make that be true
+  private compile(patch?: Patch) {
+    const U = this.graph.U();
+    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
+    //   return;
+    // }
+
+    this.devices = [];
+    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    log(`found ${classes.length} device classes`);
+    uniq(sortBy(classes, "value"), true).forEach((dc) => {
+      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
+        this.devices.push(dev as NamedNode);
+      });
+    });
+    this.requestUpdate();
+  }
+
+  setEffectFromUrl() {
+    // not a continuous bidi link between url and effect; it only reads
+    // the url when the page loads.
+    const effect = new URL(window.location.href).searchParams.get("effect");
+    if (effect != null) {
+      this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
+    }
+    this.okToWriteUrl = true;
+  }
+
+  writeToUrl(effect: NamedNode | undefined) {
+    const effectStr = effect ? this.graph.shorten(effect) : "";
+    if (!this.okToWriteUrl) {
+      return;
+    }
+    const u = new URL(window.location.href);
+    if ((u.searchParams.get("effect") || "") === effectStr) {
+      return;
+    }
+    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
+    window.history.replaceState({}, "", u.href);
+    log("wrote new url", u.href);
+  }
+
+  newEffect() {
+    this.setCurrentEffect(newEffect(this.graph));
+  }
+
+  clearAll() {
+    this.currentEffect?.clearAllSettings()
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/Light9Listbox.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,76 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+const log = debug("listbox");
+export type Choice = { uri: string; label: string };
+
+@customElement("light9-listbox")
+export class Light9Listbox extends LitElement {
+  static styles = [
+    css`
+      paper-listbox {
+        --paper-listbox-background-color: none;
+        --paper-listbox-color: white;
+        --paper-listbox: {
+          /* measure biggest item? use flex for columns? */
+          column-width: 9em;
+        }
+      }
+      paper-item {
+        --paper-item-min-height: 0;
+        --paper-item: {
+          display: block;
+          border: 1px outset #0f440f;
+          margin: 0 1px 5px 0;
+          background: #0b1d0b;
+        }
+      }
+      paper-item.iron-selected {
+        background: #7b7b4a;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
+        <paper-item on-focus="selectOnFocus">None</paper-item>
+        <template is="dom-repeat" items="{{choices}}">
+          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
+        </template>
+      </paper-listbox>
+    `;
+  }
+  @property() choices: Array<Choice> = [];
+  @property() value: String | null = null;
+
+  constructor() {
+    super();
+  }
+  selectOnFocus(ev) {
+    if (ev.target.uri === undefined) {
+      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
+      //this.clear();
+      //return;
+    }
+    this.value = ev.target.uri;
+  }
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("value")) {
+      if (this.value === null) {
+        this.clear();
+      }
+    }
+  }
+  onValue(value: String | null) {
+    if (value === null) {
+      this.clear();
+    }
+  }
+  clear() {
+    this.querySelectorAll("paper-item").forEach(function (item) {
+      item.blur();
+    });
+    this.value = null;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/README.md	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,24 @@
+This is an editor of :Effect resources, which have graphs like this:
+
+    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
+    rdfs:label "effect43";
+    :publishAttr :strength;
+    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
+
+    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
+
+# Objects
+
+SyncedGraph has the true data.
+
+Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
+sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
+(_probably_ at their zero position, but this is not always true) have a null value.
+
+GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
+
+We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
+:setting for that device+attribute
+
+SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
+the Effect would live as long as the page too)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/live/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>device settings</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="./Light9DeviceSettings"></script>
+  </head>
+  <body>
+    <style>
+      body,
+      html {
+        margin: 0;
+      }
+      light9-device-settings {
+        position: absolute;
+        left: 2px;
+        top: 2px;
+        right: 8px;
+        bottom: 0;
+      }
+    </style>
+    <light9-device-settings></light9-device-settings>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/metrics/ServiceButtonRow.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,66 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsLine } from "./StatsLine";
+
+@customElement("service-button-row")
+export class ServiceButtonRow extends LitElement {
+  @property() name: string = "?";
+  @property({ type:Boolean, attribute: "metrics" }) hasMetrics: boolean = false;
+  static styles = [
+    css`
+      :host {
+        padding-bottom: 10px;
+        border-bottom: 1px solid #333;
+      }
+      a {
+        color: #7d7dec;
+      }
+      div {
+        display: flex;
+        justify-content: space-between;
+        padding: 2px 3px;
+      }
+      .left {
+        display: inline-block;
+        margin-right: 3px;
+        flex-grow: 1;
+        min-width: 9em;
+      }
+      .window {
+      }
+      .serviceGrid > td {
+        border: 5px solid red;
+        display: inline-block;
+      }
+      .big {
+        font-size: 120%;
+        display: inline-block;
+        padding: 10px 0;
+      }
+
+      :host > div {
+        display: inline-block;
+        vertical-align: top;
+      }
+      :host > div:nth-child(2) {
+        width: 9em;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <div>
+        <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div>
+        <div class="window"><button @click="${this.click}">window</button></div>
+        ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""}
+      </div>
+
+      ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""}
+      `;
+  }
+
+  click() {
+    window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0");
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/metrics/StatsLine.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,301 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsProcess } from "./StatsProcess";
+import parsePrometheusTextFormat from "parse-prometheus-text-format";
+import debug from "debug";
+import { clamp } from "../floating_color_picker";
+const log = debug("home");
+
+interface Value {
+  labels: { string: string };
+  value?: string;
+  count?: number;
+  sum?: number;
+  buckets?: { [value: string]: string };
+}
+interface Metric {
+  name: string;
+  help: string;
+  type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED";
+  metrics: Value[];
+}
+type Metrics = Metric[];
+
+function nonBoring(m: Metric) {
+  return (
+    !m.name.endsWith("_created") && //
+    !m.name.startsWith("python_gc_") &&
+    m.name != "python_info" &&
+    m.name != "process_max_fds" &&
+    m.name != "process_virtual_memory_bytes" &&
+    m.name != "process_resident_memory_bytes" &&
+    m.name != "process_start_time_seconds" &&
+    m.name != "process_cpu_seconds_total"
+  );
+}
+
+@customElement("stats-line")
+export class StatsLine extends LitElement {
+  @property() name = "?";
+  @property() stats: Metrics = [];
+
+  prevCpuNow = 0;
+  prevCpuTotal = 0;
+  @property() cpu = 0;
+  @property() mem = 0;
+
+  updated(changedProperties: any) {
+    changedProperties.forEach((oldValue: any, propName: string) => {
+      if (propName == "name") {
+        const reload = () => {
+          fetch("/service/" + this.name + "/metrics").then((resp) => {
+            if (resp.ok) {
+              resp
+                .text()
+                .then((msg) => {
+                  this.stats = parsePrometheusTextFormat(msg) as Metrics;
+                  this.extractProcessStats(this.stats);
+                  setTimeout(reload, 1000);
+                })
+                .catch((err) => {
+                  log(`${this.name} failing`, err);
+                  setTimeout(reload, 1000);
+                });
+            } else {
+              if (resp.status == 502) {
+                setTimeout(reload, 5000);
+              }
+              // 404: likely not mapped to a responding server
+            }
+          });
+        };
+        reload();
+      }
+    });
+  }
+  extractProcessStats(stats: Metrics) {
+    stats.forEach((row: Metric) => {
+      if (row.name == "process_resident_memory_bytes") {
+        this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024;
+      }
+      if (row.name == "process_cpu_seconds_total") {
+        const now = Date.now() / 1000;
+        const cpuSecondsTotal = parseFloat(row.metrics[0].value!);
+        this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow);
+        this.prevCpuTotal = cpuSecondsTotal;
+        this.prevCpuNow = now;
+      }
+    });
+  }
+
+  static styles = [
+    css`
+      :host {
+        border: 2px solid #46a79f;
+        display: inline-block;
+      }
+      table {
+        border-collapse: collapse;
+        background: #000;
+        color: #ccc;
+        font-family: sans-serif;
+      }
+      th,
+      td {
+        outline: 1px solid #000;
+      }
+      th {
+        padding: 2px 4px;
+        background: #2f2f2f;
+        text-align: left;
+      }
+      td {
+        padding: 0;
+        vertical-align: top;
+        text-align: center;
+      }
+      td.val {
+        padding: 2px 4px;
+        background: #3b5651;
+      }
+      .recents {
+        display: flex;
+        align-items: flex-end;
+        height: 30px;
+      }
+      .recents > div {
+        width: 3px;
+        background: red;
+        border-right: 1px solid black;
+      }
+      .bigInt {
+        min-width: 6em;
+      }
+    `,
+  ];
+
+  tdWrap(content: TemplateResult): TemplateResult {
+    return html`<td>${content}</td>`;
+  }
+
+  recents(d: any, path: string[]): TemplateResult {
+    const hi = Math.max.apply(null, d.recents);
+    const scl = 30 / hi;
+
+    const bar = (y: number) => {
+      let color;
+      if (y < d.average) {
+        color = "#6a6aff";
+      } else {
+        color = "#d09e4c";
+      }
+      return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`;
+    };
+    return html`<td>
+      <div class="recents">${d.recents.map(bar)}</div>
+      <div>avg=${d.average.toPrecision(3)}</div>
+    </td>`;
+  }
+
+  table(d: Metrics, path: string[]): TemplateResult {
+    const byName = new Map<string, Metric>();
+    d.forEach((row) => {
+      byName.set(row.name, row);
+    });
+    let cols = d.map((row) => row.name);
+    cols.sort();
+
+    if (path.length == 0) {
+      ["webServer", "process"].forEach((earlyKey) => {
+        let i = cols.indexOf(earlyKey);
+        if (i != -1) {
+          cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1));
+        }
+      });
+    }
+
+    const th = (col: string): TemplateResult => {
+      return html`<th>${col}</th>`;
+    };
+    const td = (col: string): TemplateResult => {
+      const cell = byName.get(col)!;
+      return html`${this.drawLevel(cell, path.concat(col))}`;
+    };
+    return html` <table>
+      <tr>
+        ${cols.map(th)}
+      </tr>
+      <tr>
+        ${cols.map(td)}
+      </tr>
+    </table>`;
+  }
+
+  drawLevel(d: Metric, path: string[]) {
+    return html`[NEW ${JSON.stringify(d)} ${path}]`;
+  }
+
+  valueDisplay(m: Metric, v: Value): TemplateResult {
+    if (m.type == "GAUGE") {
+      return html`${v.value}`;
+    } else if (m.type == "COUNTER") {
+      return html`${v.value}`;
+    } else if (m.type == "HISTOGRAM") {
+      return this.histoDisplay(v.buckets!);
+    } else if (m.type == "UNTYPED") {
+      return html`${v.value}`;
+    } else if (m.type == "SUMMARY") {
+      if (!v.count) {
+        return html`err: summary without count`;
+      }
+      return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`;
+    } else {
+      throw m.type;
+    }
+  }
+
+  private histoDisplay(b: { [value: string]: string }) {
+    const lines: TemplateResult[] = [];
+    let firstLevel;
+    let lastLevel;
+    let prev = 0;
+
+    let maxDelta = 0;
+    for (let level in b) {
+      if (firstLevel === undefined) firstLevel = level;
+      lastLevel = level;
+      let count = parseFloat(b[level]);
+      let delta = count - prev;
+      prev = count;
+      if (delta > maxDelta) maxDelta = delta;
+    }
+    prev = 0;
+    const maxBarH = 30;
+    for (let level in b) {
+      let count = parseFloat(b[level]);
+      let delta = count - prev;
+      prev = count;
+      let levelf = parseFloat(level);
+      const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH);
+      lines.push(
+        html`<div
+          title="bucket=${level} count=${count}"
+          style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block"
+        ></div>`
+      );
+    }
+    return html`${firstLevel} ${lines} ${lastLevel}`;
+  }
+
+  tightLabel(labs: { [key: string]: string }): string {
+    const d: { [key: string]: string } = {};
+    for (let k in labs) {
+      if (k == "app_name") continue;
+      if (k == "output") continue;
+      if (k == "status_code" && labs[k] == "200") continue;
+      d[k] = labs[k];
+    }
+    const ret = JSON.stringify(d);
+    return ret == "{}" ? "" : ret;
+  }
+  tightMetric(name: string): string {
+    return name.replace("starlette", "⭐").replace("_request", "_req").replace("_duration", "_dur").replace("_seconds", "_s");
+  }
+  render() {
+    const now = Date.now() / 1000;
+
+    const displayedStats = this.stats.filter(nonBoring);
+    return html`
+      <div>
+        <table>
+          ${displayedStats.map(
+            (row, rowNum) => html`
+              <tr>
+                <th>${this.tightMetric(row.name)}</th>
+                <td>
+                  <table>
+                    ${row.metrics.map(
+                      (v) => html`
+                        <tr>
+                          <td>${this.tightLabel(v.labels)}</td>
+                          <td>${this.valueDisplay(row, v)}</td>
+                        </tr>
+                      `
+                    )}
+                  </table>
+                </td>
+                ${rowNum == 0
+                  ? html`
+                      <td rowspan="${displayedStats.length}">
+                        <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process>
+                      </td>
+                    `
+                  : ""}
+              </tr>
+            `
+          )}
+        </table>
+      </div>
+    `;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/metrics/StatsProcess.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,90 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import debug from "debug";
+
+const log = debug("process");
+
+const remap = (x: number, lo: number, hi: number, outLo: number, outHi: number) => {
+  return outLo + (outHi - outLo) * Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
+};
+
+@customElement("stats-process")
+export class StatsProcess extends LitElement {
+  // inspired by https://codepen.io/qiruiyin/pen/qOopQx
+  @property() cpu = 0; // process_cpu_seconds_total
+  @property() mem = 0; // process_resident_memory_bytes
+
+  w = 64;
+  h = 64;
+  revs = 0;
+  prev = 0;
+  canvas?: HTMLCanvasElement;
+  ctx?: CanvasRenderingContext2D;
+  connectedCallback() {
+    super.connectedCallback();
+    this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+    this.prev = Date.now() / 1000;
+
+    var animate = () => {
+      requestAnimationFrame(animate);
+      this.redraw();
+    };
+    animate();
+  }
+  initCanvas(canvas: HTMLCanvasElement) {
+    if (!canvas) {
+      return;
+    }
+    this.canvas = canvas;
+    this.ctx = this.canvas.getContext("2d")!;
+
+    this.canvas.width = this.w;
+    this.canvas.height = this.h;
+  }
+  redraw() {
+    if (!this.ctx) {
+      this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+    }
+    if (!this.ctx) return;
+
+    this.canvas!.setAttribute("title", 
+    `cpu ${new Number(this.cpu).toPrecision(3)}% mem ${new Number(this.mem).toPrecision(3)}MB`);
+
+    const now = Date.now() / 1000;
+    const ctx = this.ctx;
+    ctx.beginPath();
+    // wrong type of fade- never goes to 0
+    ctx.fillStyle = "#00000003";
+    ctx.fillRect(0, 0, this.w, this.h);
+    const dt = now - this.prev;
+    this.prev = now;
+
+    const size = remap(this.mem.valueOf() / 1024 / 1024, /*in*/ 20, 80, /*out*/ 3, 30);
+    this.revs += dt * remap(this.cpu.valueOf(), /*in*/ 0, 100, /*out*/ 4, 120);
+    const rad = remap(size, /*in*/ 3, 30, /*out*/ 14, 5);
+
+    var x = this.w / 2 + rad * Math.cos(this.revs / 6.28),
+      y = this.h / 2 + rad * Math.sin(this.revs / 6.28);
+
+    ctx.save();
+    ctx.beginPath();
+    ctx.fillStyle = "hsl(194, 100%, 42%)";
+    ctx.arc(x, y, size, 0, 2 * Math.PI);
+    ctx.fill();
+    ctx.restore();
+  }
+
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        width: 64px;
+        height: 64px;
+      }
+    `,
+  ];
+
+  render() {
+    return html`<canvas></canvas>`;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/metrics/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>metrics</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="./ServiceButtonRow.ts"></script>
+  </head>
+  <body>
+    <div style="display: grid">
+      <service-button-row name="ascoltami" metrics="1"></service-button-row>
+      <service-button-row name="fade"></service-button-row>
+      <service-button-row name="effects"></service-button-row>
+      <service-button-row name="effectSequencer" metrics="1"></service-button-row>
+      <service-button-row name="collector" metrics="1"></service-button-row>
+      <service-button-row name="rdfdb" metrics="1"></service-button-row>
+    </div>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/mime.types	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,83 @@
+
+types {
+
+    image/svg+xml                         svg;
+    application/x-compressed              tgz tar.gz;
+    application/x-gzip                    gz;
+    audio/x-vorbis                        ogg;
+    video/ogg                             ogv;
+    video/mp4 mp4;
+    video/webm webm;
+
+
+    text/html                             html htm shtml;
+    text/css                              css;
+    text/xml                              xml rss;
+    image/gif                             gif;
+    image/jpeg                            jpeg jpg;
+    application/json                      json;
+    application/x-javascript              js;
+    application/atom+xml                  atom;
+
+    application/rdf+xml                   rdf;
+    text/rdf+n3                           n3;
+
+    text/mathml                           mml;
+    text/plain                            txt;
+    text/vnd.sun.j2me.app-descriptor      jad;
+    text/vnd.wap.wml                      wml;
+    text/x-component                      htc;
+
+    image/png                             png;
+    image/tiff                            tif tiff;
+    image/vnd.wap.wbmp                    wbmp;
+    image/x-icon                          ico;
+    image/x-jng                           jng;
+    image/x-ms-bmp                        bmp;
+
+    application/java-archive              jar war ear;
+    application/mac-binhex40              hqx;
+    application/msword                    doc;
+    application/pdf                       pdf;
+    application/postscript                ps eps ai;
+    application/rtf                       rtf;
+    application/vnd.ms-excel              xls;
+    application/vnd.ms-powerpoint         ppt;
+    application/vnd.wap.wmlc              wmlc;
+    application/xhtml+xml         xhtml;
+    application/x-cocoa                   cco;
+    application/x-java-archive-diff       jardiff;
+    application/x-java-jnlp-file          jnlp;
+    application/x-makeself                run;
+    application/x-perl                    pl pm;
+    application/x-pilot                   prc pdb;
+    application/x-rar-compressed          rar;
+    application/x-redhat-package-manager  rpm;
+    application/x-sea                     sea;
+    application/x-shockwave-flash         swf;
+    application/x-stuffit                 sit;
+    application/x-tcl                     tcl tk;
+    application/x-x509-ca-cert            der pem crt;
+    application/x-xpinstall               xpi;
+    application/zip                       zip;
+
+    application/octet-stream              bin exe dll;
+    application/octet-stream              deb;
+    application/octet-stream              dmg;
+    application/octet-stream              eot;
+    application/octet-stream              iso img;
+    application/octet-stream              msi msp msm;
+
+    audio/midi                            mid midi kar;
+    audio/mpeg                            mp3;
+    audio/x-realaudio                     ra;
+
+    video/3gpp                            3gpp 3gp;
+    video/mpeg                            mpeg mpg;
+    video/quicktime                       mov;
+    video/x-flv                           flv;
+    video/x-mng                           mng;
+    video/x-ms-asf                        asx asf;
+    video/x-ms-wmv                        wmv;
+    video/x-msvideo                       avi;
+}
Binary file web/paint/bg1.jpg has changed
Binary file web/paint/bg2.jpg has changed
Binary file web/paint/bg3.jpg has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/paint/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,23 @@
+<!doctype html>
+<html>
+  <head>
+    <title>paint</title>
+    <meta charset="utf-8" />
+    <script src="/lib/webcomponentsjs/webcomponents-lite.min.js"></script>
+    <link rel="stylesheet"  href="/style.css">
+    <link rel="import" href="paint-elements.html">
+    <meta name="viewport" content="user-scalable=no, width=1000, initial-scale=.5" />
+    <style>
+     body {
+         position: relative;
+         height: 500px;
+     }
+    </style>
+  </head>
+  <body>
+    <light9-paint style="position: absolute; left: 0px; top: 0px; width: 1000px; height: 500px">
+    </light9-paint>
+
+
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/paint/paint-elements.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,232 @@
+log = debug('paint')
+debug.enable('paint')
+
+class Painting
+  constructor: (@svg) ->
+    @strokes = []
+
+  setSize: (@size) ->
+
+  startStroke: (pos, color) ->
+    stroke = new Stroke(pos, color, @size)
+    stroke.appendElem(@svg)
+    @strokes.push(stroke)
+    return stroke
+
+  hover: (pos) ->
+    @clear()
+    s = @startStroke(pos, '#ffffff', @size)
+    r = .02
+    steps = 5
+    for ang in [0..steps]
+      ang = 6.28 * ang / steps
+      s.move([pos[0] + r * Math.sin(ang), pos[1] + 1.5 * r * Math.cos(ang)])
+
+  getDoc: ->
+    {strokes: @strokes}
+
+  clear: ->
+    s.removeElem() for s in @strokes
+    @strokes = []
+
+class Stroke
+  constructor: (pos, @color, @size) ->
+    @path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+    @path.setAttributeNS(null, 'd', "M #{pos[0]*@size[0]} #{pos[1]*@size[1]}")
+    @pts = [pos]
+    @lastPos = pos
+
+  appendElem: (parent) ->
+    parent.appendChild(@path)
+
+  removeElem: ->
+    @path.remove()
+    
+  move: (pos) ->
+    if Math.hypot(pos[0] - @lastPos[0], pos[1] - @lastPos[1]) < .02
+      return
+    @path.attributes.d.value += " L #{pos[0]*@size[0]} #{pos[1]*@size[1]}"
+    @pts.push(pos)
+    @lastPos = pos
+
+  finish: () ->
+
+Polymer
+  is: "light9-paint-canvas"
+  behaviors: [ Polymer.IronResizableBehavior ]
+  listeners: 'iron-resize': 'onResize'
+  properties: {
+    bg: { type: String },
+    tool: { type: String, value: 'hover' },
+    painting: { type: Object } # output
+  }
+  ready: ->
+    @painting = new Painting(@$.paint)
+    @onResize()
+    @$.paint.addEventListener('mousedown', @onDown.bind(@))
+    @$.paint.addEventListener('mousemove', @onMove.bind(@))
+    @$.paint.addEventListener('mouseup', @onUp.bind(@))
+    @$.paint.addEventListener('touchstart', @onDown.bind(@))
+    @$.paint.addEventListener('touchmove', @onMove.bind(@))
+    @$.paint.addEventListener('touchend', @onUp.bind(@))
+
+    @hover = _.throttle((ev) =>
+          @painting.hover(@evPos(ev))
+          @scopeSubtree(@$.paint)
+          @fire('paintingChanged', @painting)
+        , 100)
+
+  evPos: (ev) ->
+    px = (if ev.touches?.length? then [Math.round(ev.touches[0].clientX),
+                                       Math.round(ev.touches[0].clientY)] else [ev.x, ev.y])
+    return [px[0] / @size[0], px[1] / @size[1]]
+
+  onClear: () ->
+    @painting.clear()
+    @fire('paintingChanged', @painting)
+    
+  onDown: (ev) ->
+    switch @tool
+      when "hover"
+        @onMove(ev)
+      when "paint"
+        # if it's on an existing one, do selection
+        @currentStroke = @painting.startStroke(@evPos(ev), '#aaaaaa')
+    @scopeSubtree(@$.paint)
+
+  onMove: (ev) ->
+    switch @tool
+      when "hover"
+        @hover(ev)
+
+      when "paint"
+        # ..or move selection
+        return unless @currentStroke
+        @currentStroke.move(@evPos(ev))
+
+  onUp: (ev) ->
+    return unless @currentStroke
+    @currentStroke.finish()
+    @currentStroke = null
+    
+    @notifyPath('painting.strokes.length') # not working
+    @fire('paintingChanged', @painting)
+
+  onResize: (ev) ->
+    @size = [@$.parent.offsetWidth, @$.parent.offsetHeight]
+    @$.paint.attributes.viewBox.value = "0 0 #{@size[0]} #{@size[1]}"
+    @painting.setSize(@size)
+
+
+Polymer
+  is: "light9-simulation"
+  properties: {
+    graph: { type: Object }
+    layers: { type: Object }
+    solution: { type: Object }
+  }
+  listeners: [
+    "onLayers(layers)"
+  ]
+  ready: ->
+    null
+  onLayers: (layers) ->
+    log('upd', layers)
+
+
+Polymer
+  is: "light9-device-settings",
+  properties: {
+    graph: { type: Object }
+    subj: {type: String, notify: true},
+    label: {type: String, notify: true},
+    attrs: {type: Array, notify: true},
+  },
+  observers: [
+    'onSubj(graph, subj)'
+  ]
+  ready: ->
+    @label = "aura2"
+    @attrs = [
+        {attr: 'rx', val: .03},
+        {attr: 'color', val: '#ffe897'},
+    ]
+  onSubj: (graph, @subj) ->
+    graph.runHandler(@loadAttrs.bind(@), "loadAttrs #{@subj}")
+  loadAttrs: ->
+    U = (x) => @graph.Uri(x)
+    @attrs = []
+    for s in @graph.objects(U(@subj), U(':setting'))
+      attr = @graph.uriValue(s, U(':deviceAttr'))
+      attrLabel = @graph.stringValue(attr, U('rdfs:label'))
+      @attrs.push({attr: attrLabel, val: @settingValue(s)})
+    @attrs = _.sortBy(@attrs, 'attr')
+
+  settingValue: (s) ->
+    U = (x) => @graph.Uri(x)
+    for pred in [U(':value'), U(':scaledValue')]
+      try
+        return @graph.stringValue(s, pred)
+      catch
+        null
+      try
+        return @graph.floatValue(s, pred)
+      catch
+        null
+    throw new Error("no value for #{s}")
+    
+Polymer
+  is: "light9-paint"
+  properties: {
+    painting: { type: Object }
+    client: { type: Object }
+    graph: { type: Object }
+  }
+
+  ready: () ->
+    # couldn't make it work to bind to painting's notifyPath events
+    @$.canvas.addEventListener('paintingChanged', @paintingChanged.bind(@))
+    @$.solve.addEventListener('response', @onSolve.bind(@))
+
+    @clientSendThrottled = _.throttle(@client.send.bind(@client), 60)
+    @bestMatchPending = false
+    
+  paintingChanged: (ev) ->
+    U = (x) => @graph.Uri(x)
+
+    @painting = ev.detail
+    @$.solve.body = JSON.stringify(@painting.getDoc())
+    #@$.solve.generateRequest()
+
+    @$.bestMatches.body = JSON.stringify({
+      painting: @painting.getDoc(),
+      devices: [
+        U('dev:aura1'), U('dev:aura2'), U('dev:aura3'), U('dev:aura4'), U('dev:aura5'),
+        U('dev:q1'), U('dev:q2'), U('dev:q3'),
+        ]})
+
+    send = =>
+      @$.bestMatches.generateRequest().completes.then (r) =>
+        @clientSendThrottled(r.response.settings)
+        if @bestMatchPending
+          @bestMatchPending = false
+          send()
+    
+    if @$.bestMatches.loading
+      @bestMatchPending = true
+    else
+      send()
+
+  onSolve: (response) ->
+    U = (x) => @graph.Uri(x)
+
+    sample = @$.solve.lastResponse.bestMatch.uri
+    settingsList = []
+    for s in @graph.objects(sample, U(':setting'))
+      try
+        v = @graph.floatValue(s, U(':value'))
+      catch
+        v = @graph.stringValue(s, U(':scaledValue'))
+      row = [@graph.uriValue(s, U(':device')), @graph.uriValue(s, U(':deviceAttr')), v]
+      settingsList.push(row)
+    @client.send(settingsList)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/paint/paint-elements.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,116 @@
+<script src="/lib/underscore/underscore-min.js"></script>
+<link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+<link rel="import" href="/lib/paper-radio-group/paper-radio-group.html">
+<link rel="import" href="/lib/paper-radio-button/paper-radio-button.html">
+<link rel="import" href="paint-report-elements.html">
+<link rel="import" href="../rdfdb-synced-graph.html">
+<link rel="import" href="../light9-collector-client.html">
+
+
+<dom-module id="light9-paint-canvas">
+  <template>
+    <style>
+     :host {
+         display: block;
+     }
+     #parent {
+         position: relative;
+         height: 500px;
+     }
+     #parent > * {
+         position: absolute;
+         top: 0;
+         left: 0;
+         width: 100%;
+         height: 500px;
+     }
+     #toolbar {
+         background: #a7a7a7;
+     }
+     svg > path {
+         fill:none;
+         stroke:rgba(255, 255, 255, 0.66);
+         stroke-width:80;
+         filter:url(#blur);
+         stroke-linecap:butt;
+         stroke-linejoin:miter;
+         stroke-miterlimit:4;
+     }
+    </style>
+
+    <div id="toolbar">
+      <paper-radio-group selected="{{tool}}">
+        <paper-radio-button name="hover">hover spot</paper-radio-button>
+        <paper-radio-button name="paint">paint</paper-radio-button>
+        <paper-radio-button name="erase">erase</paper-radio-button>
+      </paper-radio-group>
+      <button on-click="onClear">clear</button>
+    </div>
+    
+    <div id="parent">
+      <img src="{{bg}}">
+      <svg id="paint" viewBox="0 0 500 221">
+        <defs id="defs12751">
+          <filter
+              style="color-interpolation-filters:sRGB"
+              id="blur"
+              x="-5.0" y="-5.0"
+              width="11.0" height="11.0"
+          >
+            <feGaussianBlur
+                stdDeviation="20"
+                k2="1.01"
+                result="result1"
+            ></feGaussianBlur>
+            <!--   <feMorphology
+                 in="result1"
+                 operator="dilate"
+                 radius="3.39"
+                 result="result3"
+                 ></feMorphology>
+                 <feMorphology
+                 in="result1"
+                 radius="3.37"
+                 result="result2"
+                 ></feMorphology>
+                 <feComposite
+                 in="result3"
+                 in2="result2"
+                 operator="arithmetic"
+                 k1="0"
+                 k2="1.00"
+                 k3="0.43"
+                 k4="0"
+                 ></feComposite> -->
+          </filter>
+        </defs>
+      </svg>
+    </div>
+  </template>
+  
+</dom-module>
+
+<dom-module id="light9-paint">
+  <template>
+    <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+
+    <light9-paint-canvas id="canvas" bg="bg3.jpg" painting="{{painting}}"></light9-paint-canvas>
+
+    <iron-ajax id="solve" method="POST" url="../paintServer/solve" last-response="{{solve}}"></iron-ajax>
+
+    <iron-ajax id="bestMatches" method="POST" url="../paintServer/bestMatches"></iron-ajax>
+    
+    <div>To collector: <light9-collector-client self="{{client}}"></light9-collector-client></div>
+
+    <light9-simulation graph="{{graph}}" solution="{{solve}}" layers="{{layers}}"></light9-simulation>
+  </template>
+</dom-module>
+
+<script src="/node_modules/n3/n3-browser.js"></script>
+<script src="/lib/shortcut/index.js"></script>
+<script src="/lib/underscore/underscore-min.js"></script>
+<script src="/lib/async/dist/async.js"></script>
+
+<script src="paint-elements.js"></script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/paint/paint-report-elements.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,148 @@
+<link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+
+<dom-module id="light9-simulation">
+  <template>
+    <style>
+     #solutions { display: flex; margin: 20px; }
+     #single-light { margin-right: 70px; }
+     #multi-light {}
+     #breakdown { position: relative; }
+     #sources { display: flex; }
+     #solution { display: flex; margin-top: 80px; }
+     #connectors { position: absolute; width: 100%; height: 100%; z-index: -1; }
+     #connectors path { stroke: #615c54; stroke-width: 3px; }
+
+     [draggable=true]:hover {
+         box-shadow: 0 0 20px yellow;
+     }
+         
+     
+    </style>
+
+    <div id="solutions">
+      <div id="single-light">
+        <div>Single pic best match:</div>
+
+        <!-- drag this img to make an effect out of just it -->
+        <light9-capture-image name="lighhtnamehere" path="{{solution.bestMatch.path}}"></light9-capture-image>
+
+        <div>Error: {{solution.bestMatch.dist}}</div>
+        
+        <light9-device-settings graph="{{graph}}" subj="{{solution.bestMatch.uri}}"></light9-device-settings>
+      </div>
+
+      <!-- existing effect best match? -->
+      
+      <div id="multi-light">
+        Created from multiple lights:
+
+        <div id="breakdown">
+          <svg id="connectors">
+            <g>
+              <path d="M 112,241 L 150,280"></path>
+              <path d="M 332,241 L 150,280"></path>
+              <path d="M 532,241 L 150,280"></path>
+              <path d="M 732,241 L 150,280"></path>
+            </g>
+            
+          </svg>
+          <div id="sources">
+            <div class="effectLike" draggable="true">
+              <light9-capture-image name="aura1" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
+              <light9-device-settings></light9-device-settings>
+            </div>
+            <div>
+              <light9-capture-image name="aura2" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
+              <light9-device-settings></light9-device-settings>
+            </div>
+            <div>
+              <light9-capture-image name="aura3" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
+              <light9-device-settings></light9-device-settings>
+            </div>
+            <div>
+              <light9-capture-image name="aura4" path="show/dance2017/capture/moving1/cap258592/pic1.jpg"></light9-capture-image>
+              <light9-device-settings></light9-device-settings>
+            </div>
+          </div>
+          
+          <div id="solution">
+            <div>
+              <div>combined</div>
+              <!-- drag this img to make an effect out of it -->
+              <div><img width="150" src="../show/dance2017/capture/moving1/cap258592/pic1.jpg"></div>
+              <div>error 9980</div>
+            </div>
+            <div>
+              <div>residual</div>
+              <div><img width="150" src="../show/dance2017/capture/moving1/cap258592/pic1.jpg"></div>
+            </div>
+          </div>
+        </div>
+
+        Save as effect named <input> <button>Save</button>
+      </div>
+
+  </template>
+</dom-module>
+
+<!-- merge more with light9-collector-device -->
+<dom-module id="light9-device-settings">
+  <template>
+    <style>
+     :host {
+         display: block;
+         break-inside: avoid-column;
+         border: 2px solid gray;
+         padding: 8px;
+     }
+     td.nonzero {
+         background: #310202;
+         color: #e25757;
+     }
+     td.full {
+         background: #2b0000;
+         color: red;
+         font-weight: bold;
+     }
+    </style>
+    <h3><a href="{{subj}}">{{label}}</a></h3>
+    <table class="borders">
+      <tr>
+        <th>device attr</th>
+        <th>value</th>
+      </tr>
+      <template is="dom-repeat" items="{{attrs}}">
+        <tr>
+          <td>{{item.attr}}</td>
+          <td class$="{{item.valClass}}">{{item.val}}</td>
+        </tr>
+      </template>
+
+  </template>
+ 
+</dom-module>
+
+<dom-module id="light9-capture-image">
+  <template>
+    <style>
+     :host { display: block; }
+       img {
+         outline: 1px solid #232323;
+         margin: 5px;
+     }
+    </style>
+    <div>{{name}}</div>
+    <div><img width="100" src="../{{path}}"></div>
+  </template>
+  <script>
+   Polymer({
+       is: "light9-capture-image",
+       properties: {
+           name: { type: String },
+           path: { type: String },
+       }
+   });
+  </script>
+</dom-module>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/patch.test.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,79 @@
+import { assert, describe, expect, it } from "vitest";
+
+import { Quad, NamedNode } from "n3";
+import { Patch, QuadPattern } from "./patch";
+import * as N3 from "n3";
+
+const node1 = new NamedNode("http://example.com/node1");
+const node2 = new NamedNode("http://example.com/node2");
+const node3 = new NamedNode("http://example.com/node3");
+
+const decimalDT = new NamedNode("http://www.w3.org/2001/XMLSchema#decimal");
+
+function QP(
+  subject: N3.Quad_Subject | null, //
+  predicate: N3.Quad_Predicate | null,
+  object: N3.Quad_Object | null,
+  graph: N3.Quad_Graph | null
+): QuadPattern {
+  return { subject, predicate, object, graph };
+}
+
+describe("Patch.matches", () => {
+  it("matches any quads against an open pattern", () => {
+    const quad1 = new Quad(node1, node2, node3);
+    const quad2 = new Quad(node1, node2, node3);
+    const quad3 = new Quad(node1, node2, node3);
+
+    const pattern = QP(null, null, null, null);
+
+    const p = new Patch([quad1, quad2], [quad3]);
+
+    assert.isTrue(p.matches(pattern));
+  });
+  it("doesn't match when the patch is empty", () => {
+    const p = new Patch([], []);
+    assert.isFalse(p.matches(QP(null, null, null, null)));
+  });
+  it("compares terms correctly", () => {
+    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node1, null, null, null)));
+    assert.isFalse(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node2, null, null, null)));
+  });
+  it("matches on just one set term", () => {
+    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(node1, null, null, null)));
+    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(null, node2, null, null)));
+    assert.isTrue(new Patch([new Quad(node1, node2, node3)], []).matches(QP(null, null, node3, null)));
+  });
+});
+
+describe("Patch.empty", () => {
+  it("works with no quads", () => {
+    const p = new Patch([], []);
+    assert.isTrue(p.isEmpty());
+  });
+  it("works with unmatched quads", () => {
+    const p = new Patch([], [new Quad(node1, node2, node3)]);
+    assert.isFalse(p.isEmpty());
+  });
+  it("understands floats are equal", () => {
+    const p = new Patch(
+      [new Quad(node1, node2, N3.DataFactory.literal((0.12345).toPrecision(3), decimalDT))],
+      [new Quad(node1, node2, N3.DataFactory.literal((0.1234).toPrecision(3), decimalDT))]
+    );
+    assert.isTrue(p.isEmpty());
+  });
+  it("...and when they're not", () => {
+    const p = new Patch(
+      [new Quad(node1, node2, N3.DataFactory.literal(0.123, decimalDT))], //
+      [new Quad(node1, node2, N3.DataFactory.literal(0.124, decimalDT))]
+    );
+    assert.isFalse(p.isEmpty());
+  });
+  it("understands literals are equal", () => {
+    const p = new Patch(
+      [new Quad(node1, node2, node3)], //
+      [new Quad(node1, node2, new NamedNode("http://example.com/node" + "3"))]
+    );
+    assert.isTrue(p.isEmpty());
+  });
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/patch.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,217 @@
+import * as async from "async";
+import debug from "debug";
+import * as N3 from "n3";
+import { NamedNode, Parser, Quad, Writer } from "n3";
+import * as Immutable from "immutable";
+export interface QuadPattern {
+  subject: N3.Quad_Subject | null;
+  predicate: N3.Quad_Predicate | null;
+  object: N3.Quad_Object | null; // literals allowed? needs review. probably 'yes'.
+  graph: N3.Quad_Graph | null;
+}
+
+const log = debug("patch");
+
+export class Patch {
+  // immutable
+  private dels: Immutable.Set<Quad>;
+  private adds: Immutable.Set<Quad>;
+  private _allPredsCache?: Immutable.Set<string>;
+  private _allSubjsCache?: Immutable.Set<string>;
+  constructor(dels: Iterable<Quad>, adds: Iterable<Quad>) {
+    this.dels = Immutable.Set(dels);
+    this.adds = Immutable.Set(adds);
+    this.validate();
+  }
+
+  private validate() {
+    // todo: finish porting this from coffeescript
+    this.adds.union(this.dels).forEach((q: Quad) => {
+      if (!q.equals) {
+        throw new Error("doesn't look like a proper Quad");
+      }
+      if (!q.subject.id || q.graph.id == null || q.predicate.id == null) {
+        throw new Error(`corrupt patch: ${JSON.stringify(q)}`);
+      }
+      if (
+        q.object.termType == "Literal" &&
+        (q.object.datatypeString == "http://www.w3.org/2001/XMLSchema#float" || q.object.datatypeString == "http://www.w3.org/2001/XMLSchema#double")
+      ) {
+        throw new Error(`${JSON.stringify(q)} is using non-decimal for numbers, which is going to break some comparisons`);
+      }
+    });
+  }
+
+  matches(pat: QuadPattern): boolean {
+    const allQuads = this.dels.concat(this.adds);
+    return allQuads.some((quad) => {
+      return (
+        (pat.subject === null || pat.subject.equals(quad.subject)) && //
+        (pat.predicate === null || pat.predicate.equals(quad.predicate)) && //
+        (pat.object === null || pat.object.equals(quad.object)) && //
+        (pat.graph === null || pat.graph.equals(quad.graph))
+      );
+    });
+  }
+
+  isEmpty() {
+    return Immutable.is(this.dels, this.adds);
+  }
+
+  applyToGraph(g: N3.Store) {
+    for (let quad of this.dels) {
+      g.removeQuad(quad);
+    }
+    for (let quad of this.adds) {
+      g.addQuad(quad);
+    }
+  }
+
+  update(other: Patch): Patch {
+    // this is approx, since it doesnt handle cancelling existing quads.
+    return new Patch(this.dels.union(other.dels), this.adds.union(other.adds));
+  }
+
+  summary(): string {
+    return "-" + this.dels.size + " +" + this.adds.size;
+  }
+
+  dump(): string {
+    if (this.dels.size + this.adds.size > 20) {
+      return this.summary();
+    }
+    const lines: string[] = [];
+    const s = (term: N3.Term): string => {
+      if (term.termType == "Literal") return term.value;
+      if (term.termType == "NamedNode")
+        return term.value
+          .replace("http://light9.bigasterisk.com/effect/", "effect:")
+          .replace("http://light9.bigasterisk.com/", ":")
+          .replace("http://www.w3.org/2000/01/rdf-schema#", "rdfs:")
+          .replace("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:");
+      if (term.termType == "BlankNode") return "_:" + term.value;
+      return term.id;
+    };
+    const delPrefix = "- ",
+      addPrefix = "\u200B+ "; // dels to sort before adds
+    this.dels.forEach((d) => lines.push(delPrefix + s(d.subject) + " " + s(d.predicate) + " " + s(d.object)));
+    this.adds.forEach((d) => lines.push(addPrefix + s(d.subject) + " " + s(d.predicate) + " " + s(d.object)));
+    lines.sort();
+    return lines.join("\n") + "\n" + (this.isEmpty() ? "(empty)" : "(nonempty)");
+  }
+
+  async toJsonPatch(): Promise<string> {
+    return new Promise((res, rej) => {
+      const out: SyncgraphPatchMessage = { patch: { adds: "", deletes: "" } };
+
+      const writeDels = (cb1: () => void) => {
+        const writer = new Writer({ format: "N-Quads" });
+        writer.addQuads(this.dels.toArray());
+        writer.end(function (err: any, result: string) {
+          out.patch.deletes = result;
+          cb1();
+        });
+      };
+
+      const writeAdds = (cb2: () => void) => {
+        const writer = new Writer({ format: "N-Quads" });
+        writer.addQuads(this.adds.toArray());
+        writer.end(function (err: any, result: string) {
+          out.patch.adds = result;
+          cb2();
+        });
+      };
+
+      async.parallel([writeDels, writeAdds], (err: any) => res(JSON.stringify(out)));
+    });
+  }
+
+  containsAnyPreds(preds: Iterable<NamedNode>): boolean {
+    if (this._allPredsCache === undefined) {
+      this._allPredsCache = Immutable.Set();
+      this._allPredsCache.withMutations((cache) => {
+        for (let qq of [this.adds, this.dels]) {
+          for (let q of Array.from(qq)) {
+            cache.add(q.predicate.value);
+          }
+        }
+      });
+    }
+
+    for (let p of preds) {
+      if (this._allPredsCache.has(p.value)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  allSubjs(): Immutable.Set<string> {
+    // returns subjs as Set of strings
+    if (this._allSubjsCache === undefined) {
+      this._allSubjsCache = Immutable.Set();
+      this._allSubjsCache.withMutations((cache) => {
+        for (let qq of [this.adds, this.dels]) {
+          for (let q of Array.from(qq)) {
+            cache.add(q.subject.value);
+          }
+        }
+      });
+    }
+
+    return this._allSubjsCache;
+  }
+
+  allPreds(): Immutable.Set<NamedNode> {
+    // todo: this could cache
+    const ret = Immutable.Set<NamedNode>();
+    ret.withMutations((r) => {
+      for (let qq of [this.adds, this.dels]) {
+        for (let q of Array.from(qq)) {
+          if (q.predicate.termType == "Variable") throw "unsupported";
+          r.add(q.predicate);
+        }
+      }
+    });
+    return ret;
+  }
+}
+
+// The schema of the json sent from graph server.
+export interface SyncgraphPatchMessage {
+  patch: { adds: string; deletes: string };
+}
+
+export function patchToDeleteEntireGraph(g: N3.Store) {
+  return new Patch(g.getQuads(null, null, null, null), []);
+}
+
+export function parseJsonPatch(input: SyncgraphPatchMessage, cb: (p: Patch) => void): void {
+  // note response cb doesn't have an error arg.
+  const dels: Quad[] = [];
+  const adds: Quad[] = [];
+
+  const parseAdds = (cb2: () => any) => {
+    const parser = new Parser();
+    return parser.parse(input.patch.adds, (error: any, quad: Quad, prefixes: any) => {
+      if (quad) {
+        return adds.push(quad);
+      } else {
+        return cb2();
+      }
+    });
+  };
+  const parseDels = (cb3: () => any) => {
+    const parser = new Parser();
+    return parser.parse(input.patch.deletes, (error: any, quad: any, prefixes: any) => {
+      if (quad) {
+        return dels.push(quad);
+      } else {
+        return cb3();
+      }
+    });
+  };
+
+  // todo: is it faster to run them in series? might be
+  async.parallel([parseAdds, parseDels], (err: any) => cb(new Patch(dels, adds)));
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/rdfdb-synced-graph_test.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,67 @@
+<!doctype html>
+<html>
+  <head>
+    <title>rdfdb-synced-graph test</title>
+    <meta charset="utf-8">
+    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+    <script src="/node_modules/mocha/mocha.js"></script>
+    <script src="/node_modules/chai/chai.js"></script>
+    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
+    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
+
+    <link rel="import" href="rdfdb-synced-graph.html">
+  </head>
+  <body>
+    <div id="mocha"><p><a href=".">Index</a></p></div>
+    <div id="messages"></div>
+    <div id="fixtures">
+      <dom-bind>
+        <template>
+          <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
+        </template>
+      </dom-bind>
+    </div>
+    
+    <script>
+     mocha.setup('bdd');
+     const assert = chai.assert;
+     
+     describe("rdfdb-synced-graph", () => {
+       let elem, U;
+       beforeEach(() => {
+         elem = document.querySelector("#graph");
+         window.g = elem;
+         elem.graph.clearGraph();
+         U = elem.graph.Uri.bind(elem.graph);
+       });
+       it("makes a node", () => {
+         assert.equal(elem.tagName, "RDFDB-SYNCED-GRAPH");
+       });
+       it("loads trig", (done) => {
+         elem.graph.loadTrig(`
+                       @prefix : <http://light9.bigasterisk.com/> .
+                       :a :b :c :d .
+                     `, () => {
+                       assert.equal(elem.graph.quads().length, 1);
+                       done();
+                     });
+       });
+       describe("floatValue read call", () => {
+         it("loads two values without confusing them in a cache", (done) => {
+           elem.graph.loadTrig(`
+               @prefix : <http://light9.bigasterisk.com/> .
+               :s :a 1 :g .
+               :s :b 2 :g .
+             `, () => {
+               assert.equal(elem.graph.floatValue(U(":s"), U(":a")), 1);
+               assert.equal(elem.graph.floatValue(U(":s"), U(":b")), 2);
+               assert.equal(elem.graph.floatValue(U(":s"), U(":a")), 1);
+               done();
+             });
+         });
+       });
+     }); 
+     mocha.run();
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/rdfdbclient.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,66 @@
+import debug from "debug";
+import { parseJsonPatch, Patch } from "./patch";
+import { RdfDbChannel } from "./RdfDbChannel";
+const log = debug("rdfdbclient");
+
+export class RdfDbClient {
+  private channel: RdfDbChannel;
+  _patchesToSend: Patch[];
+  // Send and receive patches from rdfdb. Primarily used in SyncedGraph.
+  //
+  // What this should do, and does not yet, is keep the graph
+  // 'coasting' over a reconnect, applying only the diffs from the old
+  // contents to the new ones once they're in. Then, remove all the
+  // clearGraph stuff in graph.coffee that doesn't even work right.
+  //
+  constructor(
+    patchSenderUrl: string,
+    private clearGraphOnNewConnection: () => void,
+    private applyPatch: (p: Patch) => void,
+    setStatus: (status: string) => void
+  ) {
+    this._patchesToSend = [];
+    this.channel = new RdfDbChannel(patchSenderUrl);
+    this.channel.statusDisplay.subscribe((st: string) => {
+      setStatus(st + `; ${this._patchesToSend.length} pending `);
+    });
+    this.channel.newConnection.subscribe(() => {
+      this.clearGraphOnNewConnection();
+    });
+    this.channel.serverMessage.subscribe((m) => {
+      parseJsonPatch(m.body, (p: Patch) => {
+        log('patch from server:', p.dump())
+        if (p.isEmpty()) {
+          return;
+        }
+        this.applyPatch(p);
+      });
+    });
+  }
+
+  sendPatch(patch: Patch) {
+    log("queue patch to server ", patch.summary());
+    this._patchesToSend.push(patch);
+    this._continueSending();
+  }
+
+  disconnect(why:string) {
+    this.channel.disconnect(why);
+  }
+
+  async _continueSending() {
+    // we could call this less often and coalesce patches together to optimize
+    // the dragging cases. See rdfdb 'compactPatches' and 'processInbox'.
+    while (this._patchesToSend.length) {
+      const patch = this._patchesToSend.splice(0, 1)[0];
+      const json = await patch.toJsonPatch();
+      const ret = this.channel.sendMessage(json);
+      if (!ret) {
+        setTimeout(this._continueSending.bind(this), 500);
+
+        // this.disconnect()
+        return;
+      }
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/resource-display_test.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,122 @@
+<!doctype html>
+<html>
+  <head>
+    <title>resource-display test</title>
+    <meta charset="utf-8">
+    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
+    <script src="/node_modules/mocha/mocha.js"></script>
+    <script src="/node_modules/chai/chai.js"></script>
+
+    <link rel="stylesheet" media="all" href="/node_modules/mocha/mocha.css">
+    <link rel="import" href="/lib/polymer/lib/elements/dom-bind.html">
+
+    <link rel="import" href="rdfdb-synced-graph.html">
+    <link rel="import" href="resource-display.html">
+  </head>
+  <body>
+    <div id="mocha"><p><a href=".">Index</a></p></div>
+    <div id="messages"></div>
+    <div id="fixtures">
+      <dom-bind>
+        <template>
+          <p>
+            <rdfdb-synced-graph id="graph" test-graph="true" graph="{{graph}}"></rdfdb-synced-graph>
+          </p>
+          <p>
+            resource: <resource-display
+                          id="elem"
+                          graph="{{graph}}"
+                          uri="http://example.com/a"></resource-display>
+          </p>
+        </template>
+      </dom-bind>
+    </div>
+    
+    <script>
+     mocha.setup('bdd')
+     const assert = chai.assert;
+     
+     describe("resource-display", () => {
+         let elem;
+         let graph;
+         beforeEach((done) => {
+             elem = document.querySelector("#elem");
+             window.elem = elem;
+             graph = document.querySelector("#graph");
+             graph.graph.clearGraph();
+             graph.graph.loadTrig(`
+      @prefix : <http://example.com/> .
+      @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+      :a rdfs:label "label a" :ctx .
+      :b rdfs:label "label b" :ctx .
+      `, done);
+         });
+         const assertLabelTextEquals = (expected) => {
+             assert.equal(elem.shadowRoot.querySelector("#uri").innerText,
+                          expected);
+             
+         };
+         describe('link display', () => {
+         it("says no uri", () => {
+             elem.setAttribute('uri', '');
+             assertLabelTextEquals("<no uri>");
+         });
+         it("has no link when there's no uri", () => {
+             elem.setAttribute('uri', '');
+             assert.equal(elem.shadowRoot.querySelector("#uri").href,
+                          'javascript:;');
+         });
+         it("shows uri's label if graph has one", () => {
+             elem.setAttribute('uri', 'http://example.com/a');
+             assertLabelTextEquals("label a");
+         });
+         it("links to uri", () => {
+             elem.setAttribute('uri', 'http://example.com/a');
+             assert.equal(elem.shadowRoot.querySelector("#uri").href,
+                          'http://example.com/a');
+         });
+         it("falls back to uri tail if there's no label", () => {
+             elem.setAttribute('uri', 'http://example.com/nolabel');
+             assertLabelTextEquals("nolabel");
+         });
+         it("falls back to full uri if the tail would be empty", () => {
+             elem.setAttribute('uri', 'http://example.com/');
+             assertLabelTextEquals('http://example.com/');
+
+         });
+         it("changes the label if the graph updates uri's label", () => {
+             const g = graph.graph;
+             elem.setAttribute('uri', 'http://example.com/a');
+
+             g.patchObject(g.Uri('http://example.com/a'),
+                           g.Uri('rdfs:label'),
+                           g.Literal('new label'));
+             assertLabelTextEquals('new label');
+
+         });
+         it("changes the label if the uri changes", (done) => {
+             elem.setAttribute('uri', 'http://example.com/a');
+             setTimeout(() => {
+                 elem.setAttribute('uri', 'http://example.com/b');
+                 assertLabelTextEquals('label b');
+                 done();
+             }, 100);
+         });
+         });
+         describe('type icons', () => {
+             it("omits icon for unknown type");
+             it("uses icon uri from graph and shows the icon");
+         });
+         describe('rename ui', () => {
+             it("shows rename button if caller wants");
+             it("opens dialog when you click rename");
+             it("shows old label in dialog, ready to be replaced");
+             it("does nothing if you cancel");
+             it("patches the graph if you accept a new name");
+         });
+         
+     }); 
+     mocha.run();
+    </script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/show_specific.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,2 @@
+export const shortShow = "dance2023";
+export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/style.css	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,240 @@
+body {
+  background: black;
+  color: white;
+  --color-background: black;
+  --color-text: white;
+  font-family: sans-serif;
+}
+h1 {
+  margin: 0;
+}
+
+h2 {
+  margin: 0;
+  padding: 0;
+  font-size: 100%;
+}
+
+ul {
+  margin: 0;
+}
+
+a {
+  color: rgb(97, 97, 255);
+}
+
+input[type="text"] {
+  border: 1px inset rgb(177, 177, 177);
+  background: rgb(230, 230, 230);
+  padding: 3px;
+}
+
+#status {
+  position: fixed;
+  bottom: 0px;
+  right: 0px;
+  background: rgba(0, 0, 0, 0.47);
+  padding-left: 6px;
+}
+
+.songs {
+  column-width: 17em;
+}
+
+.songs button {
+  display: inline-block;
+  width: 100%;
+  min-height: 50px;
+  text-align: left;
+  background: black;
+  color: white;
+  margin: 2px;
+  font-size: 130% !important;
+  font-weight: bold;
+  text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000,
+    1px 1px 0 #000;
+}
+
+button a {
+  color: white;
+}
+
+.songs button:hover {
+  color: black;
+  background: #333;
+}
+
+.commands button {
+  background: black;
+  color: white;
+  padding: 20px;
+}
+
+.commands button.active {
+  background: #a90707;
+}
+
+.key {
+  color: #888;
+}
+
+div.keys {
+  margin-top: 10px;
+  padding: 5px;
+}
+
+.keyCap {
+  color: #ccc;
+  background: #525252;
+  display: inline-block;
+  border: 1px outset #b3b3b3;
+  padding: 2px 3px;
+  margin: 3px 0;
+  font-size: 16px;
+  box-shadow: 0.9px 0.9px 0px 2px #565656;
+  border-radius: 2px;
+}
+
+.currentSong button {
+  background: #a90707;
+}
+
+
+.stalled {
+  opacity: 0.5;
+}
+
+.num {
+  font-size: 27px;
+  color: rgb(233, 122, 122);
+  display: inline-block;
+  font-size: 200% !important;
+  font-weight: bold;
+  text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000,
+    1px 1px 0 #000;
+  float: left;
+}
+
+.dropTarget {
+  padding: 10px 5px;
+  border: 2px dashed gray;
+  font-style: italic;
+  color: rgb(78, 90, 107);
+}
+
+.dropTarget:hover {
+  background: #1f1f0d;
+}
+
+.twoColList {
+  -webkit-column-width: 24em;
+}
+
+.twoColList > li {
+  margin-bottom: 13px;
+}
+
+.song {
+  color: rgb(85, 221, 85);
+}
+
+.song:before {
+  content: "♫";
+  color: black;
+  background: rgb(85, 221, 85);
+  border-radius: 30%;
+}
+
+.effect:before {
+  content: "⛖";
+}
+
+.song:before,
+.effect:before {
+  margin-right: 3px;
+  text-decoration: none !important;
+  font-size: 140%;
+}
+
+/* ascoltami mini mode */
+@media (max-width: 600px) {
+  .songs {
+    column-width: 15em;
+  }
+  .songs button {
+    font-size: initial !important;
+    min-height: 35px !important;
+    width: 100%;
+    margin: initial;
+    border-width: 1px;
+    margin-bottom: 2px;
+  }
+  .num {
+    font-size: initial !important;
+    padding: initial !important;
+  }
+  .commands button {
+    padding: 5px;
+  }
+}
+
+/* subserver */
+.vari {
+  color: white;
+}
+
+.sub {
+  display: inline-block;
+  vertical-align: top;
+}
+
+.sub.local {
+  background: rgb(44, 44, 44);
+}
+
+.sub img {
+  width: 196px;
+  min-height: 40px;
+  margin: 0 6px;
+  background: -webkit-gradient(
+    linear,
+    right top,
+    left bottom,
+    color-stop(0, rgb(121, 120, 120)),
+    color-stop(1, rgb(54, 54, 54))
+  );
+}
+
+.chase {
+  background: rgb(75, 57, 72);
+}
+
+a button {
+  font-size: 60%;
+}
+
+a.big {
+  background-color: #384052;
+  padding: 6px;
+  text-shadow: rgba(0, 0, 0, 0.48) -1px -1px 0px;
+  color: rgb(172, 172, 255);
+  font-size: 160%;
+  margin: 0px;
+  display: inline-block;
+  border-radius: 5px;
+}
+
+table {
+  border-collapse: collapse;
+}
+
+table.borders td,
+table.borders th {
+  border: 1px solid #4a4a4a;
+  padding: 2px 8px;
+}
+
+hr {
+  width: 100%;
+  border-color: #1d3e1d;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/Note.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,286 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+# Maintains a pixi object, some adjusters, and inlineattrs corresponding to a note
+# in the graph.
+class Note
+  constructor: (@parentElem, @container, @project, @graph, @selection, @uri, @setAdjuster, @song, @viewState, @brickLayout) ->
+    @adjusterIds = new Set() # id string
+    @updateSoon = _.debounce(@update.bind(@), 30)
+
+  initWatchers: ->
+    @graph.runHandler(@update.bind(@), "note update #{@uri.value}")
+    ko.computed @update.bind(@)
+
+  destroy: ->
+    log('destroy', @uri.value)
+    @isDetached = true
+    @clearAdjusters()
+    @parentElem.updateInlineAttrs(@uri, null)
+
+  clearAdjusters: ->
+    @adjusterIds.forEach (i) =>
+      @setAdjuster(i, null)
+    @adjusterIds.clear()
+
+  getCurvePoints: (subj, curveAttr) ->
+    U = (x) => @graph.Uri(x)
+    originTime = @graph.floatValue(subj, U(':originTime'))
+
+    for curve in @graph.objects(subj, U(':curve'))
+      # todo: maybe shoudl be :effectAttr?
+      if @graph.uriValue(curve, U(':attr')).equals(curveAttr)
+        return @project.getCurvePoints(curve, originTime)
+    throw new Error("curve #{@uri.value} has no attr #{curveAttr.value}")
+
+  midPoint: (i0, i1) ->
+    p0 = @worldPts[i0]
+    p1 = @worldPts[i1]
+    p0.x(.5).add(p1.x(.5))
+
+  _planDrawing: ->
+    U = (x) => @graph.Uri(x)
+    [pointUris, worldPts] = @getCurvePoints(@uri, U(':strength'))
+    effect = @graph.uriValue(@uri, U(':effectClass'))
+
+    yForV = @brickLayout.yForVFor(@)
+    dependOn = [@viewState.zoomSpec.t1(),
+                @viewState.zoomSpec.t2(),
+                @viewState.width()]
+    screenPts = (new PIXI.Point(@viewState.zoomInX(pt.e(1)),
+                                yForV(pt.e(2))) for pt in worldPts)
+    return {
+      yForV: yForV
+      worldPts: worldPts
+      screenPts: screenPts
+      effect: effect
+      hover: @uri.equals(@selection.hover())
+      selected: @selection.selected().filter((s) => s.equals(@uri)).length
+    }
+
+  onRowChange: ->
+    @clearAdjusters()
+    @updateSoon()
+
+  redraw: (params) ->
+    # no observable or graph deps in here
+    @container.removeChildren()
+    @graphics = new PIXI.Graphics({nativeLines: false})
+    @graphics.interactive = true
+    @container.addChild(@graphics)
+
+    if params.hover
+      @_traceBorder(params.screenPts, 12, 0x888888)
+    if params.selected
+      @_traceBorder(params.screenPts, 6, 0xff2900)
+
+    shape = new PIXI.Polygon(params.screenPts)
+    @graphics.beginFill(@_noteColor(params.effect), .313)
+    @graphics.drawShape(shape)
+    @graphics.endFill()
+
+    @_traceBorder(params.screenPts, 2, 0xffd900)
+
+    @_addMouseBindings()
+    
+                 
+  update: ->
+    if not @parentElem.isActiveNote(@uri)
+      # stale redraw call
+      return
+
+    if @worldPts
+      @brickLayout.setNoteSpan(@, @worldPts[0].e(1),
+                               @worldPts[@worldPts.length - 1].e(1))
+
+    params = @_planDrawing()
+    @worldPts = params.worldPts
+
+    @redraw(params)
+
+    curveWidthCalc = () => @project.curveWidth(@worldPts)
+    @_updateAdjusters(params.screenPts, @worldPts, curveWidthCalc,
+                      params.yForV, @viewState.zoomInX, @song)
+    @_updateInlineAttrs(params.screenPts, params.yForV)
+    @parentElem.noteDirty()
+
+  _traceBorder: (screenPts, thick, color) ->
+    @graphics.lineStyle(thick, color, 1)
+    @graphics.moveTo(screenPts[0].x, screenPts[0].y)
+    for p in screenPts.slice(1)
+      @graphics.lineTo(p.x, p.y)
+
+  _addMouseBindings: () ->
+    @graphics.on 'mousedown', (ev) =>
+      @_onMouseDown(ev)
+
+    @graphics.on 'mouseover', =>
+      if @selection.hover() and @selection.hover().equals(@uri)
+        # Hovering causes a redraw, which would cause another
+        # mouseover event.
+        return
+      @selection.hover(@uri)
+
+    # mouseout never fires since we rebuild the graphics on mouseover.
+    @graphics.on 'mousemove', (ev) =>
+      if @selection.hover() and @selection.hover().equals(@uri) and ev.target != @graphics
+        @selection.hover(null)
+
+  onUri: ->
+    @graph.runHandler(@update.bind(@), "note updates #{@uri}")
+
+  patchCouldAffectMe: (patch) ->
+    if patch and patch.addQuads # sometimes patch is a polymer-sent value. @update is used as a listener too
+      if patch.addQuads.length == patch.delQuads.length == 1
+        add = patch.addQuads[0]
+        del = patch.delQuads[0]
+        if (add.predicate.equals(del.predicate) and del.predicate.equals(@graph.Uri(':time')) and add.subject.equals(del.subject))
+          timeEditFor = add.subject
+          if @worldPts and timeEditFor not in @pointUris
+            return false
+    return true
+
+  xupdate: (patch) ->
+    # update our note DOM and SVG elements based on the graph
+    if not @patchCouldAffectMe(patch)
+      # as autodep still fires all handlers on all patches, we just
+      # need any single dep to cause another callback. (without this,
+      # we would no longer be registered at all)
+      @graph.subjects(@uri, @uri, @uri)
+      return
+    if @isDetached?
+      return
+
+    @_updateDisplay()
+
+  _updateAdjusters: (screenPts, worldPts, curveWidthCalc, yForV, zoomInX, ctx) ->
+    # todo: allow offset even on more narrow notes
+    if screenPts[screenPts.length - 1].x - screenPts[0].x < 100 or screenPts[0].x > @parentElem.offsetWidth or screenPts[screenPts.length - 1].x < 0
+      @clearAdjusters()
+    else
+      @_makeOffsetAdjuster(yForV, curveWidthCalc, ctx)
+      @_makeCurvePointAdjusters(yForV, worldPts, ctx)
+      @_makeFadeAdjusters(yForV, zoomInX, ctx, worldPts)
+
+  _updateInlineAttrs: (screenPts, yForV) ->
+    w = 280
+
+    leftX = Math.max(2, screenPts[Math.min(1, screenPts.length - 1)].x + 5)
+    rightX = screenPts[Math.min(2, screenPts.length - 1)].x - 5
+    if screenPts.length < 3
+      rightX = leftX + w
+
+    if rightX - leftX < w or rightX < w or leftX > @parentElem.offsetWidth
+      @parentElem.updateInlineAttrs(@uri, null)
+      return
+
+    config = {
+      uri: @uri,
+      left: leftX,
+      top: yForV(1) + 5,
+      width: w,
+      height: yForV(0) - yForV(1) - 15,
+      }
+
+    @parentElem.updateInlineAttrs(@uri, config)
+
+  _makeCurvePointAdjusters: (yForV, worldPts, ctx) ->
+    for pointNum in [0...worldPts.length]
+      @_makePointAdjuster(yForV, worldPts, pointNum, ctx)
+
+  _makePointAdjuster: (yForV, worldPts, pointNum, ctx) ->
+    U = (x) => @graph.Uri(x)
+
+    adjId = @uri.value + '/p' + pointNum
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      adj = new AdjustableFloatObject({
+        graph: @graph
+        subj: worldPts[pointNum].uri
+        pred: U(':time')
+        ctx: ctx
+        getTargetPosForValue: (value) =>
+          $V([@viewState.zoomInX(value), yForV(worldPts[pointNum].e(2))])
+        getValueForPos: (pos) =>
+          origin = @graph.floatValue(@uri, U(':originTime'))
+          (@viewState.zoomInX.invert(pos.e(1)) - origin)
+        getSuggestedTargetOffset: () => @_suggestedOffset(worldPts[pointNum]),
+      })
+      adj._getValue = (=>
+        # note: don't use originTime from the closure- we need the
+        # graph dependency
+        adj._currentValue + @graph.floatValue(@uri, U(':originTime'))
+        )
+      adj
+
+  _makeOffsetAdjuster: (yForV, curveWidthCalc, ctx) ->
+    U = (x) => @graph.Uri(x)
+
+    adjId = @uri.value + '/offset'
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      adj = new AdjustableFloatObject({
+        graph: @graph
+        subj: @uri
+        pred: U(':originTime')
+        ctx: ctx
+        getDisplayValue: (v, dv) => "o=#{dv}"
+        getTargetPosForValue: (value) =>
+          # display bug: should be working from pt[0].t, not from origin
+          $V([@viewState.zoomInX(value + curveWidthCalc() / 2), yForV(.5)])
+        getValueForPos: (pos) =>
+          @viewState.zoomInX.invert(pos.e(1)) - curveWidthCalc() / 2
+        getSuggestedTargetOffset: () => $V([-10, 0])
+      })
+      adj
+
+  _makeFadeAdjusters: (yForV, zoomInX, ctx, worldPts) ->
+    U = (x) => @graph.Uri(x)
+    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeIn', 0, 1, $V([-50, -10]))
+    n = worldPts.length
+    @_makeFadeAdjuster(yForV, zoomInX, ctx, @uri.value + '/fadeOut', n - 2, n - 1, $V([50, -10]))
+
+  _makeFadeAdjuster: (yForV, zoomInX, ctx, adjId, i0, i1, offset) ->
+    @adjusterIds.add(adjId)
+    @setAdjuster adjId, =>
+      new AdjustableFade(yForV, zoomInX, i0, i1, @, offset, ctx)
+
+  _suggestedOffset: (pt) ->
+    if pt.e(2) > .5
+      $V([0, 30])
+    else
+      $V([0, -30])
+
+  _onMouseDown: (ev) ->
+    sel = @selection.selected()
+    if ev.data.originalEvent.ctrlKey
+      if @uri in sel
+        sel = _.without(sel, @uri)
+      else
+        sel.push(@uri)
+    else
+      sel = [@uri]
+    @selection.selected(sel)
+
+  _noteColor: (effect) ->
+    effect = effect.value
+    if effect in ['http://light9.bigasterisk.com/effect/blacklight',
+                  'http://light9.bigasterisk.com/effect/strobewarm']
+      hue = 0
+      sat = 100
+    else
+      hash = 0
+      for i in [(effect.length-10)...effect.length]
+        hash += effect.charCodeAt(i)
+      hue = (hash * 8) % 360
+      sat = 40 + (hash % 20) # don't conceal colorscale too much
+
+    return parseInt(tinycolor.fromRatio({h: hue / 360, s: sat / 100, l: .58}).toHex(), 16)
+
+    #elem = @getOrCreateElem(uri+'/label', 'noteLabels', 'text', {style: "font-size:13px;line-height:125%;font-family:'Verana Sans';text-align:start;text-anchor:start;fill:#000000;"})
+    #elem.setAttribute('x', curvePts[0].e(1)+20)
+    #elem.setAttribute('y', curvePts[0].e(2)-10)
+    #elem.innerHTML = effectLabel
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/Project.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,93 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+class Project
+  constructor: (@graph) ->
+
+  makeEffect: (uri) ->
+    U = (x) => @graph.Uri(x)
+    effect = U(uri.value + '/effect')
+    quad = (s, p, o) => @graph.Quad(s, p, o, effect)
+
+    quads = [
+      quad(effect, U('rdf:type'), U(':Effect')),
+      quad(effect, U(':copiedFrom'), uri),
+      quad(effect, U('rdfs:label'), @graph.Literal(uri.replace(/.*capture\//, ''))),
+      quad(effect, U(':publishAttr'), U(':strength')),
+      ]
+
+    fromSettings = @graph.objects(uri, U(':setting'))
+
+    toSettings = @graph.nextNumberedResources(effect + '_set', fromSettings.length)
+
+    for fs in fromSettings
+      ts = toSettings.pop()
+      # full copies of these since I may have to delete captures
+      quads.push(quad(effect, U(':setting'), ts))
+      quads.push(quad(ts, U(':device'), @graph.uriValue(fs, U(':device'))))
+      quads.push(quad(ts, U(':deviceAttr'), @graph.uriValue(fs, U(':deviceAttr'))))
+      try
+        quads.push(quad(ts, U(':value'), @graph.uriValue(fs, U(':value'))))
+      catch
+        quads.push(quad(ts, U(':scaledValue'), @graph.uriValue(fs, U(':scaledValue'))))
+
+    @graph.applyAndSendPatch({delQuads: [], addQuads: quads})
+    return effect
+
+  makeNewNote: (song, effect, dropTime, desiredWidthT) ->
+    U = (x) => @graph.Uri(x)
+    quad = (s, p, o) => @graph.Quad(s, p, o, song)
+
+    newNote = @graph.nextNumberedResource("#{song.value}/n")
+    newCurve = @graph.nextNumberedResource("#{newNote.value}c")
+    points = @graph.nextNumberedResources("#{newCurve.value}p", 4)
+
+    curveQuads = [
+        quad(song, U(':note'), newNote)
+        quad(newNote, U('rdf:type'), U(':Note'))
+        quad(newNote, U(':originTime'), @graph.LiteralRoundedFloat(dropTime))
+        quad(newNote, U(':effectClass'), effect)
+        quad(newNote, U(':curve'), newCurve)
+        quad(newCurve, U('rdf:type'), U(':Curve'))
+        # todo: maybe shoudl be :effectAttr?
+        quad(newCurve, U(':attr'), U(':strength'))
+      ]
+
+    pointQuads = []
+    for i in [0...4]
+      pt = points[i]
+      pointQuads.push(quad(newCurve, U(':point'), pt))
+      pointQuads.push(quad(pt, U(':time'), @graph.LiteralRoundedFloat(i/3 * desiredWidthT)))
+      pointQuads.push(quad(pt, U(':value'), @graph.LiteralRoundedFloat(i == 1 or i == 2)))
+
+    patch = {
+      delQuads: []
+      addQuads: curveQuads.concat(pointQuads)
+    }
+    @graph.applyAndSendPatch(patch)
+
+  getCurvePoints: (curve, xOffset) ->
+    worldPts = []
+    uris = @graph.objects(curve, @graph.Uri(':point'))
+    for pt in uris
+      tm = @graph.floatValue(pt, @graph.Uri(':time'))
+      val = @graph.floatValue(pt, @graph.Uri(':value'))
+      v = $V([xOffset + tm, val])
+      v.uri = pt
+      worldPts.push(v)
+    worldPts.sort((a,b) -> a.e(1) - b.e(1))
+    return [uris, worldPts]
+
+  curveWidth: (worldPts) ->
+    tMin = @graph.floatValue(worldPts[0].uri, @graph.Uri(':time'))
+    tMax = @graph.floatValue(worldPts[3].uri, @graph.Uri(':time'))
+    tMax - tMin
+
+  deleteNote: (song, note, selection) ->
+    patch = {delQuads: [@graph.Quad(song, graph.Uri(':note'), note, song)], addQuads: []}
+    @graph.applyAndSendPatch(patch)
+    if note in selection.selected()
+      selection.selected(_.without(selection.selected(), note))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/TimeAxis.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+
+
+@customElement("light9-timeline-time-axis")
+class TimeAxis extends LitElement
+  @getter_properties:
+    viewState: { type: Object, notify: true, observer: "onViewState" }
+  onViewState: ->
+    ko.computed =>
+      dependOn = [@viewState.zoomSpec.t1(), @viewState.zoomSpec.t2()]
+      pxPerTick = 50
+      axis = d3.axisTop(@viewState.zoomInX).ticks(@viewState.width() / pxPerTick)
+      d3.select(@$.axis).call(axis)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/TimeZoomed.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,174 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+# plan: in here, turn all the notes into simple js objects with all
+# their timing data and whatever's needed for adjusters. From that, do
+# the brick layout. update only changing adjusters.
+@customElement('light9-timeline-time-zoomed')
+class TimeZoomed extends LitElement
+  @getter_properties:
+    graph: { type: Object, notify: true }
+    project: { type: Object }
+    selection: { type: Object, notify: true }
+    song: { type: String, notify: true }
+    viewState: { type: Object, notify: true }
+    inlineAttrConfigs: { type: Array, value: [] } # only for inlineattrs that should be displayed
+    imageSamples: { type: Array, value: [] }
+  @getter_observers: [
+    '_onGraph(graph, setAdjuster, song, viewState, project)',
+    'onZoom(viewState)',
+    '_onViewState(viewState)',
+  ]
+  constructor: ->
+    super()
+    @numRows = 6
+    @noteByUriStr = new Map()
+    @stage = new PIXI.Container()
+    @stage.interactive=true
+
+    @renderer = PIXI.autoDetectRenderer({
+      backgroundColor: 0x606060,
+      antialias: true,
+      forceCanvas: true,
+    })
+    @bg = new PIXI.Container()
+    @stage.addChild(@bg)
+
+    @dirty = _.debounce(@_repaint.bind(@), 10)
+
+  ready: ->
+    super.ready()
+
+    @imageSamples = ['one']
+
+    @addEventListener('iron-resize', @_onResize.bind(@))
+    Polymer.RenderStatus.afterNextRender(this, @_onResize.bind(@))
+
+    @$.rows.appendChild(@renderer.view)
+
+    # This works for display, but pixi hit events didn't correctly
+    # move with the objects, so as a workaround, I extended the top of
+    # the canvas in _onResize.
+    #
+    #ko.computed =>
+    #  @stage.setTransform(0, -(@viewState.rowsY()), 1, 1, 0, 0, 0, 0, 0)
+
+  _onResize: ->
+    @$.rows.firstChild.style.position = 'relative'
+    @$.rows.firstChild.style.top = -@viewState.rowsY() + 'px'
+
+    @renderer.resize(@clientWidth, @clientHeight + @viewState.rowsY())
+
+    @dirty()
+
+  _onGraph: (graph, setAdjuster, song, viewState, project)->
+    return unless @song # polymer will call again
+    @graph.runHandler(@gatherNotes.bind(@), 'zoom notes')
+
+  _onViewState: (viewState) ->
+    @brickLayout = new BrickLayout(@viewState, @numRows)
+
+  noteDirty: -> @dirty()
+    
+  onZoom: ->
+    updateZoomFlattened = ->
+      @zoomFlattened = ko.toJS(@viewState.zoomSpec)
+    ko.computed(updateZoomFlattened.bind(@))
+
+  gatherNotes: ->
+    U = (x) => @graph.Uri(x)
+    return unless @song?
+    songNotes = @graph.objects(U(@song), U(':note'))
+
+    toRemove = new Set(@noteByUriStr.keys())
+    
+    for uri in @graph.sortedUris(songNotes)
+      had = toRemove.delete(uri.value)
+      if not had
+        @_addNote(uri)
+
+    toRemove.forEach @_delNote.bind(@)
+
+    @dirty()
+
+  isActiveNote: (note) -> @noteByUriStr.has(note.value)
+
+  _repaint: ->
+    @_drawGrid()  
+    @renderer.render(@stage)
+
+  _drawGrid: ->
+    # maybe someday this has snappable timing markers too
+    @bg.removeChildren()
+    gfx = new PIXI.Graphics()
+    @bg.addChild(gfx)
+
+    gfx.lineStyle(1, 0x222222, 1)
+    for row in [0...@numRows]
+      y = @brickLayout.rowBottom(row)
+      gfx.moveTo(0, y)
+      gfx.lineTo(@clientWidth, y)
+
+  _addNote: (uri) ->
+    U = (x) => @graph.Uri(x)
+    
+    con = new PIXI.Container()
+    con.interactive=true
+    @stage.addChild(con)
+    
+    note = new Note(@, con, @project, @graph, @selection, uri, @setAdjuster, U(@song), @viewState, @brickLayout)
+    # this must come before the first Note.draw
+    @noteByUriStr.set(uri.value, note)
+    @brickLayout.addNote(note, note.onRowChange.bind(note))
+    note.initWatchers()
+
+  _delNote: (uriStr) ->
+    n = @noteByUriStr.get(uriStr)
+    @brickLayout.delNote(n)
+    @stage.removeChild(n.container)
+    n.destroy()
+    @noteByUriStr.delete(uriStr)
+            
+  onDrop: (effect, pos) ->
+    U = (x) => @graph.Uri(x)
+
+    return unless effect and effect.match(/^http/)
+
+    # we could probably accept some initial overrides right on the
+    # effect uri, maybe as query params
+
+    if not @graph.contains(effect, U('rdf:type'), U(':Effect'))
+      if @graph.contains(effect, U('rdf:type'), U(':LightSample'))
+        effect = @project.makeEffect(effect)
+      else
+        log("drop #{effect} is not an effect")
+        return
+
+    dropTime = @viewState.zoomInX.invert(pos.e(1))
+
+    desiredWidthX = @offsetWidth * .3
+    desiredWidthT = @viewState.zoomInX.invert(desiredWidthX) - @viewState.zoomInX.invert(0)
+    desiredWidthT = Math.min(desiredWidthT, @viewState.zoomSpec.duration() - dropTime)
+    @project.makeNewNote(U(@song), U(effect), dropTime, desiredWidthT)
+
+  updateInlineAttrs: (note, config) ->
+    if not config?
+      index = 0
+      for c in @inlineAttrConfigs
+        if c.uri.equals(note)
+          @splice('inlineAttrConfigs', index)
+          return
+        index += 1
+    else
+      index = 0
+      for c in @inlineAttrConfigs
+        if c.uri.equals(note)
+          @splice('inlineAttrConfigs', index, 1, config)
+          return
+        index += 1
+      @push('inlineAttrConfigs', config)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/TimelineEditor.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,194 @@
+log = debug('timeline')
+debug.enable('*')
+
+Drawing = window.Drawing
+ROW_COUNT = 7
+
+
+@customElement('light9-timeline-editor')
+class TimelineEditor extends LitElement
+  @getter_properties:
+    viewState: { type: Object }
+    debug: {type: String}
+    graph: {type: Object, notify: true}
+    project: {type: Object}
+    setAdjuster: {type: Function, notify: true}
+    playerSong: {type: String, notify: true}
+    followPlayerSong: {type: Boolean, notify: true, value: true}
+    song: {type: String, notify: true}
+    show: {type: String, notify: true}
+    songTime: {type: Number, notify: true}
+    songDuration: {type: Number, notify: true}
+    songPlaying: {type: Boolean, notify: true}
+    selection: {type: Object, notify: true}
+  @getter_observers: [
+    '_onSong(playerSong, followPlayerSong)',
+    '_onGraph(graph)',
+    '_onSongDuration(songDuration, viewState)',
+    '_onSongTime(song, playerSong, songTime, viewState)',
+    '_onSetAdjuster(setAdjuster)',
+  ]
+  constructor: ->
+    super()
+    @viewState = new ViewState()
+    window.viewState = @viewState
+
+  ready: ->
+    super.ready()
+    @addEventListener 'mousedown', (ev) => @$.adjustersCanvas.onDown(ev)
+    @addEventListener 'mousemove', (ev) => @$.adjustersCanvas.onMove(ev)
+    @addEventListener 'mouseup', (ev) => @$.adjustersCanvas.onUp(ev)
+
+    ko.options.deferUpdates = true
+
+    @selection = {hover: ko.observable(null), selected: ko.observable([])}
+
+    window.debug_zoomOrLayoutChangedCount = 0
+    window.debug_adjUpdateDisplay = 0
+
+    ko.computed(@zoomOrLayoutChanged.bind(@))
+
+    @trackMouse()
+    @bindKeys()
+    @bindWheelZoom(@)
+
+    setInterval(@updateDebugSummary.bind(@), 100)
+
+    @addEventListener('iron-resize', @_onIronResize.bind(@))
+    Polymer.RenderStatus.afterNextRender(this, @_onIronResize.bind(@))
+
+    Polymer.RenderStatus.afterNextRender this, =>
+      setupDrop(@$.zoomed.$.rows, @$.zoomed.$.rows, @, @$.zoomed.onDrop.bind(@$.zoomed))
+
+  _onIronResize: ->
+    @viewState.setWidth(@offsetWidth)
+    @viewState.coveredByDiagramTop(@$.coveredByDiagram.offsetTop)
+    @viewState.rowsY(@$.zoomed.$.rows.offsetTop) if @$.zoomed?.$?.rows?
+    @viewState.audioY(@$.audio.offsetTop)
+    @viewState.audioH(@$.audio.offsetHeight)
+    if @$.zoomed?.$?.time?
+      @viewState.zoomedTimeY(@$.zoomed.$.time.offsetTop)
+      @viewState.zoomedTimeH(@$.zoomed.$.time.offsetHeight)
+
+  _onSongTime: (song, playerSong, t) ->
+    if song != playerSong
+      @viewState.cursor.t(0)
+      return
+    @viewState.cursor.t(t)
+
+  _onSongDuration: (d) ->
+    d = 700 if d < 1 # bug is that asco isn't giving duration, but 0 makes the scale corrupt
+    @viewState.zoomSpec.duration(d)
+
+  _onSong: (s) ->
+    @song = @playerSong if @followPlayerSong
+
+  _onGraph: (graph) ->
+    @project = new Project(graph)
+    @show = showRoot
+
+  _onSetAdjuster: () ->
+    @makeZoomAdjs()
+
+  updateDebugSummary: ->
+    elemCount = (tag) -> document.getElementsByTagName(tag).length
+    @debug = "#{window.debug_zoomOrLayoutChangedCount} layout change,
+     #{elemCount('light9-timeline-note')} notes,
+     #{@selection.selected().length} selected
+     #{elemCount('light9-timeline-graph-row')} rows,
+     #{window.debug_adjsCount} adjuster items registered,
+     #{window.debug_adjUpdateDisplay} adjuster updateDisplay calls,
+    "
+
+  zoomOrLayoutChanged: ->
+    vs = @viewState
+    dependOn = [vs.zoomSpec.t1(), vs.zoomSpec.t2(), vs.width()]
+
+    # shouldn't need this- deps should get it
+    @$.zoomed.gatherNotes() if @$.zoomed?.gatherNotes?
+
+    # todo: these run a lot of work purely for a time change
+    if @$.zoomed?.$?.audio?
+      #@dia.setTimeAxis(vs.width(), @$.zoomed.$.audio.offsetTop, vs.zoomInX)
+      @$.adjustersCanvas.updateAllCoords()
+
+  trackMouse: ->
+    # not just for show- we use the mouse pos sometimes
+    for evName in ['mousemove', 'touchmove']
+      @addEventListener evName, (ev) =>
+        ev.preventDefault()
+
+        # todo: consolidate with _editorCoordinates version
+        if ev.touches?.length
+          ev = ev.touches[0]
+
+        root = @$.cursorCanvas.getBoundingClientRect()
+        @viewState.mouse.pos($V([ev.pageX - root.left, ev.pageY - root.top]))
+
+        # should be controlled by a checkbox next to follow-player-song-choice
+        @sendMouseToVidref() unless window.location.hash.match(/novidref/)
+
+  sendMouseToVidref: ->
+    now = Date.now()
+    if (!@$.vidrefLastSent? || @$.vidrefLastSent < now - 200) && !@songPlaying
+      @$.vidrefTime.body = {t: @viewState.latestMouseTime(), source: 'timeline', song: @song}
+      @$.vidrefTime.generateRequest()
+      @$.vidrefLastSent = now
+
+  bindWheelZoom: (elem) ->
+    elem.addEventListener 'mousewheel', (ev) =>
+      @viewState.onMouseWheel(ev.deltaY)
+
+  bindKeys: ->
+    shortcut.add "Ctrl+P", (ev) =>
+      @$.music.seekPlayOrPause(@viewState.latestMouseTime())
+    shortcut.add "Ctrl+Escape", => @viewState.frameAll()
+    shortcut.add "Shift+Escape", => @viewState.frameToEnd()
+    shortcut.add "Escape", => @viewState.frameCursor()
+    shortcut.add "L", =>
+      @$.adjustersCanvas.updateAllCoords()
+    shortcut.add 'Delete', =>
+      for note in @selection.selected()
+        @project.deleteNote(@graph.Uri(@song), note, @selection)
+
+  makeZoomAdjs: ->
+    yMid = => @$.audio.offsetTop + @$.audio.offsetHeight / 2
+
+    valForPos = (pos) =>
+      x = pos.e(1)
+      t = @viewState.fullZoomX.invert(x)
+    @setAdjuster('zoom-left', => new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t1,
+      getTarget: () =>
+        $V([@viewState.fullZoomX(@viewState.zoomSpec.t1()), yMid()])
+      getSuggestedTargetOffset: () => $V([-50, 10])
+      getValueForPos: valForPos
+    }))
+
+    @setAdjuster('zoom-right', => new AdjustableFloatObservable({
+      observable: @viewState.zoomSpec.t2,
+      getTarget: () =>
+        $V([@viewState.fullZoomX(@viewState.zoomSpec.t2()), yMid()])
+      getSuggestedTargetOffset: () => $V([50, 10])
+      getValueForPos: valForPos
+    }))
+
+    panObs = ko.pureComputed({
+      read: () =>
+        (@viewState.zoomSpec.t1() + @viewState.zoomSpec.t2()) / 2
+      write: (value) =>
+        zs = @viewState.zoomSpec
+        span = zs.t2() - zs.t1()
+        zs.t1(value - span / 2)
+        zs.t2(value + span / 2)
+    })
+
+    @setAdjuster('zoom-pan', => new AdjustableFloatObservable({
+      observable: panObs
+      emptyBox: true
+      # fullzoom is not right- the sides shouldn't be able to go
+      # offscreen
+      getTarget: () => $V([@viewState.fullZoomX(panObs()), yMid()])
+      getSuggestedTargetOffset: () => $V([0, 0])
+      getValueForPos: valForPos
+    }))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/adjustable.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,269 @@
+import * as d3 from "d3";
+import { debug } from "debug";
+import * as ko from "knockout";
+const log = debug("adjustable");
+
+interface Config {
+  //   getTarget -> vec2 of current target position
+  getTarget: () => Vector;
+  //   getSuggestedTargetOffset -> vec2 pixel offset from target
+  getSuggestedTargetOffset: () => Vector;
+  //   emptyBox -> true if you want no value display
+  emptyBox: boolean;
+}
+
+export class Adjustable {
+  config: any;
+  handle: any;
+  initialTarget: any;
+  targetDraggedTo: any;
+  root: any;
+  // Some value you can edit in the UI, probably by dragging
+  // stuff. Drawn by light9-adjusters-canvas. This object does the
+  // layout and positioning.
+  //
+  // The way dragging should work is that you start in the yellow *adj
+  // widget*, wherever it is, but your drag is moving the *target*. The
+  // adj will travel around too, but it may do extra moves to not bump
+  // into stuff or to get out from under your finger.
+
+  constructor(config: any) {
+    this.config = config;
+    this.ctor2();
+  }
+
+  ctor2() {
+    // updated later by layout algoritm
+    return (this.handle = $V([0, 0]));
+  }
+
+  getDisplayValue() {
+    if (this.config.emptyBox) {
+      return "";
+    }
+    const defaultFormat = d3.format(".4g")(this._getValue());
+    if (this.config.getDisplayValue != null) {
+      return this.config.getDisplayValue(this._getValue(), defaultFormat);
+    }
+    return defaultFormat;
+  }
+  _getValue(): any {
+    throw new Error("Method not implemented.");
+  }
+
+  getSuggestedHandle() {
+    return this.getTarget().add(this.config.getSuggestedTargetOffset());
+  }
+
+  getHandle() {
+    // vec2 of pixels
+    return this.handle;
+  }
+
+  getTarget() {
+    // vec2 of pixels
+    return this.config.getTarget();
+  }
+
+  subscribe(onChange: any) {
+    // change could be displayValue or center or target. This likely
+    // calls onChange right away if there's any data yet.
+    throw new Error("not implemented");
+  }
+
+  startDrag() {
+    return (this.initialTarget = this.getTarget());
+  }
+
+  continueDrag(pos: { add: (arg0: any) => any }) {
+    //# pos is vec2 of pixels relative to the drag start
+    return (this.targetDraggedTo = pos.add(this.initialTarget));
+  }
+
+  endDrag() {}
+  // override
+
+  _editorCoordinates() {
+    // vec2 of mouse relative to <l9-t-editor>
+    let rootElem: { getBoundingClientRect: () => any };
+    return this.targetDraggedTo;
+    // let ev = d3.event.sourceEvent;
+
+    // if (ev.target.tagName === "LIGHT9-TIMELINE-EDITOR") {
+    //   rootElem = ev.target;
+    // } else {
+    //   rootElem = ev.target.closest("light9-timeline-editor");
+    // }
+
+    // if (ev.touches != null ? ev.touches.length : undefined) {
+    //   ev = ev.touches[0];
+    // }
+
+    // // storing root on the object to remember it across calls in case
+    // // you drag outside the editor.
+    // if (rootElem) {
+    //   this.root = rootElem.getBoundingClientRect();
+    // }
+    // const offsetParentPos = $V([ev.pageX - this.root.left, ev.pageY - this.root.top]);
+
+    // return offsetParentPos;
+  }
+}
+
+class AdjustableFloatObservable extends Adjustable {
+  constructor(config: any) {
+    // config also has:
+    //   observable -> ko.observable we will read and write
+    //   getValueForPos(pos) -> what should we set to if the user
+    //                          moves target to this coord?
+    this.config = config;
+    super();
+    this.ctor2();
+  }
+
+  _getValue() {
+    return this.config.observable();
+  }
+
+  continueDrag(pos: any) {
+    // pos is vec2 of pixels relative to the drag start.
+    super.continueDrag(pos);
+    const epos = this._editorCoordinates();
+    const newValue = this.config.getValueForPos(epos);
+    return this.config.observable(newValue);
+  }
+
+  subscribe(onChange: () => any) {
+    log("AdjustableFloatObservable subscribe", this.config);
+    return ko.computed(() => {
+      this.config.observable();
+      return onChange();
+    });
+  }
+}
+
+class AdjustableFloatObject extends Adjustable {
+  _currentValue: any;
+  _onChange: any;
+  constructor(config: any) {
+    // config also has:
+    //   graph
+    //   subj
+    //   pred
+    //   ctx
+    //   getTargetPosForValue(value) -> getTarget result for value
+    //   getValueForPos
+    this.config = config;
+    super();
+    this.ctor2();
+    if (this.config.ctx == null) {
+      throw new Error("missing ctx");
+    }
+    // this seems to not fire enough.
+    this.config.graph.runHandler(this._syncValue.bind(this), `adj sync ${this.config.subj.value} ${this.config.pred.value}`);
+  }
+
+  _syncValue() {
+    this._currentValue = this.config.graph.floatValue(this.config.subj, this.config.pred);
+    if (this._onChange) {
+      return this._onChange();
+    }
+  }
+
+  _getValue() {
+    // this is a big speedup- callers use _getValue about 4x as much as
+    // the graph changes and graph.floatValue is slow
+    return this._currentValue;
+  }
+
+  getTarget() {
+    return this.config.getTargetPosForValue(this._getValue());
+  }
+
+  subscribe(onChange: any) {
+    // only works on one subscription at a time
+    if (this._onChange) {
+      throw new Error("multi subscribe not implemented");
+    }
+    return (this._onChange = onChange);
+  }
+
+  continueDrag(pos: any) {
+    // pos is vec2 of pixels relative to the drag start
+    super.continueDrag(pos);
+    const newValue = this.config.getValueForPos(this._editorCoordinates());
+
+    return this.config.graph.patchObject(this.config.subj, this.config.pred, this.config.graph.LiteralRoundedFloat(newValue), this.config.ctx);
+    //@_syncValue()
+  }
+}
+
+class AdjustableFade extends Adjustable {
+  yForV: any;
+  zoomInX: any;
+  i0: any;
+  i1: any;
+  note: any;
+  constructor(yForV: any, zoomInX: any, i0: any, i1: any, note: any, offset: any, ctx: any) {
+    this.yForV = yForV;
+    this.zoomInX = zoomInX;
+    this.i0 = i0;
+    this.i1 = i1;
+    this.note = note;
+    super();
+    this.config = {
+      getSuggestedTargetOffset() {
+        return offset;
+      },
+      getTarget: this.getTarget.bind(this),
+      ctx,
+    };
+    this.ctor2();
+  }
+
+  getTarget() {
+    const mid = this.note.midPoint(this.i0, this.i1);
+    return $V([this.zoomInX(mid.e(1)), this.yForV(mid.e(2))]);
+  }
+
+  _getValue() {
+    return this.note.midPoint(this.i0, this.i1).e(1);
+  }
+
+  continueDrag(pos: { e: (arg0: number) => any }) {
+    // pos is vec2 of pixels relative to the drag start
+    super.continueDrag(pos);
+    const { graph } = this.note;
+    const U = (x: string) => graph.Uri(x);
+
+    const goalCenterSec = this.zoomInX.invert(this.initialTarget.e(1) + pos.e(1));
+
+    const diamSec = this.note.worldPts[this.i1].e(1) - this.note.worldPts[this.i0].e(1);
+    const newSec0 = goalCenterSec - diamSec / 2;
+    const newSec1 = goalCenterSec + diamSec / 2;
+
+    const originSec = graph.floatValue(this.note.uri, U(":originTime"));
+
+    const p0 = this._makePatch(graph, this.i0, newSec0, originSec, this.config.ctx);
+    const p1 = this._makePatch(graph, this.i1, newSec1, originSec, this.config.ctx);
+
+    return graph.applyAndSendPatch(this._addPatches(p0, p1));
+  }
+
+  _makePatch(
+    graph: { getObjectPatch: (arg0: any, arg1: any, arg2: any, arg3: any) => any; Uri: (arg0: string) => any; LiteralRoundedFloat: (arg0: number) => any },
+    idx: string | number,
+    newSec: number,
+    originSec: number,
+    ctx: any
+  ) {
+    return graph.getObjectPatch(this.note.worldPts[idx].uri, graph.Uri(":time"), graph.LiteralRoundedFloat(newSec - originSec), ctx);
+  }
+
+  _addPatches(p0: { addQuads: { concat: (arg0: any) => any }; delQuads: { concat: (arg0: any) => any } }, p1: { addQuads: any; delQuads: any }) {
+    return {
+      addQuads: p0.addQuads.concat(p1.addQuads),
+      delQuads: p0.delQuads.concat(p1.delQuads),
+    };
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/adjusters.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,273 @@
+import { debug } from "debug";
+import { LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import { throttle } from "underscore";
+import * as d3 from "d3";
+import { Adjustable } from "./adjustable";
+import * as Drawing from "../drawing";
+// https://www.npmjs.com/package/@types/sylvester Global values: $L, $M, $P, $V, Line, Matrix, Plane, Sylvester, Vector
+const log = debug("adjusters");
+
+const maxDist = 60;
+
+interface Drag {
+  start: Vector;
+  adj: Adjustable;
+  cur?: Vector;
+}
+type QTreeData = Vector & { adj: Adjustable };
+@customElement("light9-adjusters-canvas")
+class AdjustersCanvas extends LitElement {
+  static getter_properties: { setAdjuster: { type: any; notify: boolean } };
+  static getter_observers: {};
+  redraw: any;
+  adjs: { [id: string | number]: Adjustable };
+  hoveringNear: any;
+  ctx: any;
+  $: any;
+  setAdjuster: any;
+  offsetParent: any;
+  currentDrag?: Drag;
+  qt?: d3.Quadtree<QTreeData>;
+  canvasCenter: any;
+  static initClass() {
+    this.getter_properties = { setAdjuster: { type: Function, notify: true } };
+    this.getter_observers = ["updateAllCoords(adjs)"];
+  }
+  constructor() {
+    super();
+    this.redraw = throttle(this._throttledRedraw.bind(this), 30, { leading: false });
+    this.adjs = {};
+    this.hoveringNear = null;
+  }
+
+  ready() {
+    this.addEventListener("iron-resize", this.resizeUpdate.bind(this));
+    this.ctx = this.$.canvas.getContext("2d");
+
+    this.redraw();
+    this.setAdjuster = this._setAdjuster.bind(this);
+
+    // These don't fire; TimelineEditor calls the handlers for us.
+    this.addEventListener("mousedown", this.onDown.bind(this));
+    this.addEventListener("mousemove", this.onMove.bind(this));
+    return this.addEventListener("mouseup", this.onUp.bind(this));
+  }
+  addEventListener(arg0: string, arg1: any) {
+    throw new Error("Method not implemented.");
+  }
+
+  _mousePos(ev: MouseEvent) {
+    return $V([ev.clientX, ev.clientY - this.offsetParent.offsetTop]);
+  }
+
+  onDown(ev: MouseEvent) {
+    if (ev.buttons === 1) {
+      const start = this._mousePos(ev);
+      const adj = this._adjAtPoint(start);
+      if (adj) {
+        ev.stopPropagation();
+        this.currentDrag = { start, adj };
+        return adj.startDrag();
+      }
+    }
+  }
+
+  onMove(ev: MouseEvent) {
+    const pos = this._mousePos(ev);
+    if (this.currentDrag) {
+      this.hoveringNear = null;
+      this.currentDrag.cur = pos;
+      this.currentDrag.adj.continueDrag(this.currentDrag.cur.subtract(this.currentDrag.start));
+      this.redraw();
+    } else {
+      const near = this._adjAtPoint(pos);
+      if (this.hoveringNear !== near) {
+        this.hoveringNear = near;
+        this.redraw();
+      }
+    }
+  }
+
+  onUp(ev: any) {
+    if (!this.currentDrag) {
+      return;
+    }
+    this.currentDrag.adj.endDrag();
+    this.currentDrag = undefined;
+  }
+
+  _setAdjuster(adjId: string | number, makeAdjustable?: () => Adjustable) {
+    // callers register/unregister the Adjustables they want us to make
+    // adjuster elements for. Caller invents adjId.  makeAdjustable is
+    // a function returning the Adjustable or it is undefined to clear any
+    // adjusters with this id.
+    if (makeAdjustable == null) {
+      if (this.adjs[adjId]) {
+        delete this.adjs[adjId];
+      }
+    } else {
+      // this might be able to reuse an existing one a bit
+      const adj = makeAdjustable();
+      this.adjs[adjId] = adj;
+      adj.id = adjId;
+    }
+
+    this.redraw();
+
+    (window as any).debug_adjsCount = Object.keys(this.adjs).length;
+  }
+
+  updateAllCoords() {
+    this.redraw();
+  }
+
+  _adjAtPoint(pt: Vector): Adjustable|undefined {
+    const nearest = this.qt!.find(pt.e(1), pt.e(2));
+    if (nearest == null || nearest.distanceFrom(pt) > maxDist) {
+      return undefined;
+    }
+    return nearest != null ? nearest.adj : undefined;
+  }
+
+  resizeUpdate(ev: { target: { offsetWidth: any; offsetHeight: any } }) {
+    this.$.canvas.width = ev.target.offsetWidth;
+    this.$.canvas.height = ev.target.offsetHeight;
+    this.canvasCenter = $V([this.$.canvas.width / 2, this.$.canvas.height / 2]);
+    return this.redraw();
+  }
+
+  _throttledRedraw() {
+    if (this.ctx == null) {
+      return;
+    }
+    console.time("adjs redraw");
+    this._layoutCenters();
+
+    this.ctx.clearRect(0, 0, this.$.canvas.width, this.$.canvas.height);
+
+    for (let adjId in this.adjs) {
+      const adj = this.adjs[adjId];
+      const ctr = adj.getHandle();
+      const target = adj.getTarget();
+      if (this._isOffScreen(target)) {
+        continue;
+      }
+      this._drawConnector(ctr, target);
+
+      this._drawAdjuster(adj.getDisplayValue(), ctr.e(1) - 20, ctr.e(2) - 10, ctr.e(1) + 20, ctr.e(2) + 10, adj === this.hoveringNear);
+    }
+    return console.timeEnd("adjs redraw");
+  }
+
+  _layoutCenters() {
+    // push Adjustable centers around to avoid overlaps
+    // Todo: also don't overlap inlineattr boxes
+    // Todo: don't let their connector lines cross each other
+    const qt = d3.quadtree<QTreeData>(
+      [],
+      (d: QTreeData) => d.e(1),
+      (d: QTreeData) => d.e(2)
+    );
+    this.qt = qt;
+
+    qt.extent([
+      [0, 0],
+      [8000, 8000],
+    ]);
+
+    let _: string | number, adj: { handle: any; getSuggestedHandle: () => any };
+    for (_ in this.adjs) {
+      adj = this.adjs[_];
+      adj.handle = this._clampOnScreen(adj.getSuggestedHandle());
+    }
+
+    const numTries = 8;
+    for (let tryn = 0; tryn < numTries; tryn++) {
+      for (_ in this.adjs) {
+        adj = this.adjs[_];
+        let current = adj.handle;
+        qt.remove(current);
+        const nearest = qt.find(current.e(1), current.e(2), maxDist);
+        if (nearest) {
+          const dist = current.distanceFrom(nearest);
+          if (dist < maxDist) {
+            current = this._stepAway(current, nearest, 1 / numTries);
+            adj.handle = current;
+          }
+        }
+        current.adj = adj;
+        qt.add(current);
+      }
+    }
+    //if -50 < output.e(1) < 20 # mostly for zoom-left
+    //  output.setElements([
+    //    Math.max(20, output.e(1)),
+    //    output.e(2)])
+  }
+
+
+  _stepAway(
+    current: Vector,
+    nearest: Vector,
+    dx: number
+  ) {
+    const away = current.subtract(nearest).toUnitVector();
+    const toScreenCenter = this.canvasCenter.subtract(current).toUnitVector();
+    const goalSpacingPx = 20;
+    return this._clampOnScreen(current.add(away.x(goalSpacingPx * dx)));
+  }
+
+  _isOffScreen(pos: Vector):boolean {
+    return pos.e(1) < 0 || pos.e(1) > this.$.canvas.width || pos.e(2) < 0 || pos.e(2) > this.$.canvas.height;
+  }
+
+  _clampOnScreen(pos: Vector): Vector {
+    const marg = 30;
+    return $V([Math.max(marg, Math.min(this.$.canvas.width - marg, pos.e(1))), Math.max(marg, Math.min(this.$.canvas.height - marg, pos.e(2)))]);
+  }
+
+  _drawConnector(ctr: Vector, target: Vector) {
+    this.ctx.strokeStyle = "#aaa";
+    this.ctx.lineWidth = 2;
+    this.ctx.beginPath();
+    Drawing.line(this.ctx, ctr, target);
+    this.ctx.stroke();
+  }
+
+  _drawAdjuster(label: any, x1: number, y1: number, x2: number, y2: number, hover: boolean) {
+    const radius = 8;
+
+    this.ctx.shadowColor = "black";
+    this.ctx.shadowBlur = 15;
+    this.ctx.shadowOffsetX = 5;
+    this.ctx.shadowOffsetY = 9;
+
+    this.ctx.fillStyle = hover ? "#ffff88" : "rgba(255, 255, 0, 0.5)";
+    this.ctx.beginPath();
+    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
+    this.ctx.fill();
+
+    this.ctx.shadowColor = "rgba(0,0,0,0)";
+
+    this.ctx.strokeStyle = "yellow";
+    this.ctx.lineWidth = 2;
+    this.ctx.setLineDash([3, 3]);
+    this.ctx.beginPath();
+    Drawing.roundRect(this.ctx, x1, y1, x2, y2, radius);
+    this.ctx.stroke();
+    this.ctx.setLineDash([]);
+
+    this.ctx.font = "12px sans";
+    this.ctx.fillStyle = "#000";
+    this.ctx.fillText(label, x1 + 5, y2 - 5, x2 - x1 - 10);
+
+    // coords from a center that's passed in
+    // # special layout for the thaeter ones with middinh
+    // l/r arrows
+    // mouse arrow cursor upon hover, and accent the hovered adjuster
+    // connector
+  }
+}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/brick_layout.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,95 @@
+import { debug } from "debug";
+import { sortBy } from "underscore";
+import { ViewState } from "viewstate";
+const log = debug("brick");
+
+interface Placement {
+  row?: number;
+  prev?: number;
+  t0: number;
+  t1: number;
+  onRowChange: () => void;
+}
+
+export class BrickLayout {
+  viewState: ViewState;
+  numRows: number;
+  noteRow: { [uri: string]: Placement };
+  constructor(viewState: ViewState, numRows: number) {
+    this.viewState = viewState;
+    this.numRows = numRows;
+    this.noteRow = {}; // uristr: row, t0, t1, onRowChange
+  }
+
+  addNote(n: { uri: { value: string } }, onRowChange: any) {
+    this.noteRow[n.uri.value] = { row: 0, t0: 0, t1: 0, onRowChange };
+  }
+
+  setNoteSpan(n: { uri: { value: string } }, t0: any, t1: any) {
+    this.noteRow[n.uri.value].t0 = t0;
+    this.noteRow[n.uri.value].t1 = t1;
+    this._recompute();
+  }
+
+  delNote(n: { uri: { value: string } }) {
+    delete this.noteRow[n.uri.value];
+    this._recompute();
+  }
+
+  _recompute() {
+    for (let u in this.noteRow) {
+      const row = this.noteRow[u];
+      row.prev = row.row;
+      row.row = undefined;
+    }
+    const overlap = (a: Placement, b: Placement) => a.t0 < b.t1 && a.t1 > b.t0;
+
+    const result = [];
+    for (let u in this.noteRow) {
+      const row = this.noteRow[u];
+      result.push({ dur: row.t1 - row.t0 + row.t0 * 0.0001, uri: u });
+    }
+    const notesByWidth = sortBy(result, "dur");
+    notesByWidth.reverse();
+
+    for (let n of Array.from(notesByWidth)) {
+      const blockedRows = new Set();
+      for (let u in this.noteRow) {
+        const other = this.noteRow[u];
+        if (other.row !== null) {
+          if (overlap(other, this.noteRow[n.uri])) {
+            blockedRows.add(other.row);
+          }
+        }
+      }
+
+      for (let r = 0; r < this.numRows; r++) {
+        if (!blockedRows.has(r)) {
+          this.noteRow[n.uri].row = r;
+          break;
+        }
+      }
+      if (this.noteRow[n.uri].row === null) {
+        log(`warning: couldn't place ${n.uri}`);
+        this.noteRow[n.uri].row = 0;
+      }
+      if (this.noteRow[n.uri].row !== this.noteRow[n.uri].prev) {
+        this.noteRow[n.uri].onRowChange();
+      }
+    }
+  }
+
+  rowBottom(row: number) {
+    return this.viewState.rowsY() + 20 + 150 * row + 140;
+  }
+
+  yForVFor(n: { uri: { value: string } }) {
+    const row = this.noteRow[n.uri.value].row;
+    if (row === undefined) {
+      throw new Error();
+    }
+    const rowBottom = this.rowBottom(row);
+    const rowTop = rowBottom - 140;
+    return (v: number) => rowBottom + (rowTop - rowBottom) * v;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,12 @@
+<!doctype html>
+<html>
+  <head>
+    <title>timeline</title>
+    <meta charset="utf-8">
+    <script type="module" src="./timeline/timeline-elements.ts"></script>
+  </head>
+  <body>
+    <light9-timeline-editor style="position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px;">
+    </light9-timeline-editor>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/inline-attrs.coffee	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,88 @@
+log = debug('attrs')
+debug.enable('*')
+
+coffeeElementSetup(class InlineAttrs extends Polymer.Element
+  @is: "light9-timeline-note-inline-attrs"
+  @getter_properties:
+    graph: { type: Object, notify: true }
+    project: { type: Object, notify: true }
+    song: { type: String, notify: true }
+    config: { type: Object } # just for setup
+    uri: { type: Object, notify: true }  # the Note
+    effectStr: { type: String, notify: true }
+    colorScale: { type: String, notify: true }
+    noteLabel: { type: String, notify: true }
+    selection: { type: Object, notify: true }
+  @getter_observers: [
+    '_onConfig(config)'
+    'addHandler(graph, uri)'
+    'onColorScale(graph, uri, colorScale)'
+    ]
+
+  ready: ->
+    super.ready()
+    @$.effect.addEventListener 'edited', =>
+      @graph.patchObject(@uri, @graph.Uri(':effectClass'), @graph.Uri(@effectStr), @graph.Uri(@song))
+      
+  _onConfig: ->
+    @uri = @config.uri
+    for side in ['top', 'left', 'width', 'height']
+      @.style[side] = @config[side] + 'px'
+    
+  addHandler: ->
+    return unless @uri
+    @graph.runHandler(@update.bind(@), "update inline attrs #{@uri.value}")
+    
+  onColorScale: ->
+    return unless @uri? and @colorScale? and @colorScaleFromGraph?
+    U = (x) => @graph.Uri(x)
+    if @colorScale == @colorScaleFromGraph
+      return
+    @editAttr(@uri, U(':colorScale'), @graph.Literal(@colorScale))
+
+  editAttr: (note, attr, value) ->
+    U = (x) => @graph.Uri(x)
+    if not @song?
+      log("inline: can't edit inline attr yet, no song")
+      return
+
+    existingColorScaleSetting = null
+    for setting in @graph.objects(note, U(':setting'))
+      ea = @graph.uriValue(setting, U(':effectAttr'))
+      if ea.equals(attr)
+        existingColorScaleSetting = setting
+        
+    if existingColorScaleSetting
+      log('inline: update setting', existingColorScaleSetting.value)
+      @graph.patchObject(existingColorScaleSetting, U(':value'), value, U(@song))
+    else
+      log('inline: new setting')
+      setting = @graph.nextNumberedResource(note.value + 'set')
+      patch = {delQuads: [], addQuads: [
+        @graph.Quad(note, U(':setting'), setting, U(@song))
+        @graph.Quad(setting, U(':effectAttr'), attr, U(@song))
+        @graph.Quad(setting, U(':value'), value, U(@song))
+        ]}
+      @graph.applyAndSendPatch(patch)
+    
+  update: ->
+    console.time('attrs update')
+    U = (x) => @graph.Uri(x)
+    @effectStr = @graph.uriValue(@uri, U(':effectClass'))?.value
+    @noteLabel = @uri.value.replace(/.*\//, '')
+    existingColorScaleSetting = null
+    for setting in @graph.objects(@uri, U(':setting'))
+      ea = @graph.uriValue(setting, U(':effectAttr'))
+      value = @graph.stringValue(setting, U(':value'))
+      if ea.equals(U(':colorScale'))
+        @colorScaleFromGraph = value
+        @colorScale = value
+        existingColorScaleSetting = setting
+    if existingColorScaleSetting == null
+      @colorScaleFromGraph = '#ffffff'
+      @colorScale = '#ffffff'
+    console.timeEnd('attrs update')
+
+  onDel: ->
+    @project.deleteNote(@graph.Uri(@song), @uri, @selection)
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/inline-attrs.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,33 @@
+<link rel="import" href="/lib/polymer/polymer-element.html">
+<link rel="import" href="../light9-color-picker.html">
+<link rel="import" href="../edit-choice.html">
+
+<!-- sometimes we draw attrs within the shape of a note. -->
+<dom-module id="light9-timeline-note-inline-attrs">
+  <template>
+    <style>
+     :host {
+         position: absolute;
+
+         display: block;
+         overflow: hidden;
+         background: rgba(19, 19, 19, 0.65);
+         border-radius: 6px;
+         border: 1px solid #313131;
+         padding: 3px;
+         z-index: 2;
+         color: white;
+     }
+    </style>
+
+      <div>note [[noteLabel]] <button on-click="onDel">del</button></div>
+      <table>
+        <tr><th>effect:</th><td><edit-choice id="effect" graph="{{graph}}" uri="{{effectStr}}"></edit-choice></td></tr>
+        <tr><th>colorScale:</th><td>
+          <light9-color-picker color="{{colorScale}}"></light9-color-picker>
+        </td></tr>
+      </table>
+      <img src="/show/dance2019/anim/rainbow1.png">
+  </template>
+  <script src="inline-attrs.js"></script>
+</dom-module>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/timeline-elements.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,248 @@
+console.log("hi tl")
+import { debug } from "debug";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export {Light9TimelineAudio} from "../light9-timeline-audio"
+debug.enable("*");
+/*
+ <link rel="import" href="/lib/polymer/polymer.html">
+<link rel="import" href="/lib/polymer/lib/utils/render-status.html">
+<link rel="import" href="/lib/iron-resizable-behavior/iron-resizable-behavior.html">
+<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
+<link rel="import" href="light9-timeline-audio.html">
+<link rel="import" href="../rdfdb-synced-graph.html">
+<link rel="import" href="../light9-music.html">
+<link rel="import" href="../edit-choice.html">
+<link rel="import" href="inline-attrs.html">
+    <script src="/websocket.js"></script>
+<script type="module" src="/light9-vidref-replay.js"></script>
+
+<script type="module" src="/light9-vidref-replay-stack.js"></script>
+
+*/
+
+// Whole editor- include this on your page.
+// Most coordinates are relative to this element.
+@customElement("light9-timeline-editor")
+export class Light9TimelineEditor extends LitElement {
+  render() {
+    return html`
+    <style>
+     :host {
+         background: #444;
+         display: flex;
+         flex-direction: column;
+         position: relative;
+         border: 1px solid black;
+         overflow: hidden;
+     }
+     light9-timeline-audio {
+         width: 100%;
+         height: 30px;
+     }
+     light9-timeline-time-zoomed {
+         flex-grow: 1;
+     }
+     #coveredByDiagram {
+         position: relative;
+         display: flex;
+         flex-direction: column;
+         height: 100%;
+     }
+     #dia, #adjusters, #cursorCanvas, #adjustersCanvas {
+         position: absolute;
+         left: 0; top: 0; right: 0; bottom: 0;
+     }
+     #debug {
+         background: white;
+         font-family: monospace;
+         font-size: 125%;
+         height: 15px;
+     }
+     light9-vidref-replay-stack {
+             position: absolute;
+             bottom: 10px;
+             width: 50%;
+             background: gray;
+             box-shadow: 6px 10px 12px #0000006b;
+             display: inline-block;
+     }
+    </style>
+    <div>
+      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+      <light9-music id="music"
+                    song="{{playerSong}}"
+                    t="{{songTime}}"
+                    playing="{{songPlaying}}"
+                    duration="{{songDuration}}"></light9-music>
+      timeline editor: song <edit-choice graph="{{graph}}" uri="{{song}}"></edit-choice>
+      <label><input type="checkbox" checked="{{followPlayerSong::change}}" > follow player song choice</label>
+    </div>
+    <div id="debug">[[debug]]</div>
+    <iron-ajax id="vidrefTime" url="/vidref/time" method="PUT" content-type="application/json"></iron-ajax>
+    <div id="coveredByDiagram">
+      <light9-timeline-audio id="audio"
+                             graph="{{graph}}"
+                             show="{{show}}"
+                             song="{{song}}"></light9-timeline-audio>
+      <light9-timeline-time-zoomed id="zoomed"
+                                   graph="{{graph}}"
+                                   project="{{project}}"
+                                   selection="{{selection}}"
+                                   set-adjuster="{{setAdjuster}}"
+                                   song="{{song}}"
+                                   show="{{show}}"
+                                   view-state="{{viewState}}">
+      </light9-timeline-time-zoomed>
+      <light9-adjusters-canvas id="adjustersCanvas" set-adjuster="{{setAdjuster}}">
+      </light9-adjusters-canvas>
+      <light9-cursor-canvas id="cursorCanvas" view-state="{{viewState}}"></light9-cursor-canvas>
+      <light9-vidref-replay-stack size="small"></light9-vidref-replay-stack>
+    </div>
+`;
+  }
+}
+
+// the whole section that pans/zooms in time (most of the editor)
+@customElement("light9-timeline-time-zoomed")
+export class Light9TimelineTimeZoomed extends LitElement {
+  render() {
+    return html`
+    <style>
+     :host {
+         display: flex;
+         height: 100%;
+         flex-direction: column;
+     }
+     #top {
+     }
+     #rows {
+         height: 100%;
+         overflow: hidden;
+     }
+     #rows.dragging {
+         background: rgba(126, 52, 245, 0.0784);
+     }
+     light9-timeline-time-axis {
+     }
+     light9-timeline-audio {
+         width: 100%;
+         height: 100px;
+     }
+     light9-timeline-graph-row {
+         flex-grow: 1;
+     }
+    </style>
+    <div id="top">
+      <light9-timeline-time-axis id="time" view-state="{{viewState}}"></light9-timeline-time-axis>
+      <light9-timeline-audio id="audio"
+                             graph="{{graph}}"
+                             song="{{song}}"
+                             show="{{show}}"
+                             zoom="{{zoomFlattened}}">
+      </light9-timeline-audio>
+    </div>
+    <div id="rows"></div>
+    <template is="dom-repeat" items="{{imageSamples}}">
+      <img src="/show/dance2019/anim/rainbow1.png">
+    </template>
+    <template is="dom-repeat" items="{{inlineAttrConfigs}}">
+      <light9-timeline-note-inline-attrs graph="{{graph}}"
+                                         project="{{project}}"
+                                         selection="{{selection}}"
+                                         song="{{song}}"
+                                         config="{{item}}">
+      </light9-timeline-note-inline-attrs>
+    </template>
+`;
+  } 
+}
+
+@customElement("light9-cursor-canvas")
+export class Light9CursorCanvas extends LitElement {
+  render() {
+    return html`
+      <style>
+        #canvas,
+        :host {
+          pointer-events: none;
+        }
+      </style>
+      <canvas id="canvas"></canvas>
+    `;
+  }
+}
+
+@customElement("light9-adjusters-canvas")
+export class Light9AdjustersCanvas extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          pointer-events: none;
+        }
+      </style>
+      <canvas id="canvas"></canvas>
+    `;
+  }
+}      
+
+// seconds labels
+@customElement("light9-timeline-time-axis")
+export class Light9TimelineTimeAxis extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          display: block;
+        }
+        div {
+          width: 100%;
+          height: 31px;
+        }
+        svg {
+          width: 100%;
+          height: 30px;
+        }
+      </style>
+      <svg id="timeAxis" xmlns="http://www.w3.org/2000/svg">
+        <style>
+          text {
+            fill: white;
+            color: white;
+            font-size: 135%;
+            font-weight: bold;
+          }
+        </style>
+        <g id="axis" transform="translate(0,30)"></g>
+      </svg>
+    `;
+  }
+}
+
+// All the adjusters you can edit or select. Tells a light9-adjusters-canvas how to draw them. Probabaly doesn't need to be an element.
+//  This element manages their layout and suppresion.
+//  Owns the selection.
+//  Maybe includes selecting things that don't even have adjusters.
+//  Maybe manages the layout of other labels and text too, to avoid overlaps.
+@customElement("light9-timeline-adjusters")
+export class Light9TimelineAdjusters extends LitElement {
+  render() {
+    return html`
+      <style>
+        :host {
+          pointer-events: none; /* restored on the individual adjusters */
+        }
+      </style>
+    `;
+  }
+}
+
+/*
+<script src="/lib/async/dist/async.js"></script>
+<script src="/lib/shortcut/index.js"></script>
+<script src="/lib/underscore/underscore-min.js"></script>
+<script src="/node_modules/n3/n3-browser.js"></script> 
+<script src="/node_modules/pixi.js/dist/pixi.min.js"></script>
+<script src="/node_modules/tinycolor2/dist/tinycolor-min.js"></script>
+*/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/viewstate.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,128 @@
+import * as ko from "knockout";
+import * as d3 from "d3";
+import debug from "debug";
+
+const log = debug("viewstate");
+export class ViewState {
+  zoomSpec: {
+    duration: ko.Observable<number>; // current song duration
+    t1: ko.Observable<number>;
+    t2: ko.Observable<number>;
+  };
+  cursor: { t: ko.Observable<number> };
+  mouse: { pos: ko.Observable<Vector> };
+  width: ko.Observable<number>;
+  coveredByDiagramTop: ko.Observable<number>;
+  audioY: ko.Observable<number>;
+  audioH: ko.Observable<number>;
+  zoomedTimeY: ko.Observable<number>;
+  zoomedTimeH: ko.Observable<number>;
+  rowsY: ko.Observable<number>;
+  fullZoomX: d3.ScaleLinear<number, number>;
+  zoomInX: d3.ScaleLinear<number, number>;
+  zoomAnimSec: number;
+  constructor() {
+    // caller updates all these observables
+    this.zoomSpec = {
+      duration: ko.observable(100), // current song duration
+      t1: ko.observable(0),
+      t2: ko.observable(100),
+    };
+    this.cursor = { t: ko.observable(20) }; // songTime
+    this.mouse = { pos: ko.observable($V([0, 0])) };
+    this.width = ko.observable(500);
+    this.coveredByDiagramTop = ko.observable(0); // page coords
+    // all these are relative to #coveredByDiagram:
+    this.audioY = ko.observable(0);
+    this.audioH = ko.observable(0);
+    this.zoomedTimeY = ko.observable(0);
+    this.zoomedTimeH = ko.observable(0);
+    this.rowsY = ko.observable(0);
+
+    this.fullZoomX = d3.scaleLinear();
+    this.zoomInX = d3.scaleLinear();
+
+    this.zoomAnimSec = 0.1;
+
+    ko.computed(this.maintainZoomLimitsAndScales.bind(this));
+  }
+
+  setWidth(w: any) {
+    this.width(w);
+    this.maintainZoomLimitsAndScales(); // before other handlers run
+  }
+
+  maintainZoomLimitsAndScales() {
+    // not for cursor updates
+
+    if (this.zoomSpec.t1() < 0) {
+      this.zoomSpec.t1(0);
+    }
+    if (this.zoomSpec.duration() && this.zoomSpec.t2() > this.zoomSpec.duration()) {
+      this.zoomSpec.t2(this.zoomSpec.duration());
+    }
+
+    const rightPad = 5; // don't let time adjuster fall off right edge
+    this.fullZoomX.domain([0, this.zoomSpec.duration()]);
+    this.fullZoomX.range([0, this.width() - rightPad]);
+
+    this.zoomInX.domain([this.zoomSpec.t1(), this.zoomSpec.t2()]);
+    this.zoomInX.range([0, this.width() - rightPad]);
+  }
+
+  latestMouseTime(): number {
+    return this.zoomInX.invert(this.mouse.pos().e(1));
+  }
+
+  onMouseWheel(deltaY: any) {
+    const zs = this.zoomSpec;
+
+    const center = this.latestMouseTime();
+    const left = center - zs.t1();
+    const right = zs.t2() - center;
+    const scale = Math.pow(1.005, deltaY);
+
+    zs.t1(center - left * scale);
+    zs.t2(center + right * scale);
+    log("view to", ko.toJSON(this));
+  }
+
+  frameCursor() {
+    const zs = this.zoomSpec;
+    const visSeconds = zs.t2() - zs.t1();
+    const margin = visSeconds * 0.4;
+    // buggy: really needs t1/t2 to limit their ranges
+    if (this.cursor.t() < zs.t1() || this.cursor.t() > zs.t2() - visSeconds * 0.6) {
+      const newCenter = this.cursor.t() + margin;
+      this.animatedZoom(newCenter - visSeconds / 2, newCenter + visSeconds / 2, this.zoomAnimSec);
+    }
+  }
+  frameToEnd() {
+    this.animatedZoom(this.cursor.t() - 2, this.zoomSpec.duration(), this.zoomAnimSec);
+  }
+  frameAll() {
+    this.animatedZoom(0, this.zoomSpec.duration(), this.zoomAnimSec);
+  }
+  animatedZoom(newT1: number, newT2: number, secs: number) {
+    const fps = 30;
+    const oldT1 = this.zoomSpec.t1();
+    const oldT2 = this.zoomSpec.t2();
+    let lastTime = 0;
+    for (let step = 0; step < secs * fps; step++) {
+      const frac = step / (secs * fps);
+      ((frac) => {
+        const gotoStep = () => {
+          this.zoomSpec.t1((1 - frac) * oldT1 + frac * newT1);
+          return this.zoomSpec.t2((1 - frac) * oldT2 + frac * newT2);
+        };
+        const delay = frac * secs * 1000;
+        setTimeout(gotoStep, delay);
+        lastTime = delay;
+      })(frac);
+    }
+    setTimeout(() => {
+      this.zoomSpec.t1(newT1);
+      return this.zoomSpec.t2(newT2);
+    }, lastTime + 10);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline/vite.config.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,20 @@
+import { defineConfig } from "vite";
+
+const servicePort = 8216;
+export default defineConfig({
+  base: "/timeline/",
+  root: "./light9/web/timeline",
+  publicDir: "../web",
+  server: {
+    host: "0.0.0.0",
+    strictPort: true,
+    port: servicePort + 100,
+    hmr: {
+      port: servicePort + 200,
+    },
+  },
+  clearScreen: false,
+  define: {
+    global: {},
+  },
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/timeline2/index.html	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,138 @@
+<!DOCTYPE HTML>
+<html>
+  <head>
+    <title>pixi.js test</title>
+    <style>
+     body {
+	 margin: 0;
+	 padding: 0;
+	 background-color: #000000;
+     }
+     
+     #help{
+	 position: absolute;
+	 z-index: 20;
+	 color: black;
+	 top: 20px;
+	 left: 120px;
+     }
+    </style>
+
+
+    
+    <script src="node_modules/pixi.js/dist/pixi.js"></script>
+  </head>
+  <body>
+    <script>
+     const log = debug('timeline');
+     var stage = new PIXI.Container();
+     
+     var renderer = PIXI.autoDetectRenderer(3000,2000, {
+         backgroundColor: 0x606060,
+     });
+     
+     document.body.appendChild(renderer.view);
+     requestAnimFrame =  window.requestAnimationFrame;
+     requestAnimFrame( animate );
+
+     if(1) {
+         var graphics = new PIXI.Graphics();
+
+         // set a fill and line style
+         graphics.beginFill(0xFF3300);
+         graphics.lineStyle(4, 0xffd900, 1);
+         graphics.blendMode = PIXI.BLEND_MODES.LUMINOSITY;
+         graphics.cursor = 'wait';
+
+         // draw a shape
+         graphics.moveTo(50,50);
+         graphics.lineTo(250, 50);
+         graphics.lineTo(100, 100);
+         graphics.lineTo(50, 50);
+         graphics.endFill();
+         graphics.interactive = true;
+         graphics.on('click',  (ev) => {
+             log('hit', ev);
+         });
+         
+         stage.addChild(graphics);
+     }
+     
+     objs =  [];
+     const mkdrag = (txt, pos) => {
+         var draggable = new PIXI.Container();
+         
+         var graphics = new PIXI.Graphics();
+         graphics.beginFill(0xeecc00, .6);
+         graphics.lineStyle(2, 0xffd900, 1);
+         graphics.drawRoundedRect(0,0,50,30,5);
+         graphics.endFill();
+
+         draggable.addChild(graphics);
+
+         var style = new PIXI.TextStyle({
+             fontFamily: 'Arial',
+             fontSize: 16,
+             fill: ['#000000'], 
+         });
+         var basicText = new PIXI.Text(txt, style);
+         basicText.x = 3;
+         basicText.y = 9;
+         basicText.scale = new PIXI.Point(.7,1);
+         draggable.addChild(basicText);
+
+         draggable.interactive = true;
+         draggable.on('click',  (ev) => {
+             console.log('d hit', ev);
+         });
+         
+         draggable.position = pos;
+         
+         //   console.log(     draggable.toGlobal(new PIXI.Point(3,  3)));
+         return draggable;
+     };
+
+     for (let x=0; x<3000; x+=30) {
+         for(let i=0; i < 400; i+= 20) {
+             let d = mkdrag('o='+i, new PIXI.Point(i+x, i*2))
+             stage.addChild(d);
+             objs.push(d);
+         }
+     }
+     
+
+     var style = new PIXI.TextStyle({
+         fontFamily: 'Arial',
+         fontSize: 36,
+         fill: ['#ffffff'],
+         stroke: '#4a1850',
+         strokeThickness: 2,
+         dropShadow: true,
+         dropShadowColor: '#000000',
+         dropShadowBlur: 1,
+         dropShadowAngle: Math.PI / 6,
+         dropShadowDistance: 6,
+         //    wordWrap: true,
+         //    wordWrapWidth: 440
+     });
+     var basicText = new PIXI.Text(`num objs = ${objs.length}`, style);
+     basicText.x = 30;
+     basicText.y = 90;
+
+     stage.addChild(basicText);
+
+     function animate() {
+         requestAnimFrame( animate );
+
+         for (let d of objs) {
+             d.rotation = Date.now()  / 2000;
+         }
+         
+         renderer.render(stage);
+     }
+     renderer.render(stage);
+
+    </script>
+
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/vite.config.ts	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,19 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+  base: "/",
+  root: "./web",
+  publicDir: "../node_modules",
+  server: {
+    host: "0.0.0.0",
+    strictPort: true,
+    port: 8300,
+    hmr: {
+      port: 8301,
+    },
+  },
+  clearScreen: false,
+  define: {
+    global: {},
+  },
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/websocket.js	Sun May 12 19:02:10 2024 -0700
@@ -0,0 +1,62 @@
+/*
+  url is now relative to the window location. Note that nginx may drop
+  the connection after 60sec of inactivity.
+*/
+
+class ReconnectingWebsocket {
+    
+    constructor(url, onMessage) {
+        this.onMessage = onMessage;
+        this.ws = null;
+        this.connectTimer = null
+        this.pong = 0;
+        
+        this.fullUrl = (
+            "ws://"
+                + window.location.host
+                + window.location.pathname
+                + (window.location.pathname.match(/\/$/) ? "" : "/")
+                + url);
+        this.connect();
+    }
+    setStatus(txt) {
+        const docStatus = document.querySelector('#status')
+        if (docStatus) {
+            docStatus.innerText = txt;
+        }
+    }
+    connect() {
+        this.reconnect = true;
+        this.ws = new WebSocket(this.fullUrl);
+        
+        this.ws.onopen = () => { this.setStatus("connected"); };
+        this.ws.onerror = (e) => { this.setStatus("error: "+e); };
+        this.ws.onclose = () => {
+            this.pong = 1 - this.pong;
+            this.setStatus("disconnected (retrying "+(this.pong ? "😼":"😺")+")");
+            this.ws = null;
+
+            this.connectTimer = setTimeout(() => {
+                this.connectTimer = null;
+                requestAnimationFrame(() => {
+                    if (this.reconnect) {
+                        this.connect();
+                    }
+                });
+            }, 2000);
+        };
+        this.ws.onmessage = (evt) => {
+            this.onMessage(JSON.parse(evt.data));
+        };
+    }
+    disconnect() {
+        this.reconnect = false;
+        this.ws.close();
+    }
+}
+
+
+
+function reconnectingWebSocket(url, onMessage) {
+    return new ReconnectingWebsocket(url, onMessage);
+}