import json import logging import os import tornado.ioloop import tornado.web import tornado.websocket from urllib.parse import urlparse import uuid import datetime import html import argparse import coloredlogs import glob import svganim.strokes logger = logging.getLogger("svganim.webserver") class DateTimeEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, datetime.datetime): return o.isoformat(timespec="milliseconds") return super().default(self, o) class StaticFileWithHeaderHandler(tornado.web.StaticFileHandler): def set_extra_headers(self, path): """For subclass to add extra headers to the response""" if path[-5:] == ".html": self.set_header("Access-Control-Allow-Origin", "*") if path[-4:] == ".svg": self.set_header("Content-Type", "image/svg+xml") class WebSocketHandler(tornado.websocket.WebSocketHandler): """ Websocket from the drawing """ # CORS_ORIGINS = ['localhost'] connections = set() def initialize(self, config): self.config = config self.strokes = [] self.hasWritten = False self.prev_file = None self.prev_file_duration = 0 self.dimensions = [None, None] # def check_origin(self, origin): # parsed_origin = urlparse(origin) # # parsed_origin.netloc.lower() gives localhost:3333 # valid = any([parsed_origin.hostname.endswith(origin) for origin in self.CORS_ORIGINS]) # return valid # the client connected def open(self, p=None): self.__class__.connections.add(self) self.prefix = datetime.datetime.now().strftime("%Y-%m-%d-") self.filename = ( self.prefix + str(self.check_filenr()) + "-" + uuid.uuid4().hex[:6] ) logger.info(f"{self.filename=}") self.write_message(json.dumps({"filename": self.filename})) def check_filenr(self): files = glob.glob(os.path.join(self.config.storage, self.prefix + "*")) return len(files) + 1 def appendEvent(self, row): if not self.hasWritten and self.prev_file and 'event' in row and row['event'] == 'viewbox': # ignore canvas movement after return # write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable with open( os.path.join(self.config.storage, self.filename + ".json_appendable"), "a" ) as fp: if not self.hasWritten: if self.prev_file: # TODO WIP with open( self.prev_file, 'r' ) as fprev: wrote = False for line in fprev: wrote = True fp.write(line) if wrote: fp.write(",\n") # metadata to first row, but only on demand fp.write( json.dumps( [ datetime.datetime.now().strftime("%Y-%m-%d %T"), self.dimensions[0], self.dimensions[1], ] ) ) # writer.writerow() self.hasWritten = True fp.write(",\n") # first column is color, rest is points fp.write(json.dumps(row)) def preloadFile(self, file): if self.hasWritten: logger.error("Cannot preload when already written content") return False logger.info(f"load {file}") # TODO, make sure file doesn't load file outside of storage prev_file = os.path.join( self.config.storage, file + ".json_appendable") if not os.path.exists(prev_file): logger.error(f"Cannot preload non-existent file: {prev_file}") self.write_message(json.dumps( {"error": f"Non-existent file: {file}"})) return False self.prev_file = prev_file metadata = self.getFileMetadata(self.prev_file) self.prev_file_duration = self.getLastTimestampInFile(self.prev_file) logger.info( "Previous file set. {self.prev_file} {metadata=} time: {self.prev_file_duration}") self.write_message(json.dumps( {"preloaded_svg": f"/drawing/{file}", "dimensions": [metadata[1], metadata[2]], "time": self.prev_file_duration})) def getFileMetadata(self, filename): with open(filename, "r") as fp: first_line = fp.readline().strip() if first_line.endswith(","): first_line = first_line[:-1] metadata = json.loads(first_line) return metadata def getLastTimestampInFile(self, filename): with open(filename, "r") as fp: for line in fp: pass # loop until the end last_line = line.strip() if last_line.endswith(","): last_line = last_line[:-1] data = json.loads(last_line) if type(data) is list: raise Exception("Oddly, the file ends with merely metadata") if data['event'] == 'stroke': return data['points'][-1][3] elif data['event'] == 'viewbox': return data['viewboxes'][-1]['t'] else: raise Exception("Unknown last event") # the client sent the message def on_message(self, message): logger.info(f"recieve: {message}") try: msg = json.loads(message) if msg["event"] == "stroke": logger.info("stroke") for i in range(len(msg['points'])): msg['points'][i][3] += self.prev_file_duration self.appendEvent(msg) elif msg["event"] == "dimensions": self.dimensions = [int(msg["width"]), int(msg["height"])] logger.info(f"{self.dimensions=}") elif msg["event"] == "viewbox": logger.info("move or resize") if len(msg['viewboxes']) == 0: logger.warn("Empty viewbox array") else: for i in range(len(msg['viewboxes'])): msg['viewboxes'][i]['t'] += self.prev_file_duration self.appendEvent(msg) elif msg["event"] == "preload": self.preloadFile(msg["file"]) else: # self.send({'alert': 'Unknown request: {}'.format(message)}) logger.warn("Unknown request: {}".format(message)) except Exception as e: # self.send({'alert': 'Invalid request: {}'.format(e)}) logger.exception(e) # client disconnected def on_close(self): self.__class__.rmConnection(self) logger.info(f"Client disconnected: {self.request.remote_ip}") @classmethod def rmConnection(cls, client): if client not in cls.connections: return cls.connections.remove(client) @classmethod def hasConnection(cls, client): return client in cls.connections class AudioListingHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config self.audiodir = os.path.join(self.config.storage, "audio") def get(self): # filename = self.get_argument("file", None) self.set_header("Content-Type", "application/json") if not os.path.exists(self.audiodir): names = [] else: names = sorted( [ f"/audio/{name}" for name in os.listdir(self.audiodir) if name not in [".gitignore"] ] ) print(names) self.write(json.dumps(names)) class AnimationHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config def get(self, filename): self.set_header("Content-Type", "application/json") # filename = self.get_argument("file", None) if filename == "": files = [] names = [ name for name in os.listdir(self.config.storage) if name.endswith("json_appendable") ] for name in names: fn = os.path.join(self.config.storage, name) stat = os.stat(fn) if stat.st_size == 0: continue with open(fn, "r") as fp: first_line = fp.readline().strip() if first_line.endswith(","): first_line = first_line[:-1] metadata = json.loads(first_line) files.append( { "name": f"/files/{name[:-16]}", "id": name[:-16], "ctime": metadata[0], "mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"), "dimensions": [metadata[1], metadata[2]], "svg": f"/drawing/{name[:-16]}.svg", } ) files.sort(key=lambda k: k["mtime"]) self.write(json.dumps(files)) else: path = os.path.join( self.config.storage, os.path.basename( filename) + ".json_appendable" ) drawing = {"file": filename, "shape": []} with open(path, "r") as fp: events = json.loads("[" + fp.read() + "]") for i, event in enumerate(events): if i == 0: # metadata on first line drawing["time"] = event[0] drawing["dimensions"] = [event[1], event[2]] else: if type(event) is list: # ignore double metadatas, which appear when continuaing an existing drawing continue if event["event"] == "viewbox": pass if event["event"] == "stroke": # points = [] # for i in range(int(len(stroke) / 4)): # p = stroke[i*4:i*4+4] # points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])]) drawing["shape"].append( {"color": event["color"], "points": event["points"]} ) self.write(json.dumps(drawing)) class TagHandler(tornado.web.RequestHandler): """List all tags""" def initialize(self, config, index: svganim.strokes.AnnotationIndex): self.config = config self.index = index self.metadir = os.path.join(self.config.storage, "metadata") def get(self): self.set_header("Content-Type", "application/json") tags = self.index.tags.keys() self.write(json.dumps(list(tags))) class TagAnnotationsHandler(tornado.web.RequestHandler): """List all annotations for given tag""" def initialize(self, config, index: svganim.strokes.AnnotationIndex): self.config = config self.index = index self.metadir = os.path.join(self.config.storage, "metadata") def get(self, tag): if tag not in self.index.tags: raise tornado.web.HTTPError(404) self.set_header("Content-Type", "application/json") annotations = self.index.tags[tag] self.write(json.dumps(list([a.id for a in annotations]))) class AnnotationHandler(tornado.web.RequestHandler): """Get annotation as svg""" def initialize(self, config, index: svganim.strokes.AnnotationIndex): self.config = config self.index = index self.metadir = os.path.join(self.config.storage, "metadata") def get(self, annotation_id): if annotation_id[-4:] == ".svg": extension = "svg" annotation_id = annotation_id[:-4] elif annotation_id[-4:] == ".mp3": extension = "mp3" annotation_id = annotation_id[:-4] elif annotation_id[-4:] == ".wav": extension = "wav" annotation_id = annotation_id[:-4] else: extension = None logger.info(f"annotation {annotation_id=}, {extension=}") if annotation_id not in self.index.annotations: raise tornado.web.HTTPError(404) annotation = self.index.annotations[annotation_id] if extension == "svg": self.set_header("Content-Type", "image/svg+xml") self.write(annotation.get_as_svg()) elif extension == "mp3": self.set_header("Content-Type", "audio/mp3") self.write(annotation.getAnimationSlice( ).audio.export(format="mp3").read()) elif extension == "wav": self.set_header("Content-Type", "audio/wav") self.write(annotation.getAnimationSlice( ).audio.export(format="wav").read()) else: self.set_header("Content-Type", "application/json") self.write(json.dumps({ "id": annotation.id, "tag": annotation.tag, "audio": f"/annotation/{annotation.id}.mp3", })) class DrawingHandler(tornado.web.RequestHandler): """Get drawing as svg""" def initialize(self, config, index: svganim.strokes.AnnotationIndex): self.config = config self.index = index self.metadir = os.path.join(self.config.storage, "metadata") def get(self, drawing_id): if drawing_id[-4:] == ".svg": extension = "svg" drawing_id = drawing_id[:-4] elif drawing_id[-4:] == ".mp3": extension = "mp3" drawing_id = drawing_id[:-4] elif drawing_id[-4:] == ".wav": extension = "wav" drawing_id = drawing_id[:-4] else: extension = None logger.info(f"drawing {drawing_id=}, {extension=}") if drawing_id not in self.index.drawings: self.index.refresh() # double check if drawing_id not in self.index.drawings: raise tornado.web.HTTPError(404) drawing = self.index.drawings[drawing_id] if extension == "svg": self.set_header("Content-Type", "image/svg+xml") self.write(drawing.get_animation().get_as_svg()) elif extension == "mp3": self.set_header("Content-Type", "audio/mp3") self.write(drawing.get_animation( ).audio.export(format="mp3").read()) elif extension == "wav": self.set_header("Content-Type", "audio/wav") self.write(drawing.get_animation( ).audio.export(format="wav").read()) else: self.set_header("Content-Type", "application/json") self.write(json.dumps({ "id": drawing.id, "annotations_url": drawing.get_annotations_url(), "audio": f"/drawing/{drawing.id}.mp3", "svg": f"/drawing/{drawing.id}.svg", })) class AnnotationsHandler(tornado.web.RequestHandler): def initialize(self, config): self.config = config self.metadir = os.path.join(self.config.storage, "metadata") def prepare(self): if self.request.headers.get("Content-Type", "").startswith("application/json"): self.json_args = json.loads(self.request.body) else: self.json_args = None def get_filenames(self): return [ name[:-16] for name in os.listdir(self.config.storage) if name.endswith("json_appendable") ] def get(self, filename): self.set_header("Content-Type", "application/json") filenames = self.get_filenames() print(filenames, filename) if filename not in filenames: raise tornado.web.HTTPError(404) meta_file = os.path.join(self.metadir, filename + ".json") if not os.path.exists(meta_file): self.set_status(404) return with open(meta_file, "r") as fp: self.write(json.load(fp)) def post(self, filename): # filename = self.argument("file", None) filenames = self.get_filenames() print(filenames, filename) if filename not in filenames: raise tornado.web.HTTPError(404) if not os.path.exists(self.metadir): os.mkdir(self.metadir) meta_file = os.path.join(self.metadir, filename + ".json") with open(meta_file, "w") as fp: json.dump(self.json_args, fp) class IndexHandler(tornado.web.RequestHandler): """Get annotation as svg""" def initialize(self, config, index: svganim.strokes.AnnotationIndex): self.config = config self.index = index def get(self): do_refresh = bool(self.get_query_argument('refresh', False)) if do_refresh: self.logger.info("Reloading Annotation Index") self.index.refresh() self.logger.info("\treloaded annotation index") self.render("templates/index.html", index=self.index) class Server: """ Server for HIT -> plotter events As well as for the Status interface """ loop = None def __init__(self, config, logger): self.config = config self.logger = logger # self.config['server']['port'] self.web_root = os.path.join("www") self.index = svganim.strokes.AnnotationIndex( "annotation_index.shelve", "files", "files/metadata" ) self.logger.info("Loading Annotation Index") self.index.refresh() self.logger.info("\tloaded annotation index") def start(self): application = tornado.web.Application( [ ( r"/ws(.*)", WebSocketHandler, { "config": self.config, }, ), (r"/files/(.*)", AnimationHandler, {"config": self.config}), ( r"/audio/(.+)", tornado.web.StaticFileHandler, {"path": os.path.join(self.config.storage, "audio")}, ), (r"/audio", AudioListingHandler, {"config": self.config}), (r"/annotations/(.+)", AnnotationsHandler, {"config": self.config}), (r"/tags", TagHandler, {"config": self.config, "index": self.index}), ( r"/tags/(.+)", TagAnnotationsHandler, {"config": self.config, "index": self.index}, ), ( r"/annotation/(.+)", AnnotationHandler, {"config": self.config, "index": self.index}, ), ( r"/drawing/(.+)", DrawingHandler, {"config": self.config, "index": self.index}, ), (r"/index", IndexHandler, {"config": self.config, "index": self.index}), (r"/(.*)", StaticFileWithHeaderHandler, {"path": self.web_root, 'default_filename': 'index.html'}), ], debug=True, autoreload=True, ) application.listen(self.config.port) tornado.ioloop.IOLoop.current().start() if __name__ == "__main__": argParser = argparse.ArgumentParser( description="Start up the vector animation server" ) # argParser.add_argument( # '--config', # '-c', # required=True, # type=str, # help='The yaml config file to load' # ) argParser.add_argument("--port", type=int, default=7890, help="Port") argParser.add_argument( "--storage", type=str, default="files", help="directory name for output files" ) argParser.add_argument("--verbose", "-v", action="count", default=0) args = argParser.parse_args() loglevel = ( logging.NOTSET if args.verbose > 1 else logging.DEBUG if args.verbose > 0 else logging.INFO ) coloredlogs.install( level=loglevel, # default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s" fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s", ) # File logging formatter = logging.Formatter( fmt="%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s", datefmt="%Y/%m/%d %H:%M:%S", ) # %I:%M:%S %p AM|PM format logFileHandler = logging.handlers.RotatingFileHandler( "log/draw_log.log", maxBytes=1024 * 512, backupCount=5 ) logFileHandler.setFormatter(formatter) logger.addHandler(logFileHandler) logger.info(f"Start server: http://localhost:{args.port}") server = Server(args, logger) server.start()