Mercurial > code > home > repos > light9
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 -> 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"><b>Live view</b></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"><b>Last log</b></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"><b>Playback 1</b></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"><b>Music position</b></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>
--- 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. -
--- 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> - — default behavior</li> - <li><a href="demos/tutorial2.html">Basic Handler</a> - — basic form integration</li> - <li><a href="demos/tutorial3.html">Aspect Ratio w/ Preview Pane</a> - — nice visual example</li> - <li><a href="demos/tutorial4.html">Animation/Transitions</a> - — animation/fading demo</li> - <li><a href="demos/tutorial5.html">API Interface</a> - — real-time API example</li> - <li><a href="demos/styling.html">Styling Example</a> - — style Jcrop dynamically with CSS - <small>New in 0.9.10</small> - </li> - <li><a href="demos/non-image.html">Non-Image Elements</a> - — 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> - — 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>© 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; -}
--- 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 -> 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"><b>Live view</b></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"><b>Last log</b></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"><b>Playback 1</b></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"><b>Music position</b></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>
--- /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. +
--- /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> + — default behavior</li> + <li><a href="demos/tutorial2.html">Basic Handler</a> + — basic form integration</li> + <li><a href="demos/tutorial3.html">Aspect Ratio w/ Preview Pane</a> + — nice visual example</li> + <li><a href="demos/tutorial4.html">Animation/Transitions</a> + — animation/fading demo</li> + <li><a href="demos/tutorial5.html">API Interface</a> + — real-time API example</li> + <li><a href="demos/styling.html">Styling Example</a> + — style Jcrop dynamically with CSS + <small>New in 0.9.10</small> + </li> + <li><a href="demos/non-image.html">Non-Image Elements</a> + — 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> + — 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>© 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; +}
--- /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); +}