support to pick up drawing on an existing file. Creates a copy on changes

This commit is contained in:
Ruben van de Ven 2022-02-03 17:39:59 +01:00
parent a83b05daaa
commit 503f7a1636
9 changed files with 281 additions and 32 deletions

2
files/.gitignore vendored
View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,10 +1,11 @@
[Unit] [Unit]
Description=SVG animation interfaces Description=SVG animation interfaces
[Service] [Service]
ExecStart=/home/svganim/.poetry/bin/poetry webserver.py ExecStart=/home/svganim/.poetry/bin/poetry run python webserver.py
WorkingDirectory=/home/svganim/svganim WorkingDirectory=/home/svganim/svganim
User=svganim User=svganim
Restart=on-failure 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -8,7 +8,9 @@ from pydub import AudioSegment
import svgwrite import svgwrite
import tempfile import tempfile
import io import io
import logging
logger = logging.getLogger('svganim.strokes')
class Annotation: class Annotation:
def __init__(self, tag: str, drawing: Drawing, t_in: float, t_out: float) -> None: 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}" return f"/annotations/{self.id}"
def get_canvas_metadata(self) -> list: def get_canvas_metadata(self) -> list:
logger.info(f'metadata for {self.id}')
with open(self.eventfile, "r") as fp: with open(self.eventfile, "r") as fp:
first_line = fp.readline().strip() first_line = fp.readline().strip()
@ -79,6 +82,10 @@ class Drawing:
# metadata on first line # metadata on first line
pass pass
else: else:
if type(event) is list:
# ignore double metadatas, which appear when continuaing an existing drawing
continue
if event["event"] == "viewbox": if event["event"] == "viewbox":
pass pass
if event["event"] == "stroke": if event["event"] == "stroke":
@ -275,11 +282,12 @@ class AnnotationIndex:
self.drawing_dir = drawing_dir self.drawing_dir = drawing_dir
self.metadata_dir = metadata_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): def refresh(self):
# reset the index # reset the index
for key in self.shelve: for key in list(self.shelve.keys()):
print(key)
del self.shelve[key] del self.shelve[key]
self.shelve["_drawings"] = { self.shelve["_drawings"] = {
@ -328,7 +336,7 @@ class AnnotationIndex:
return [ return [
name[:-16] name[:-16]
for name in os.listdir(self.drawing_dir) 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]: def get_drawing_filenames(self) -> list[Filename]:
@ -346,8 +354,8 @@ class AnnotationIndex:
class Point: class Point:
def __init__(self, x: float, y: float, last: bool, t: float): def __init__(self, x: float, y: float, last: bool, t: float):
self.x = x self.x = float(x)
self.y = y self.y = float(y) # if y == 0 it can still be integer.... odd python
self.last = last self.last = last
self.t = t self.t = t

View file

@ -46,6 +46,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
self.config = config self.config = config
self.strokes = [] self.strokes = []
self.hasWritten = False self.hasWritten = False
self.prev_file = None
self.dimensions = [None, None] self.dimensions = [None, None]
# def check_origin(self, origin): # def check_origin(self, origin):
@ -69,11 +70,28 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
return len(files) + 1 return len(files) + 1
def appendEvent(self, row): 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 # write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
with open( 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: ) as fp:
if not self.hasWritten: 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 # metadata to first row, but only on demand
fp.write( fp.write(
json.dumps( json.dumps(
@ -91,6 +109,33 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
# first column is color, rest is points # first column is color, rest is points
fp.write(json.dumps(row)) 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 # the client sent the message
def on_message(self, message): def on_message(self, message):
logger.info(f"recieve: {message}") logger.info(f"recieve: {message}")
@ -106,6 +151,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
elif msg["event"] == "viewbox": elif msg["event"] == "viewbox":
logger.info("move or resize") logger.info("move or resize")
self.appendEvent(msg) self.appendEvent(msg)
elif msg["event"] == "preload":
self.preloadFile(msg["file"])
else: else:
# self.send({'alert': 'Unknown request: {}'.format(message)}) # self.send({'alert': 'Unknown request: {}'.format(message)})
logger.warn("Unknown request: {}".format(message)) logger.warn("Unknown request: {}".format(message))
@ -168,25 +215,34 @@ class AnimationHandler(tornado.web.RequestHandler):
if name.endswith("json_appendable") if name.endswith("json_appendable")
] ]
for name in names: 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() first_line = fp.readline().strip()
if first_line.endswith(","): if first_line.endswith(","):
first_line = first_line[:-1] first_line = first_line[:-1]
print(first_line)
metadata = json.loads(first_line) metadata = json.loads(first_line)
files.append( files.append(
{ {
"name": f"/files/{name[:-16]}", "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]], "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)) self.write(json.dumps(files))
else: else:
path = os.path.join( 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": []} drawing = {"file": filename, "shape": []}
with open(path, "r") as fp: with open(path, "r") as fp:
@ -205,7 +261,8 @@ class AnimationHandler(tornado.web.RequestHandler):
# p = stroke[i*4:i*4+4] # p = stroke[i*4:i*4+4]
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])]) # points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
drawing["shape"].append( drawing["shape"].append(
{"color": event["color"], "points": event["points"]} {"color": event["color"],
"points": event["points"]}
) )
self.write(json.dumps(drawing)) self.write(json.dumps(drawing))
@ -273,10 +330,12 @@ class AnnotationHandler(tornado.web.RequestHandler):
self.write(annotation.get_as_svg()) self.write(annotation.get_as_svg())
elif extension == "mp3": elif extension == "mp3":
self.set_header("Content-Type", "audio/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": elif extension == "wav":
self.set_header("Content-Type", "audio/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: else:
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
self.write(json.dumps({ 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): class AnnotationsHandler(tornado.web.RequestHandler):
def initialize(self, config): def initialize(self, config):
self.config = config self.config = config
@ -337,6 +447,7 @@ class AnnotationsHandler(tornado.web.RequestHandler):
with open(meta_file, "w") as fp: with open(meta_file, "w") as fp:
json.dump(self.json_args, fp) json.dump(self.json_args, fp)
class IndexHandler(tornado.web.RequestHandler): class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg""" """Get annotation as svg"""
@ -353,6 +464,7 @@ class IndexHandler(tornado.web.RequestHandler):
self.render("templates/index.html", index=self.index) self.render("templates/index.html", index=self.index)
class Server: class Server:
""" """
Server for HIT -> plotter events Server for HIT -> plotter events
@ -392,8 +504,10 @@ class Server:
{"path": os.path.join(self.config.storage, "audio")}, {"path": os.path.join(self.config.storage, "audio")},
), ),
(r"/audio", AudioListingHandler, {"config": self.config}), (r"/audio", AudioListingHandler, {"config": self.config}),
(r"/annotations/(.+)", AnnotationsHandler, {"config": self.config}), (r"/annotations/(.+)", AnnotationsHandler,
(r"/tags", TagHandler, {"config": self.config, "index": self.index}), {"config": self.config}),
(r"/tags", TagHandler,
{"config": self.config, "index": self.index}),
( (
r"/tags/(.+)", r"/tags/(.+)",
TagAnnotationsHandler, TagAnnotationsHandler,
@ -404,9 +518,16 @@ class Server:
AnnotationHandler, AnnotationHandler,
{"config": self.config, "index": self.index}, {"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, debug=True,
autoreload=True, autoreload=True,

View file

@ -193,6 +193,21 @@
.annotation-google { .annotation-google {
background-color: blueviolet !important; 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 { .unsaved::before {
content: '*'; content: '*';
@ -240,6 +255,14 @@
width: 100px; /* hides seek head */ width: 100px; /* hides seek head */
} }
.playlist img{
position: static;
width: 250px;
height: 250px;
background: white;
display: block;
}
</style> </style>
<link rel="stylesheet" href="assets/nouislider-15.5.0.css"> <link rel="stylesheet" href="assets/nouislider-15.5.0.css">
<link rel="stylesheet" href="core.css"> <link rel="stylesheet" href="core.css">
@ -257,7 +280,7 @@
if (location.search) { if (location.search) {
ann = new Annotator( ann = new Annotator(
document.getElementById("interface"), document.getElementById("interface"),
["test", "another", "google"], ["map", "text", "relation", "figure"],
location.search.substring(1) location.search.substring(1)
); );
} else { } else {

View file

@ -34,7 +34,7 @@
path { path {
fill: none; fill: none;
stroke: red; stroke: auto;
stroke-width: 1mm; stroke-width: 1mm;
stroke-linecap: round; stroke-linecap: round;
} }
@ -170,7 +170,25 @@
</div> </div>
<script src="draw.js"></script> <script src="draw.js"></script>
<script type='text/javascript'> <script type='text/javascript'>
const canvas = new Canvas(document.getElementById("interface")); // let canvas;
// if (location.search) {
// const
// canvas = new Canvas(
// document.getElementById("interface"),
// ["map", "text", "relation", "figure"],
// location.search.substring(1)
// );
// } else{
let preload;
if (location.hash.length) {
preload = location.hash.substring(1);
} else {
preload = null
}
const canvas = new Canvas(document.getElementById("interface"), preload);
// }
</script> </script>
</body> </body>

View file

@ -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 { class Canvas {
constructor(wrapperEl) { constructor(wrapperEl, preload_id) {
this.allowDrawing = false; this.allowDrawing = false;
this.viewbox = { "x": 0, "y": 0, "width": null, "height": null }; this.viewbox = { "x": 0, "y": 0, "width": null, "height": null };
this.url = window.location.origin.replace('http', 'ws') + '/ws?' + window.location.search.substring(1); 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) => { document.body.addEventListener('pointerup', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
if (ev.pointerType == "touch" || ev.buttons & 4 || this.isMoving) { // buttons is 0 on pointerup if (ev.pointerType == "touch" || ev.buttons & 4 || this.isMoving) { // buttons is 0 on pointerup
this.endMoveCanvas(ev); this.endMoveCanvas(ev);
this.isMoving = false; this.isMoving = false;
} else { // pointerType == pen or mouse } else { // pointerType == pen or mouse
location.hash = '#' + this.filename; // only update when drawn.
this.penup(ev); this.penup(ev);
} }
}); });
@ -81,6 +107,14 @@ class Canvas {
}; };
console.log('send', d); console.log('send', d);
this.socket.send(JSON.stringify(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) => { this.socket.addEventListener('message', (e) => {
let msg = JSON.parse(e.data); let msg = JSON.parse(e.data);
@ -90,9 +124,36 @@ class Canvas {
this.setFilename(msg.filename); this.setFilename(msg.filename);
this.openTheFloor() 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) { startMoveCanvas(ev) {
this.moveCanvasPrevPoint = { "x": ev.x, "y": ev.y }; this.moveCanvasPrevPoint = { "x": ev.x, "y": ev.y };
this.currentMoves = []; this.currentMoves = [];

View file

@ -23,11 +23,24 @@ class Playlist {
for (let file of data) { for (let file of data) {
const liEl = document.createElement("li"); 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"); const dateEl = document.createElement("span");
dateEl.classList.add('date'); dateEl.classList.add('date');
dateEl.innerText = file.time; dateEl.innerText = time;
liEl.append(dateEl); liEl.append(dateEl);
const nameEl = document.createElement("span"); const nameEl = document.createElement("span");
nameEl.classList.add('name'); nameEl.classList.add('name');
nameEl.innerText = file.name; nameEl.innerText = file.name;
@ -53,6 +66,14 @@ class Playlist {
annotateEl.search = "?"+file.name; annotateEl.search = "?"+file.name;
linksEl.append(annotateEl); 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) => { // liEl.addEventListener('click', (e) => {
// this.play(fileUrl); // this.play(fileUrl);
// playlist.style.display = "none"; // playlist.style.display = "none";