From bd7a4f3ce898b050cb371062d33648f3392f4b75 Mon Sep 17 00:00:00 2001 From: Piotr Dobrowolski Date: Sun, 17 Jun 2018 20:30:47 +0200 Subject: [PATCH] Initial apiserver --- .dockerignore | 2 +- .gitignore | 1 + Dockerfile | 28 -------- api/Dockerfile | 23 ++++++ api/apiserver.py | 88 +++++++++++++++++++++++ api/requirements.txt | 14 ++++ api/run.sh | 9 +++ api/snowmix.py | 93 ++++++++++++++++++------- config/janus/janus.cfg | 8 +-- config/janus/janus.plugin.streaming.cfg | 3 +- docker-compose.yml | 9 +++ 11 files changed, 217 insertions(+), 61 deletions(-) create mode 100644 .gitignore create mode 100644 api/Dockerfile create mode 100644 api/apiserver.py create mode 100644 api/requirements.txt create mode 100755 api/run.sh diff --git a/.dockerignore b/.dockerignore index 2c850c2..1097e68 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1 @@ -ui +frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/Dockerfile b/Dockerfile index aae4b96..004e39a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,31 +28,3 @@ RUN apt install -y --no-install-recommends netcat bc USER snowmix CMD snowmix - -#RUN apt install -y --no-install-recommends git python3 -# build gstreamer 1.0 from cerbero source -# the build commands are split so that docker can resume in case of errors -#RUN git clone --depth 1 git://anongit.freedesktop.org/gstreamer/cerbero -# hack: to pass "-y" argument to apt-get install launched by "cerbero bootstrap" -#RUN sed -i 's/apt-get install/apt-get install -y/g' cerbero/cerbero/bootstrap/linux.py - -#RUN apt install -y --no-install-recommends python3-setuptools sudo - -#RUN cd cerbero; ./cerbero-uninstalled bootstrap -#RUN git config --global user.email "you@example.com" && git config --global user.name "Your Name" -#RUN cd cerbero; ./cerbero-uninstalled build \ -# glib bison gstreamer-1.0 - -#RUN apt install -y --no-install-recommends python3-dev python-dev -#RUN cd cerbero; perl -pi -e 's/[^[:ascii:]]//g' packages/gstreamer-1.0/license.txt -#RUN cd cerbero; ./cerbero-uninstalled package gstreamer-1.0 - -#RUN cd cerbero; ./cerbero-uninstalled build \ -# gst-plugins-base-1.0 gst-plugins-good-1.0 -# -#RUN cd cerbero; ./cerbero-uninstalled build \ -# gst-plugins-bad-1.0 gst-plugins-ugly-1.0 -# -#RUN cd cerbero; ./cerbero-uninstalled build \ -# gst-libav-1.0 - diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..e9c5c56 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:xenial + +RUN useradd -d /app app +RUN apt-get update && \ + apt-get --no-install-recommends -y install python3 python3-pip python3-setuptools netbase + +# We use that to cache last requirements version +COPY requirements.txt /app/requirements.txt +RUN pip3 install -r /app/requirements.txt + +COPY run.sh /app/run.sh + +WORKDIR /app +EXPOSE 5000 +USER app + +ENV LANG C.UTF-8 +ENV LC_ALL C.UTF-8 + +ENV FLASK_APP apiserver.py +ENV FLASK_ENV production + +CMD python3 -u apiserver.py diff --git a/api/apiserver.py b/api/apiserver.py new file mode 100644 index 0000000..d46fccd --- /dev/null +++ b/api/apiserver.py @@ -0,0 +1,88 @@ +import time +import logging + +import eventlet +import flask +from flask import g, jsonify, request +from flask_cors import CORS +from flask_socketio import SocketIO + +import snowmix + +logging.basicConfig(level=logging.INFO) + +eventlet.monkey_patch() + +app = flask.Flask('snowmix-apiserver') +cors = CORS(app) +socketio = SocketIO(app) + +app.snowmix = snowmix.SnowmixClient('snowmix') +app.snowmix.sem = eventlet.Semaphore() + +state = {} + +def start_updater(name, func, interval=1.0): + def updater(): + global state + + while True: + try: + data = func() + + if state.get(name) != data: + state[name] = data + socketio.emit(name, data, broadcast=True) + except: + logging.exception('Oops?') + + time.sleep(interval) + + socketio.start_background_task(updater) + +start_updater('scenes', (lambda: [app.snowmix.scene_info(s) for s in app.snowmix.scene_list()]), 0.3) +start_updater('feeds', (lambda: app.snowmix.feed_list()), 3.0) +start_updater('images', (lambda: app.snowmix.image_list()), 5.0) + +@app.route('/api/1/scenes/') +def scenes(): + return jsonify([ + app.snowmix.scene_info(s) + for s in app.snowmix.scene_list() + ]) + +@app.route('/api/1/scenes/') +def scene_info(scene_id): + return jsonify(app.snowmix.scene_info(scene_id)) + +@app.route('/api/1/scenes/', methods=['PATCH']) +def scene_patch(scene_id): + req = request.json + + if 'alpha' in req: + app.snowmix.scene_alpha(scene_id, req['alpha'], -1) + if 'text_alpha' in req: + app.snowmix.scene_alpha(scene_id, req['text_alpha'], -2) + if 'background_alpha' in req: + app.snowmix.scene_alpha(scene_id, req['text_alpha'], -3) + return 'OK' + +@app.route('/api/1/feeds/') +def feeds(): + return jsonify(app.snowmix.feed_list()) + +@app.route('/api/1/images/') +def images(): + return jsonify(app.snowmix.image_list()) + +@socketio.on('connect') +def on_connect(): + for k, v in state.items(): + socketio.emit(k, v) + +@socketio.on('command') +def command(cmd): + pass + +if __name__ == "__main__": + socketio.run(app, host='0.0.0.0') diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..9c5e481 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,14 @@ +click==6.7 +eventlet==0.23.0 +Flask==1.0.2 +Flask-Cors==3.0.6 +Flask-SocketIO==3.0.1 +greenlet==0.4.13 +itsdangerous==0.24 +Jinja2==2.10 +MarkupSafe==1.0 +pyparsing==2.2.0 +python-engineio==2.1.1 +python-socketio==1.9.0 +six==1.11.0 +Werkzeug==0.14.1 diff --git a/api/run.sh b/api/run.sh new file mode 100755 index 0000000..894c1aa --- /dev/null +++ b/api/run.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +#exec uwsgi --http-socket :5000 --processes 4 --master --plugin python \ +# --lazy-apps --pythonpath /app --module apiserver:app \ +# --touch-reload '/app/apiserver.py' --gevent 1000 --http-websockets + +exec flask run --host 0.0.0.0 --port 5000 --debugger diff --git a/api/snowmix.py b/api/snowmix.py index c0a9665..0998d77 100644 --- a/api/snowmix.py +++ b/api/snowmix.py @@ -1,12 +1,13 @@ import socket import re import logging +import threading from pyparsing import nestedExpr, originalTextFor tclparser = nestedExpr('{', '}') -scene_head_re = re.compile(r'Scene (\d+) active ([01]) WxH (\d+)x(\d+) at (\d+),(\d+) name (.*)') +scene_head_re = re.compile(r'Scene (\d+) active ([01])"? WxH (\d+)x(\d+) at (\d+),(\d+) name (.*)') scene_back_re = re.compile(r'- back : (\w+) (\d+) WxH (\d+)x(\d+) at ([\d.]+),([\d.]+) shape (\d+) place (\d+)') scene_frame_re = re.compile(r'- frame (\d+) active (\d+) : (\d+)x(\d+) at (\d+),(\d+) source ([\w-]+),([\w-]+) id ([\d-]+),([\d-]+) shape (\d+),(\d+) place (\d+),(\d+)') image_list_re = re.compile('^image load (\d+) <(.*)> (\d+)x(\d+) bit depth (\d+) type (.*) seqno (\d+)$') @@ -15,9 +16,20 @@ feed_list_re = re.compile(r'^feed (\d+) : (.*) (.*) (.*) (\d+)x(\d+) (\d+),(\d+) def parse_tcl(data): return tclparser.parseString('{%s}' % (data,))[0].asList() +def cast(l, t, kl, default=None): + for k in kl: + try: + l[k] = t(l[k]) + except: + logging.exception('Cast to %r failed on %r -> %r', t, k, l) + l[k] = default + + return l + class SnowmixClient(object): def __init__(self, host='127.0.0.1', port=9999): self.connect(host, port) + self.sem = threading.Semaphore() def connect(self, host, port): self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -28,68 +40,81 @@ class SnowmixClient(object): def flush_input(self): self.sock.setblocking(0) + try: self.fd.read() except: pass + self.sock.setblocking(1) self.sock.settimeout(1.0) def call(self, command, expect='MSG:'): - self.flush_input() + with self.sem: + self.flush_input() - self.fd.write(command + '\r\n') - self.fd.flush() - while True: - line = self.fd.readline() + self.fd.write(command + '\r\n') + self.fd.flush() + while True: + line = self.fd.readline() + print('< ' + line.strip()) - if expect and line.startswith(expect): - line = line[len(expect):].strip() - if not line: - return + if expect and line.startswith(expect): + line = line[len(expect):].strip() + if not line: + return - yield line + yield line def tcl(self, code): return parse_tcl(next( self.call('tcl eval snowmix message [%s]' % code), '')) + def scene_list(self): + return [int(s) for s in next(self.call('tcl eval ScenesList')).split(' ')[2:]] + def scene_info(self, scene_id): lines = list(self.call('tcl eval SceneList %d' % scene_id)) s = {'frames': {}} - _, s['active'], s['width'], s['height'], s['x'], s['y'], s['name'] = \ + s['id'], s['active'], s['width'], s['height'], s['x'], s['y'], s['name'] = \ scene_head_re.match(lines[0]).groups() bg_type, bg_id, _, _, _, _ ,_ ,_ = \ scene_back_re.match(lines[1]).groups() for l in lines[2:]: pos = {} - front = {} - back = {} + front = {'source': [None, None]} + back = {'source': [None, None]} frame = {'position': pos, 'back': back, 'front': front} frame['id'], frame['active'], pos['width'], pos['height'], \ - pos['x'], pos['y'], front['type'], back['type'], \ - front['id'], back['id'], _, _, _, _ = \ + pos['x'], pos['y'], front['source'][0], back['source'][0], \ + front['source'][1], back['source'][1], _, _, _, _ = \ scene_frame_re.match(l).groups() s['frames'][frame['id']] = frame + frame['active'] = frame['active'] == '1' + cast(pos, int, ['width', 'height', 'x', 'y']) + back['source'] = ':'.join(back['source']) + front['source'] = ':'.join(front['source']) + + alpha = self.tcl('SceneAlpha %d' % scene_id) + s['alpha'] = float(alpha[0][0]) + s['background_alpha'] = float(alpha[0][1]) + s['text_alpha'] = float(alpha[0][2]) - alpha = c.tcl('SceneAlpha %d' % scene_id) - s['alpha'] = alpha[0][0] - s['background_alpha'] = alpha[0][1] - s['text_alpha'] = alpha[0][2] - print(alpha) for k, front_alpha, back_alpha in alpha[1:]: - s['frames'][k]['front']['alpha'] = front_alpha - s['frames'][k]['back']['alpha'] = back_alpha + s['frames'][k]['front']['alpha'] = float(front_alpha) + s['frames'][k]['back']['alpha'] = float(back_alpha) - alphalink = c.tcl('SceneAlphaLink %d' % scene_id) - s['alpha_background_link'] = alphalink[0][0] - s['alpha_text_link'] = alphalink[0][1] + alphalink = self.tcl('SceneAlphaLink %d' % scene_id) + s['alpha_background_link'] = alphalink[0][0] == '1' + s['alpha_text_link'] = alphalink[0][1] == '1' for k, link in alphalink[1:]: - s['frames'][k]['alpha_link'] = link + s['frames'][k]['alpha_link'] = link == '1' + cast(s, int, ['active', 'width', 'height', 'x', 'y', + 'alpha_background_link', 'alpha_text_link']) return s def image_list(self): @@ -99,6 +124,8 @@ class SnowmixClient(object): img['id'], img['source'], img['width'], img['height'], \ img['bit'], img['type'], img['seqno'] = \ image_list_re.match(l).groups() + cast(img, int, ['width', 'height', 'bit', 'seqno']) + images.append(img) return images @@ -112,10 +139,22 @@ class SnowmixClient(object): f['offsetx'], f['offsety'], f['fifo1'], f['fifo2'], \ f['good'], f['missed'], f['dropped'], f['name'] = \ feed_list_re.match(l).groups() + cast(f, int, ['width', 'height', 'cutstartx', 'cutstarty', + 'cutw', 'cuth', 'offsetx', 'offsety', + 'fifo1', 'fifo2', 'good', 'missed', 'dropped']) feeds.append(f) return feeds + def scene_cut(self, scene_id): + self.tcl('SceneSetState %d 1 0' % scene_id) + + def scene_fade(self, scene_id): + self.tcl('SceneSetState %d 1 1' % scene_id) + + def scene_alpha(self, scene_id, alpha, frame_id=-1): + self.tcl('SceneAlpha %d %d %f' % (scene_id, frame_id, alpha)) + if __name__ == "__main__": c = SnowmixClient('127.0.0.1') diff --git a/config/janus/janus.cfg b/config/janus/janus.cfg index 0a234a7..a06000f 100644 --- a/config/janus/janus.cfg +++ b/config/janus/janus.cfg @@ -76,11 +76,11 @@ cert_key = /opt/janus/share/janus/certs/mycert.key ; be used for gathering candidates, and enable or disable the ; internal libnice debugging, if needed. [nat] -;stun_server = stun.voip.eutelia.it -;stun_port = 3478 +stun_server = stun.voip.eutelia.it +stun_port = 3478 -stun_server = stun.l.google.com -stun_port = 19302 +;stun_server = stun.l.google.com +;stun_port = 19302 nice_debug = false ;ice_lite = true ;ice_tcp = true diff --git a/config/janus/janus.plugin.streaming.cfg b/config/janus/janus.plugin.streaming.cfg index 6dbd299..1220976 100644 --- a/config/janus/janus.plugin.streaming.cfg +++ b/config/janus/janus.plugin.streaming.cfg @@ -38,7 +38,7 @@ ; to the plugins/streams folder. [general] -;admin_key = supersecret ; If set, mountpoints can be created via API +admin_key = alkjsdnfljaLIUDHljfndlkjsa ; If set, mountpoints can be created via API ; only if this key is provided in the request [gstreamer-sample] @@ -91,6 +91,7 @@ video = yes videoport = 8004 videopt = 96 videortpmap = H264/90000 +videobufferkf = yes videofmtp = profile-level-id=42e01f\;packetization-mode=1 #videofmtp = profile-level-id=64001f\;packetization-mode=1 diff --git a/docker-compose.yml b/docker-compose.yml index 3db99f5..06926fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,5 +45,14 @@ services: - ./html:/usr/share/nginx/html:ro ports: - 8080:80 + + api: + build: api + restart: unless-stopped + ports: + - 5000:5000 + volumes: + - ./api:/app + volumes: sockets: