diff --git a/bin/vidref b/bin/vidref --- a/bin/vidref +++ b/bin/vidref @@ -1,22 +1,29 @@ #!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/vidref.js LitElement for video playback + +""" from run_local import log -import sys -sys.path.append('/usr/lib/python2.7/dist-packages') # For gtk -from twisted.internet import gtk2reactor -gtk2reactor.install() + from twisted.internet import reactor, defer -import gobject -gobject.threads_init() -import sys, logging, optparse, json + +import logging, optparse, json, base64 import cyclone.web, cyclone.httpclient, cyclone.websocket from light9 import networking -from light9.vidref.main import Gui from light9.vidref.replay import snapshotDir +from light9.vidref import videorecorder from rdfdb.syncedgraph import SyncedGraph - -# find replay dirs correctly. show multiple -# replays. trash. reorder/pin. dump takes that are too short; they're -# just from seeking +from io import BytesIO parser = optparse.OptionParser() parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG") @@ -47,6 +54,33 @@ class Snapshot(cyclone.web.RequestHandle raise +pipeline = videorecorder.GstSource( + '/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) + + def connectionLost(self, reason): + 0 #self.subj.dispose() + + def onFrame(self, t_img): + t, img = t_img + if img is None: return + output = BytesIO() + img.save(output, 'jpeg', quality=80) + + self.sendMessage( + json.dumps({ + 'jpeg': + base64.b64encode(output.getvalue()).decode('ascii'), + 'description': + f't={t}', + })) + + class SnapshotPic(cyclone.web.StaticFileHandler): pass @@ -63,24 +97,28 @@ class Time(cyclone.web.RequestHandler): graph = SyncedGraph(networking.rdfdb.url, "vidref") -gui = Gui(graph) - port = networking.vidref.port reactor.listenTCP( port, - cyclone.web.Application(handlers=[ - (r'/()', cyclone.web.StaticFileHandler, { - 'path': 'light9/vidref', - 'default_filename': 'vidref.html' - }), - (r'/snapshot', Snapshot), - (r'/snapshot/(.*)', SnapshotPic, { - "path": snapshotDir() - }), - (r'/time', Time), - ], - debug=True, - gui=gui)) + cyclone.web.Application( + handlers=[ + (r'/()', cyclone.web.StaticFileHandler, { + 'path': 'light9/vidref', + 'default_filename': 'vidref.html' + }), + (r'/setup/()', cyclone.web.StaticFileHandler, { + 'path': 'light9/vidref', + 'default_filename': 'setup.html' + }), + (r'/setup/live', Live), + (r'/snapshot', Snapshot), + (r'/snapshot/(.*)', SnapshotPic, { + "path": snapshotDir() + }), + (r'/time', Time), + ], + debug=True, + )) log.info("serving on %s" % port) reactor.run() diff --git a/light9/vidref/replay.py b/light9/vidref/replay.py --- a/light9/vidref/replay.py +++ b/light9/vidref/replay.py @@ -1,4 +1,4 @@ -import os, gtk, shutil, logging, time +import os, shutil, logging, time from bisect import bisect_left from decimal import Decimal log = logging.getLogger() diff --git a/light9/vidref/setup.html b/light9/vidref/setup.html new file mode 100644 --- /dev/null +++ b/light9/vidref/setup.html @@ -0,0 +1,20 @@ + + + + vidref setup + + + + + + + + + + + Live: + + + + + diff --git a/light9/vidref/videorecorder.py b/light9/vidref/videorecorder.py --- a/light9/vidref/videorecorder.py +++ b/light9/vidref/videorecorder.py @@ -1,21 +1,101 @@ -import pygst -pygst.require("0.10") -import gst, gobject, time, logging, os, traceback -import gtk +import sys +import gi +gi.require_version('Gst', '1.0') +gi.require_version('GstBase', '1.0') +from gi.repository import Gst +from rx.subjects import BehaviorSubject + +import time, logging, os, traceback from PIL import Image -from threading import Thread from twisted.internet import defer -from queue import Queue, Empty +from queue import Queue from light9.vidref.replay import framerate, songDir, takeDir, snapshotDir +from typing import Set + +from IPython.core import ultratb +sys.excepthook = ultratb.FormattedTB(mode='Verbose', + color_scheme='Linux', + call_pdb=1) + log = logging.getLogger() -class Pipeline(object): +class GstSource: + + def __init__(self, dev): + """ + make new gst pipeline + """ + Gst.init(None) + self.liveImages = BehaviorSubject((0, None)) + + 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}\" ! videoconvert ! 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('recording video') - def __init__(self, liveVideoXid, musicTime, recordingTo): - self.musicTime = musicTime - self.liveVideoXid = liveVideoXid - self.recordingTo = recordingTo + 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 = Image.frombytes( + 'RGB', (caps.get_structure(0).get_value('width'), + caps.get_structure(0).get_value('height')), + mapinfo.data) + img = img.crop((0, 100, 800, 500)) + finally: + buf.unmap(mapinfo) + self.liveImages.on_next((time.time(), img)) + except Exception: + traceback.print_exc() + return Gst.FlowReturn.OK + + 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() try: @@ -41,80 +121,8 @@ class Pipeline(object): self.snapshotRequests.put(req) return d - def setInput(self, name): - sourcePipe = { - "auto": "autovideosrc name=src1", - "testpattern": "videotestsrc name=src1", - "dv": "dv1394src name=src1 ! dvdemux ! dvdec", - "v4l": "v4l2src device=/dev/video0 name=src1", - }[name] - cam = ( - sourcePipe + " ! " - "videorate ! video/x-raw-yuv,framerate=%s/1 ! " - "videoscale ! video/x-raw-yuv,width=640,height=480;video/x-raw-rgb,width=320,height=240 ! " - "videocrop left=160 top=180 right=120 bottom=80 ! " - "queue name=vid" % framerate) - - print(cam) - self.pipeline = gst.parse_launch(cam) - - def makeElem(t, n=None): - e = gst.element_factory_make(t, n) - self.pipeline.add(e) - return e - - sink = makeElem("xvimagesink") - - def setRec(t): - # if you're selecting the text while gtk is updating it, - # you can get a crash in xcb_io - if getattr(self, '_lastRecText', None) == t: - return - with gtk.gdk.lock: - self.recordingTo.set_text(t) - self._lastRecText = t - - recSink = VideoRecordSink(self.musicTime, setRec, self.snapshotRequests) - self.pipeline.add(recSink) - - tee = makeElem("tee") - - caps = makeElem("capsfilter") - caps.set_property('caps', gst.caps_from_string('video/x-raw-rgb')) - - gst.element_link_many(self.pipeline.get_by_name("vid"), tee, sink) - gst.element_link_many(tee, makeElem("ffmpegcolorspace"), caps, recSink) - sink.set_xwindow_id(self.liveVideoXid) - self.pipeline.set_state(gst.STATE_PLAYING) - - def setLiveVideo(self, on): - - if on: - self.pipeline.set_state(gst.STATE_PLAYING) - # this is an attempt to bring the dv1394 source back, but - # it doesn't work right. - self.pipeline.get_by_name("src1").seek_simple( - gst.FORMAT_TIME, gst.SEEK_FLAG_FLUSH, 0 * gst.SECOND) - else: - self.pipeline.set_state(gst.STATE_READY) - - -class VideoRecordSink(gst.Element): - _sinkpadtemplate = gst.PadTemplate("sinkpadtemplate", gst.PAD_SINK, - gst.PAD_ALWAYS, gst.caps_new_any()) - - def __init__(self, musicTime, updateRecordingTo, snapshotRequests): - gst.Element.__init__(self) - self.updateRecordingTo = updateRecordingTo - self.snapshotRequests = snapshotRequests - self.sinkpad = gst.Pad(self._sinkpadtemplate, "sink") - self.add_pad(self.sinkpad) - self.sinkpad.set_chain_function(self.chainfunc) - self.lastTime = 0 - - self.musicTime = musicTime - +''' self.imagesToSave = Queue() self.startBackgroundImageSaver(self.imagesToSave) @@ -148,43 +156,4 @@ class VideoRecordSink(gst.Element): def chainfunc(self, pad, buffer): position = self.musicTime.getLatest() - - # if music is not playing and there's no pending snapshot - # request, we could skip the image conversions here. - - try: - cap = buffer.caps[0] - #print "cap", (cap['width'], cap['height']) - img = Image.fromstring('RGB', (cap['width'], cap['height']), - buffer.data) - self.imagesToSave.put((position, img, buffer.timestamp)) - except Exception: - traceback.print_exc() - - return gst.FLOW_OK - - def saveImg(self, position, img, bufferTimestamp): - if not position['song']: - return - - t1 = time.time() - 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 - return - - try: - os.makedirs(outDir) - except OSError: - pass - - img.save(outFilename) - - now = time.time() - log.info("wrote %s delay of %.2fms, took %.2fms", outFilename, - (now - self.lastTime) * 1000, (now - t1) * 1000) - self.updateRecordingTo(outDir) - self.lastTime = now - - -gobject.type_register(VideoRecordSink) +''' diff --git a/light9/web/light9-vidref-live.js b/light9/web/light9-vidref-live.js new file mode 100644 --- /dev/null +++ b/light9/web/light9-vidref-live.js @@ -0,0 +1,39 @@ +import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js'; + + +class Light9VidrefLive extends LitElement { + + static get properties() { + return { + description: { type: String } + }; + } + + static get styles() { + return css` + :host { + border: 2px solid #46a79f; + display: inline-block; + } + `; + } + + firstUpdated() { + const ws = reconnectingWebSocket('live', (msg) => { + this.shadowRoot.querySelector('#live').src = 'data:image/jpeg;base64,' + msg.jpeg; + this.description = msg.description; + }); + + } + + render() { + return html` +
+
+
${this.description}
+
+`; + + } +} +customElements.define('light9-vidref-live', Light9VidrefLive); diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ python-dateutil==2.6.0 pyusb==1.0.0 rdflib==4.2.2 requests==2.22.0 +rx==1.6.1 scipy==1.3.0 service_identity==18.1.0 statprof==0.1.2