Changeset - ec816fd31c83
[Not reviewed]
default
0 2 0
Drew Perttula - 6 years ago 2019-06-06 02:59:26
drewp@bigasterisk.com
2 files changed with 30 insertions and 26 deletions:
0 comments (0 inline, 0 general)
bin/vidref
Show inline comments
 
#!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 twisted.internet import reactor, defer
 

	
 
from typing import cast
 
import logging, optparse, json, base64, os, glob
 
import cyclone.web, cyclone.httpclient, cyclone.websocket
 
from light9 import networking, showconfig
 
from light9.vidref import videorecorder
 
from rdflib import URIRef
 
from light9.newtypes import Song
 
from rdfdb.syncedgraph import SyncedGraph
 
from cycloneerr import PrettyErrorHandler
 
from typing import cast
 

	
 
from greplin import scales
 
from greplin.scales.cyclonehandler import StatsHandler
 
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)
 

	
 
stats = scales.collection(
 
    '/webServer',
 
    scales.RecentFpsStat('liveWebsocketFrameFps'),
 
    scales.IntStat('liveClients'),
 
    
 
)
 
 
 

	
 

	
 
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(
 
@@ -87,62 +88,62 @@ class Live(cyclone.websocket.WebSocketHa
 

	
 
        stats.liveWebsocketFrameFps.mark()
 

	
 
        self.sendMessage(
 
            json.dumps({
 
                'jpeg': base64.b64encode(cf.asJpeg()).decode('ascii'),
 
                'description': f't={cf.t}',
 
            }))
 

	
 

	
 
class SnapshotPic(cyclone.web.StaticFileHandler):
 
    pass
 

	
 

	
 
class Time(cyclone.web.RequestHandler):
 

	
 
    def put(self):
 
        body = json.loads(self.request.body)
 
        t = body['t']
 
        source = body['source']
 
        self.settings.gui.incomingTime(t, source)
 
        self.set_status(202)
 

	
 

	
 

	
 
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'))
 
        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[-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)
 

	
 
@@ -154,31 +155,30 @@ outVideos = videorecorder.FramesToVideoF
 
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'/stats/(.*)', StatsHandler, {
 
                'serverName': 'vidref'
 
            }),
 

	
 
        ],
 
        debug=True,
 
    ))
 
log.info("serving on %s" % port)
 

	
 
reactor.run()
light9/vidref/videorecorder.py
Show inline comments
 
from dataclasses import dataclass
 
from io import BytesIO
 
from typing import Optional
 
import time, logging, os, traceback
 
from io import BytesIO
 

	
 
import gi
 
gi.require_version('Gst', '1.0')
 
gi.require_version('GstBase', '1.0')
 

	
 
import PIL.Image
 
from gi.repository import Gst
 
from greplin import scales
 
from rdflib import URIRef
 
from rx.subject import BehaviorSubject
 
from twisted.internet import threads
 
from rdflib import URIRef
 
import PIL.Image
 
import moviepy.editor
 
import numpy
 
from greplin import scales
 

	
 
from light9 import showconfig
 
from light9.ascoltami.musictime_client import MusicTime
 
from light9.newtypes import Song
 
from light9 import showconfig
 

	
 
log = logging.getLogger()
 

	
 
stats = scales.collection(
 
    '/recorder',
 
    scales.PmfStat('jpegEncode', recalcPeriod=1),
 
    scales.IntStat('deletes'),
 
    scales.PmfStat('waitForNextImg', recalcPeriod=1),
 
    scales.PmfStat('crop', recalcPeriod=1),
 
    scales.RecentFpsStat('encodeFrameFps'),
 
    scales.RecentFpsStat('queueGstFrameFps'),
 
    
 
)
 

	
 

	
 
@dataclass
 
class CaptureFrame:
 
    img: PIL.Image
 
    song: Song
 
    t: float
 
    isPlaying: bool
 
    imgJpeg: Optional[bytes] = None
 

	
 
    @stats.jpegEncode.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]])
 
    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}')
 
    stats.deletes += 1
 
    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
 
@@ -145,49 +150,48 @@ class FramesToVideoFiles:
 
        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):
 
        stats.encodeFrameFps.mark()
 
        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)
 
        stats.waitForNextImg = 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)
 

	
 

	
 
@@ -235,49 +239,49 @@ class GstSource:
 
            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:
 
                stats.queueGstFrameFps.mark()
 
                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
 

	
 
    @stats.crop.time()
 
    def crop(self, img):
 
        return img.crop((0, 100, 640, 380))
 
    
 

	
 
    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)
0 comments (0 inline, 0 general)