From 503f7a1636b8d6ffce083cc0b72e6b735e09d219 Mon Sep 17 00:00:00 2001 From: Ruben van de Ven Date: Thu, 3 Feb 2022 17:39:59 +0100 Subject: [PATCH] support to pick up drawing on an existing file. Creates a copy on changes --- files/.gitignore | 2 - files/audio/.gitignore | 2 - svganim.service | 3 +- svganim/strokes.py | 18 +++-- webserver.py | 149 +++++++++++++++++++++++++++++++++++++---- www/annotate.html | 25 ++++++- www/draw.html | 24 ++++++- www/draw.js | 67 +++++++++++++++++- www/playlist.js | 23 ++++++- 9 files changed, 281 insertions(+), 32 deletions(-) delete mode 100644 files/.gitignore delete mode 100644 files/audio/.gitignore diff --git a/files/.gitignore b/files/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/files/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/files/audio/.gitignore b/files/audio/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/files/audio/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/svganim.service b/svganim.service index e86264f..76001fe 100644 --- a/svganim.service +++ b/svganim.service @@ -1,10 +1,11 @@ [Unit] Description=SVG animation interfaces [Service] -ExecStart=/home/svganim/.poetry/bin/poetry webserver.py +ExecStart=/home/svganim/.poetry/bin/poetry run python webserver.py WorkingDirectory=/home/svganim/svganim User=svganim Restart=on-failure +Environment="PATH=/home/svganim/.pyenv/plugins/pyenv-virtualenv/shims:/home/svganim/.pyenv/shims:/home/svganim/.pyenv/bin:/home/svganim/.poetry/bin:/usr/local/bin:/usr/bin:/bin" [Install] WantedBy=multi-user.target diff --git a/svganim/strokes.py b/svganim/strokes.py index 45a4396..c911aad 100644 --- a/svganim/strokes.py +++ b/svganim/strokes.py @@ -8,7 +8,9 @@ from pydub import AudioSegment import svgwrite import tempfile import io +import logging +logger = logging.getLogger('svganim.strokes') class Annotation: def __init__(self, tag: str, drawing: Drawing, t_in: float, t_out: float) -> None: @@ -46,6 +48,7 @@ class Drawing: return f"/annotations/{self.id}" def get_canvas_metadata(self) -> list: + logger.info(f'metadata for {self.id}') with open(self.eventfile, "r") as fp: first_line = fp.readline().strip() @@ -79,6 +82,10 @@ class Drawing: # metadata on first line pass 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": @@ -275,11 +282,12 @@ class AnnotationIndex: self.drawing_dir = drawing_dir self.metadata_dir = metadata_dir - self.shelve = shelve.open(filename, writeback=True) + self.shelve = {} #disable disk cache because of glitches shelve.open(filename, writeback=True) def refresh(self): # reset the index - for key in self.shelve: + for key in list(self.shelve.keys()): + print(key) del self.shelve[key] self.shelve["_drawings"] = { @@ -328,7 +336,7 @@ class AnnotationIndex: return [ name[:-16] for name in os.listdir(self.drawing_dir) - if name.endswith("json_appendable") + if name.endswith("json_appendable") and os.stat(os.path.join(self.drawing_dir, name)).st_size > 0 ] def get_drawing_filenames(self) -> list[Filename]: @@ -346,8 +354,8 @@ class AnnotationIndex: class Point: def __init__(self, x: float, y: float, last: bool, t: float): - self.x = x - self.y = y + self.x = float(x) + self.y = float(y) # if y == 0 it can still be integer.... odd python self.last = last self.t = t diff --git a/webserver.py b/webserver.py index 888d263..8ca6c01 100644 --- a/webserver.py +++ b/webserver.py @@ -46,6 +46,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): self.config = config self.strokes = [] self.hasWritten = False + self.prev_file = None self.dimensions = [None, None] # def check_origin(self, origin): @@ -69,11 +70,28 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): 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" + 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( @@ -91,6 +109,33 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): # 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 + + with open(self.prev_file, "r") as fp: + first_line = fp.readline().strip() + if first_line.endswith(","): + first_line = first_line[:-1] + + metadata = json.loads(first_line) + + self.write_message(json.dumps( + {"preloaded_svg": f"/drawing/{file}", "dimensions": [metadata[1], metadata[2]]})) + # the client sent the message def on_message(self, message): logger.info(f"recieve: {message}") @@ -106,6 +151,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): elif msg["event"] == "viewbox": logger.info("move or resize") 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)) @@ -168,25 +215,34 @@ class AnimationHandler(tornado.web.RequestHandler): if name.endswith("json_appendable") ] for name in names: - with open(os.path.join(self.config.storage, name), "r") as fp: + 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] - print(first_line) + metadata = json.loads(first_line) files.append( { "name": f"/files/{name[:-16]}", - "time": metadata[0], + "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["time"]) + 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" + self.config.storage, os.path.basename( + filename) + ".json_appendable" ) drawing = {"file": filename, "shape": []} with open(path, "r") as fp: @@ -205,7 +261,8 @@ class AnimationHandler(tornado.web.RequestHandler): # 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"]} + {"color": event["color"], + "points": event["points"]} ) self.write(json.dumps(drawing)) @@ -273,10 +330,12 @@ class AnnotationHandler(tornado.web.RequestHandler): 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()) + 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()) + self.write(annotation.getAnimationSlice( + ).audio.export(format="wav").read()) else: self.set_header("Content-Type", "application/json") self.write(json.dumps({ @@ -286,6 +345,57 @@ class AnnotationHandler(tornado.web.RequestHandler): })) +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 @@ -337,6 +447,7 @@ class AnnotationsHandler(tornado.web.RequestHandler): with open(meta_file, "w") as fp: json.dump(self.json_args, fp) + class IndexHandler(tornado.web.RequestHandler): """Get annotation as svg""" @@ -350,9 +461,10 @@ class IndexHandler(tornado.web.RequestHandler): 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 @@ -392,8 +504,10 @@ class Server: {"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"/annotations/(.+)", AnnotationsHandler, + {"config": self.config}), + (r"/tags", TagHandler, + {"config": self.config, "index": self.index}), ( r"/tags/(.+)", TagAnnotationsHandler, @@ -404,9 +518,16 @@ class Server: AnnotationHandler, {"config": self.config, "index": self.index}, ), - (r"/(.+)", StaticFileWithHeaderHandler, {"path": self.web_root}), + ( + r"/drawing/(.+)", + DrawingHandler, + {"config": self.config, "index": self.index}, + ), + (r"/(.+)", StaticFileWithHeaderHandler, + {"path": self.web_root}), - (r"/", IndexHandler, {"config": self.config, "index": self.index}), + (r"/", IndexHandler, + {"config": self.config, "index": self.index}), ], debug=True, autoreload=True, diff --git a/www/annotate.html b/www/annotate.html index d33fea0..045f46d 100644 --- a/www/annotate.html +++ b/www/annotate.html @@ -193,6 +193,21 @@ .annotation-google { background-color: blueviolet !important; } + .annotation-map { + background-color: red !important; + } + + .annotation-relation { + background-color: blue !important; + } + + .annotation-text { + background-color: blueviolet !important; + } + + .annotation-figure { + background-color: pink !important; + } .unsaved::before { content: '*'; @@ -240,6 +255,14 @@ width: 100px; /* hides seek head */ } + .playlist img{ + position: static; + width: 250px; + height: 250px; + background: white; + display: block; + } + @@ -257,7 +280,7 @@ if (location.search) { ann = new Annotator( document.getElementById("interface"), - ["test", "another", "google"], + ["map", "text", "relation", "figure"], location.search.substring(1) ); } else { diff --git a/www/draw.html b/www/draw.html index 24995ed..63ac0f2 100644 --- a/www/draw.html +++ b/www/draw.html @@ -34,7 +34,7 @@ path { fill: none; - stroke: red; + stroke: auto; stroke-width: 1mm; stroke-linecap: round; } @@ -60,7 +60,7 @@ margin: 0; font-family: sans-serif; /* prevent reload on scroll in chrome */ - position: fixed; + position: fixed; overscroll-behavior: contain; overflow-y: hidden; @@ -170,7 +170,25 @@ diff --git a/www/draw.js b/www/draw.js index 53804c1..d7da4a8 100644 --- a/www/draw.js +++ b/www/draw.js @@ -1,5 +1,29 @@ +// loadDrawing = function(identifier){ +// const request = new Request('/copy_and_load/'+identifier, { +// method: 'GET', +// }); + +// // fetch(request) +// // .then(response => response.json()) +// // .then(data => { +// // const metadata_req = new Request(`/annotations/${data.file}`, { +// // method: 'GET', +// // }); +// // fetch(metadata_req) +// // .then(response => response.ok ? response.json() : null) +// // .then(metadata => { +// // if (metadata !== null) { +// // metadata.annotations = metadata.annotations.map((a) => new Annotation(a.tag, a.t_in, a.t_out)) +// // } +// // this.loadStrokes(data, metadata) +// // }) +// // .catch(e => console.log(e)); +// // // do something with the data sent in the request +// // }); +// } + class Canvas { - constructor(wrapperEl) { + constructor(wrapperEl, preload_id) { this.allowDrawing = false; this.viewbox = { "x": 0, "y": 0, "width": null, "height": null }; this.url = window.location.origin.replace('http', 'ws') + '/ws?' + window.location.search.substring(1); @@ -49,10 +73,12 @@ class Canvas { document.body.addEventListener('pointerup', (ev) => { ev.stopPropagation(); ev.preventDefault(); + if (ev.pointerType == "touch" || ev.buttons & 4 || this.isMoving) { // buttons is 0 on pointerup this.endMoveCanvas(ev); this.isMoving = false; } else { // pointerType == pen or mouse + location.hash = '#' + this.filename; // only update when drawn. this.penup(ev); } }); @@ -81,6 +107,14 @@ class Canvas { }; console.log('send', d); this.socket.send(JSON.stringify(d)); + + if (preload_id) { + // signal if we want to continue from an existing drawing + this.socket.send(JSON.stringify({ + 'event': 'preload', + 'file': preload_id, + })); + } }) this.socket.addEventListener('message', (e) => { let msg = JSON.parse(e.data); @@ -90,9 +124,36 @@ class Canvas { this.setFilename(msg.filename); this.openTheFloor() } + if (msg.hasOwnProperty('preloaded_svg')) { + console.log('preloaded', msg); + if(msg.dimensions[0] != this.viewbox.width || msg.dimensions[1] != this.viewbox.height){ + alert(`Loading file with different dimensions. This can lead to odd results. Original: ${msg.dimensions[0]}x${msg.dimensions[1]} Now: ${this.viewbox.width}x${this.viewbox.height}`) + } + this.setPreloaded(msg.preloaded_svg); + + // this.setFilename(msg.filename); + // this.openTheFloor() + } }); } + setPreloaded(json_url) { + this.preloaded_resource = json_url + const request = new Request(this.preloaded_resource + '.svg', { + method: 'GET', + }); + + fetch(request) + .then(response => response.text()) + .then(body => { + const parser = new DOMParser() + const dom = parser.parseFromString(body, "image/svg+xml"); + console.log(dom, dom.getRootNode().querySelectorAll('g')) + const group = dom.getRootNode().querySelectorAll('g')[0] + this.svgEl.prepend(group); + }) + } + startMoveCanvas(ev) { this.moveCanvasPrevPoint = { "x": ev.x, "y": ev.y }; this.currentMoves = []; @@ -121,7 +182,7 @@ class Canvas { this.viewbox.x -= diff.x; this.viewbox.y -= diff.y; this.moveCanvasPrevPoint = { "x": ev.x, "y": ev.y }; - this.currentMoves.push(Object.assign({'t': window.performance.now() - this.startTime}, this.viewbox)); + this.currentMoves.push(Object.assign({ 't': window.performance.now() - this.startTime }, this.viewbox)); this.applyViewBox() } @@ -248,7 +309,7 @@ class Canvas { if (!this.isDrawing) { return; } - + this.isDrawing = false; //document.body.removeEventListener('mousemove', draw); diff --git a/www/playlist.js b/www/playlist.js index 881fd50..a544a59 100644 --- a/www/playlist.js +++ b/www/playlist.js @@ -23,11 +23,24 @@ class Playlist { for (let file of data) { const liEl = document.createElement("li"); + + const imgEl = document.createElement("img"); + imgEl.classList.add('img'); + imgEl.title = file.id; + imgEl.src = file.svg; + liEl.append(imgEl); + + + let time = file.mtime; + if (file.ctime != file.mtime){ + time += ` (orig: ${file.ctime})`; + } const dateEl = document.createElement("span"); dateEl.classList.add('date'); - dateEl.innerText = file.time; + dateEl.innerText = time; liEl.append(dateEl); + const nameEl = document.createElement("span"); nameEl.classList.add('name'); nameEl.innerText = file.name; @@ -52,6 +65,14 @@ class Playlist { annotateEl.pathname = "annotate.html"; annotateEl.search = "?"+file.name; linksEl.append(annotateEl); + + const drawEl = document.createElement("a"); + drawEl.classList.add('draw'); + drawEl.innerText = "Draw"; + drawEl.href = location; + drawEl.pathname = "draw.html"; + drawEl.hash = file.id; + linksEl.append(drawEl); // liEl.addEventListener('click', (e) => { // this.play(fileUrl);