Compare commits

..

No commits in common. "main" and "50d4656ad7079d08fe518a1bb9c82f828fa767b8" have entirely different histories.

38 changed files with 2005 additions and 5269 deletions

View file

@ -1 +0,0 @@
files/

View file

@ -1,23 +0,0 @@
FROM python:3.9
# TODO manually compile ffmpeg, to prevent useless deps and decrease build size
# for that, see here: https://stackoverflow.com/questions/53944487/how-to-install-ffmpeg-in-a-docker-container
# pin ffmpeg version to prevent issues down the road
RUN apt-get update && apt-get -y install ffmpeg=7:4.3.*
RUN mkdir /app
COPY pyproject.toml /app
COPY poetry.lock /app
WORKDIR /app
ENV PYTHONPATH=${PYTHONPATH}:${PWD}
RUN pip3 install poetry
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev
# copy files as late as possible, these change the most
# forcing a rerun of all layers
COPY /app /app
ENTRYPOINT poetry run python webserver.py --storage /files

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Ruben van de Ven
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

127
README.md
View file

@ -1,124 +1,7 @@
# Annotated Vector Drawing Animations
# svganim
<p align="center">
<a href="#getting-started">Getting Started</a>
<a href="#usage">Usage</a>
<a href="#license">License</a>
<a href="#credits">Credits</a>
<a href="#citation">Citation</a>
</p>
Create a hand drawn vector animation.
This software allows for drawings to be made and played back in a time-based format. Additionally, it contains tools to create excerpts of the drawings and browse these segments by annotation.
This software was developed in light of a research project in which we used it in an interview setting. Asking speakers not just to speak, but to draw the entities they mention and the relations between them. This adds a visual layer on top of the audio.
For more information on the rationale behind developing this tool, see the [citation](#citation) section.
## Getting Started
These instructions will give you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on deploying the project on a live system.
### Prerequisites
The easiest way to run svganim is with docker-compose. Alternatively you can choose set up your own python environment. If you do so, ffmpeg is required.
### Installation
* `git clone` this repository.
* `cd` into the folder and run `docker-compose build`
* `docker-compose up -d`
* Docker should now expose the service at port 7890. Edit docker-compose.yml to change this.
Alternatively, you can set up the environment using [poetry](https://python-poetry.org/).
* `cd app`
* `poetry install` to install dependencies.
* `poetry run python webserver.py --storage ../file`
### Update
* `git pull` the new revision
* rerun `docker-compose build` to rebuild the image
* rerun `docker-compose up -d` to restart the container
### Deployment
The server is best exposed through a proxy. An [example Apache configuration file](./diagramming-apache-site.example.conf) is included.
## Usage
When the service runs, open it using your browser. The first page shows three options:
* Tags -- Manage a set of tags for annotating.
* Drawings -- All recorded drawings to be played back and annotated (or removed).
* Draw -- start drawing a diagram.
### Drawing
* By default, the drawing page gives four colour options. Select the desired colour on the left.
* The big 'Fullscreen' button puts the canvas in fullscreen. To exit, use the escape key.
* The drawing will be recorded from the moment the pen touches the canvas.
* When you stop drawing (ie. close the tab or browser) you can recover the drawing by going to 'Drawings' and select 'Draw' at the drawing you want to continue.
* **NOTE** Please note that when continuing a drawing, the intermediate time _is not captured_. Ie. the last stroke of the first drawing, will be immediately followed by the first stroke from the second drawing.
* A pen display such as a Wacom or Huion is recommended but not at all required.
### Annotating
* Before annotating, make sure to configure your initial tags on the tags page (`URL/index`)
* [optional] Add audio
* add an audio recording of the conversation to `files/audio`. Note that a wav file can be quite slow as everything goes through a browser. So possibly convert wav to mp3 or ogg.
* Open the drawing in the interface URL/annotate.html. Hover the title on the top left, and select the audio file from the pull down.
* Use the _Offset_ parameter to align the audio to the drawing (number in seconds).
* **NOTE** The diagram starts recoding on the first stroke. Often the audio file starts before the drawing. This requires a _negative_ offset.
* The title of the drawing can be set and changed by clicking on it.
* To annotate, select an in and out point using the bar below the drawing.
* Alternatively, in and out point can be set to the playhead `i` and `o` keys.
* When the in or out point is selected, it can be moved wit the arrow keys, or page up/down for larger increments.
* To precisely enter a timecode, click the time label of the point to type it.
* Then click the desired tag from the overview on the left.
* Creating an annotation, immediately deselects it, and sets the selected area to the time after annotation.
* Click an annotation to select it
* Use the textbox at the bottom to add a comment. This will be visible in the tags index page.
* After selecting an annotation, click it again to deselect. Alternatively, use the `esc` key.
* Deselecting an annotation sets the selected area to the time after it.
![The annotation interface.](docs/Figure1.png)
### Tags
With the tags interface you can manage a nested set of tags and manage the annotated segments. In our workflow, we had annotated the interviews, but tweaked the terms to create interesting subsets of drawings.
* To create a nested tag, select a tag, and an 'add tag' option will appear.
* Double-click the title to rename.
* Use the bucket icon to remove a tag.
* Click the coloured square to tweak the colour.
* In the overview of excerpts, use the checkboxes to change the tags in batch.
* Clicking an excerpt plays it back.
### Export as video
The tag index contains a download button which creates a zip file with all the required files for an embeddable player.
There is however no integrated a way to export the excerpts to a video file. The easiest way to do so however is to make a screen capture of a part of your display.
E.g. on Linux: set the pulse default input device to the monitor of the speakers. Then use wf-recorder: `wf-recorder -g"$(slurp)" -a -f recording.mp4`
### Notes
The `parse_offsets.py` script can be used to pad the diagram in order to sync it with the audio. This is necessary eg. after a network failure. It works by adding a line with the required offset to the `.json_appendable`-file. It dumps the output to the terminal. So use it with a redirect. E.g. `python parse_offsets.py -i faulty_file.json_appendable > new_file.json_appendable`.
## License
This code is made available under the MIT license. For details see [LICENSE](./LICENSE).
## Credits
This software uses (and for ease of use, redistributes) the [noUiSlider](https://refreshless.com/nouislider/) and [wnumb](https://refreshless.com/wnumb/) javascript libraries, developed by [Léon Gersen](https://refreshless.com/). Also using the MIT License.
## Citation
If you use this work in your research, please cite it using:
van de Ven, Ruben, and Ildikó Zonga Plájás. 2022. Inconsistent Projections: Con-Figuring Security Vision through Diagramming. _A Peer-Reviewed Journal About_ 11(1): 5065. https://doi.org/10.7146/aprja.v11i1.134306
```bash
poetry run python webserver.py
```

View file

@ -1,68 +0,0 @@
"""
Some recordings start with the wrong window size, probably because they were started on the laptop screen.
This script modifies the opening dimensions and all subsequent viewbox elements to adhere to the new size.
"""
import json
import logging
import argparse
import sys
# TODO the whole script (still a copy)
logger = logging.getLogger("svganim.fixer")
argParser = argparse.ArgumentParser(
description="""Helper tool to fix the window size when the recording started with a smaller screen that full screen.
Beware, when used with a shell stdin redirect, do not point it towards the input file. That file will be truncated before reading!
"""
)
argParser.add_argument(
"--input", type=str, help="Filename to run on (.json_appendable)"
)
argParser.add_argument(
"--width", type=int, help="New width in px"
)
argParser.add_argument(
"--height", type=int, help="New height in px"
)
args = argParser.parse_args()
with open(args.input, "r") as fp:
events = json.loads("[" + fp.read() + "]")
extrabox = None
for i, event in enumerate(events):
if type(event) is list:
# if event[1] != args.width or event[2] != args.height:
# # correct default position
# # extrabox = {"event": "viewbox", "viewboxes": [{
# # 't': 0,
# # 'x': (event[1] - args.width)/2,
# # 'y': (event[2] - args.height)/2,
# # 'width': args.width,
# # 'height': args.height,
# # }], 'original_size': [event[1], event[2]]}
event[1] = args.width
event[2] = args.height
elif event["event"] == "viewbox":
for key, box in enumerate(event['viewboxes']):
# {"t": 1088912, "x": 0, "y": 0, "width": 2048, "height": 1152},
box['x'] += (box['width'] - args.width)/2
box['y'] += (box['height'] - args.height)/2
box['width'] = args.width
box['height'] = args.height
event['viewboxes'][key] = box
# elif event["event"] == "stroke":
# event['points'] = [[p[0], p[1], p[2], p[3] + time_offset]
# for p in event['points']]
if i>0:
sys.stdout.write(",\n")
sys.stdout.write(json.dumps(event))
if extrabox:
sys.stdout.write(",\n")
sys.stdout.write(json.dumps(extrabox))
extrabox = None

View file

@ -1,45 +0,0 @@
import json
import logging
import argparse
import sys
logger = logging.getLogger("svganim.fixer")
argParser = argparse.ArgumentParser(
description="Helper tool to fix timings after glitch"
)
argParser.add_argument(
"--input", type=str, help="Filename to run on (.json_appendable)"
)
args = argParser.parse_args()
with open(args.input, "r") as fp:
events = json.loads("[" + fp.read() + "]")
time_offset = 0
for i, event in enumerate(events):
if type(event) is list:
pass
elif event['event'] == "offset":
time_offset += event['offset']
# print a reverseable event that is ignored by this script
event = {
'event': 'added_offset',
'offset': event['offset']
}
elif event['event'] == 'added_offset':
logger.warning('ignore existing offset')
continue
elif event["event"] == "viewbox":
for key, box in enumerate(event['viewboxes']):
event['viewboxes'][key]['t'] += time_offset
elif event["event"] == "stroke":
event['points'] = [[p[0], p[1], p[2], p[3] + time_offset]
for p in event['points']]
if i>0:
sys.stdout.write(",\n")
sys.stdout.write(json.dumps(event))
if time_offset == 0:
logger.warning('\n\nNo offset defined in file, please do so by adding {"event":"offset", "offset": miliseconds} at the line from which the offset should be added.')

View file

@ -1,786 +0,0 @@
from __future__ import annotations
import asyncio
import copy
from ctypes.wintypes import tagMSG
import json
from os import X_OK, PathLike
import os
import random
import string
import subprocess
from typing import Optional, Union
import shelve
from pydub import AudioSegment
import svgwrite
import tempfile
import io
import logging
from anytree import NodeMixin, RenderTree, iterators
from anytree.exporter import JsonExporter, DictExporter
from anytree.importer import JsonImporter, DictImporter
logger = logging.getLogger('svganim.strokes')
Milliseconds = float
Seconds = float
class Annotation:
def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds, comment: str = None) -> None:
self.tag = tag
self.t_in = t_in
self.t_out = t_out
self.drawing = drawing
self.comment = comment
@property
def id(self) -> str:
return f'{self.drawing.id}:{self.tag}:{self.t_in}:{self.t_out}'
def getAnimationSlice(self) -> AnimationSlice:
return self.drawing.get_animation().getSlice(self.t_in, self.t_out)
def get_as_svg(self) -> str:
return self.getAnimationSlice().get_as_svg()
def getJsonUrl(self) -> str:
return self.drawing.get_url() + f"?t_in={self.t_in}&t_out={self.t_out}"
Filename = Union[str, bytes, PathLike[str], PathLike[bytes]]
SliceId = [str, float, float]
class Drawing:
def __init__(self, filename: Filename, metadata_dir: Filename, basedir: Filename) -> None:
self.eventfile = filename
self.id = os.path.splitext(os.path.basename(self.eventfile))[0]
self.metadata_fn = os.path.join(metadata_dir, f"{self.id}.json")
self.basedir = basedir
def get_url(self) -> str:
return f"/files/{self.id}"
def get_annotations_url(self) -> str:
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()
if first_line.endswith(","):
first_line = first_line[:-1]
data = json.loads(first_line)
return {
"date": data[0],
"dimensions": {
"width": data[1],
"height": data[2],
},
}
def get_audio(self) -> Optional[AudioSlice]:
md = self.get_metadata()
if 'audio' not in md:
return None
if 'file' not in md['audio']:
return None
return AudioSlice(filename=os.path.join(self.basedir, md['audio']['file'][1:]), drawing=self, offset=md['audio']['offset']*1000)
def get_animation(self) -> AnimationSlice:
# with open(self.eventfile, "r") as fp:
strokes = []
viewboxes = []
with open(self.eventfile, "r") as fp:
events = json.loads("[" + fp.read() + "]")
for i, event in enumerate(events):
if i == 0:
# metadata on first line, add as initial viewbox to slice
viewboxes.append(TimedViewbox(-float('Infinity'), 0, 0, 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":
viewboxes.extend([TimedViewbox(
b['t'], b['x'], b['y'], b['width'], b['height']) for b in event['viewboxes']])
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])])
strokes.append(
Stroke(
event["color"],
[Point.fromTuple(tuple(p))
for p in event["points"]],
)
)
return AnimationSlice(self, [self.id, None, None], strokes, viewboxes, audioslice=self.get_audio())
def get_metadata(self):
canvas = self.get_canvas_metadata()
if os.path.exists(self.metadata_fn):
with open(self.metadata_fn, "r") as fp:
metadata = json.load(fp)
else:
metadata = {}
metadata["canvas"] = canvas
return metadata
def get_absolute_viewbox(self) -> Viewbox:
return self.get_animation().get_bounding_box()
class Viewbox:
def __init__(self, x: float, y: float, width: float, height: float):
self.x = x
self.y = y
self.width = width
self.height = height
def __str__(self) -> str:
return f"{self.x} {self.y} {self.width} {self.height}"
class TimedViewbox(Viewbox):
def __init__(self, time: Milliseconds, x: float, y: float, width: float, height: float):
super().__init__(x, y, width, height)
self.t = time
FrameIndex = tuple[int, int]
class AnimationSlice:
# either a whole drawing or the result of applying an annotation to a drawing (an excerpt)
# TODO rename to AnimationSlice to include audio as well
def __init__(
self, drawing: Drawing, slice_id: SliceId, strokes: list[Stroke], viewboxes: list[TimedViewbox] = [], t_in: float = 0, t_out: float = None, audioslice: AudioSlice = None
) -> None:
self.drawing = drawing
self.id = slice_id
self.strokes = strokes
self.viewboxes = viewboxes
self.t_in = t_in
self.t_out = t_out
self.audio = audioslice
def asDict(self, include_full_drawing=False) -> dict:
"""Can be used to json-ify the animation-slice
"""
# conversion necessary for when no t_in is given
boxes = [v.__dict__ for v in self.viewboxes]
for box in boxes:
if box['t'] == -float('Infinity'):
box['t'] = 0
drawing = {
"file": self.getUrl(),
"time": "-", # creation date
# dimensions of drawing canvas
"dimensions": [self.viewboxes[0].width, self.viewboxes[0].height],
"shape": [s.asDict() for s in self.strokes],
"viewboxes": boxes,
"bounding_box": self.get_bounding_box().__dict__,
"audio": self.getAudioDict() if self.audio else None,
}
if include_full_drawing:
drawing["background"] = [s.get_as_d() for s in self.drawing.get_animation().strokes]
drawing["background_bounding_box"] = self.drawing.get_animation().get_bounding_box().__dict__
return drawing
def getAudioDict(self):
"""quick and dirty to not use audio.asDict(), but it avoids passing all around sorts of data"""
return {
"file": '/files/' + self.getUrl('.mp3'),
"offset": 0
# "offset": self.audio.offset / 1000
}
def getUrl(self, extension = '') -> str:
if not self.id[1] and not self.id[2]:
return self.id[0]
return self.id[0] + f"{extension}?t_in={self.t_in}&t_out={self.t_out}"
def getHash(self):
'''
A repeatable way to create a relatively unique hash for the slice
Used e.g. to export the slice.
'''
random.seed('-'.join([str(b) if b is not None else "" for b in self.id]))
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
def get_bounding_box(self, stroke_thickness: float = 3.5) -> Viewbox:
"""Stroke_thickness 3.5 == 1mm. If it should not be considered, just set it to 0.
"""
if len(self.strokes) == 0:
# empty set
return Viewbox(0,0,0,0)
min_x, max_x = float("inf"), float("-inf")
min_y, max_y = float("inf"), float("-inf")
for s in self.strokes:
for p in s.points:
x1 = p.x - stroke_thickness/2
x2 = p.x + stroke_thickness/2
y1 = p.y - stroke_thickness/2
y2 = p.y + stroke_thickness/2
if x1 < min_x:
min_x = x1
if x2 > max_x:
max_x = x2
if y1 < min_y:
min_y = y1
if y2 > max_y:
max_y = y2
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
def getSlice(self, t_in: Milliseconds, t_out: Milliseconds) -> AnimationSlice:
"""slice the slice. T in ms"""
frame_in = self.getIndexForInPoint(t_in)
frame_out = self.getIndexForOutPoint(t_out)
strokes = self.getStrokeSlices(frame_in, frame_out, t_in)
# TODO shift t of points with t_in
viewboxes = self.getViewboxesSlice(t_in, t_out)
audio = self.audio.getSlice(t_in, t_out) if self.audio else None
return AnimationSlice(self.drawing, [self.id[0], t_in, t_out], strokes, viewboxes, t_in, t_out, audio)
def get_as_svg_dwg(self) -> svgwrite.Drawing:
box = self.get_bounding_box()
(_, fn) = tempfile.mkstemp(suffix='.svg', text=True)
dwg = svgwrite.Drawing(fn, size=(box.width, box.height))
dwg.viewbox(box.x, box.y, box.width, box.height)
self.add_to_dwg(dwg)
dwg.defs.add(
dwg.style("path{stroke-width:1mm;stroke-linecap: round;}"))
return dwg
def get_as_svg(self) -> str:
dwg = self.get_as_svg_dwg()
fp = io.StringIO()
dwg.write(fp, pretty=True)
return fp.getvalue()
def add_to_dwg(self, dwg: SvgDrawing):
group = svgwrite.container.Group()
for stroke in self.strokes:
stroke.add_to_dwg(group)
dwg.add(group)
def getViewboxesSlice(self, t_in: Milliseconds, t_out: Milliseconds) -> list[TimedViewbox]:
"""Extract the viewboxes for in- and outpoints.
If there's one before inpoint, move that to the t_in, so that animation starts at the right position
the slice is offset by t_in ms
"""
viewboxes = [] # Add single empty element, so that we can use viewboxes[0] later
lastbox = None
for viewbox in self.viewboxes:
if viewbox.t > t_out:
break
if viewbox.t <= t_in:
# make sure the first box is the last box from _before_ the slice
firstbox = TimedViewbox(
0, viewbox.x, viewbox.y, viewbox.width, viewbox.height)
if not len(viewboxes):
viewboxes.append(firstbox)
else:
viewboxes[0] = firstbox
continue
viewboxes.append(TimedViewbox(viewbox.t-t_in, viewbox.x, viewbox.y, viewbox.width, viewbox.height))
return viewboxes
def getStrokeSlices(
self, index_in: FrameIndex, index_out: FrameIndex, t_offset: Seconds = 0
) -> list[Stroke]:
"""Get list of Stroke/StrokeSlice based in in and out indexes
Based on annotation.js getStrokesSliceForPathRange(in_point, out_point)
If either in point or out point is [None, None], return an empty set.
"""
slices = []
if index_in[0] is None and index_in[1] is None:
# If no inpoint is set, in_point is after the last stroke
return slices
if index_out[0] is None and index_out[1] is None:
# If no out point is set, out_point is before the last stroke
return slices
for i in range(index_in[0], index_out[0] + 1):
try:
stroke = self.strokes[i]
except IndexError:
# out point can be Infinity. So interrupt whenever the end is reached
break
in_i = index_in[1] if index_in[0] == i else 0
out_i = index_out[1] if index_out[0] == i else len(
stroke.points) - 1
slices.append(StrokeSlice(stroke, in_i, out_i, t_offset))
return slices
def getIndexForInPoint(self, ms: Milliseconds) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
The In point version (so the first index after ms)
Equal to annotations.js findPositionForTime(ms)
"""
path_i = None
point_i = None
for i, stroke in enumerate(self.strokes):
start_at = stroke.points[0].t
end_at = stroke.points[-1].t
if end_at < ms:
# certainly not the right point yet
continue
if start_at > ms:
path_i = i
point_i = 0
break # too far, so this is the first point after in point
else:
# our in-point is inbetween first and last of the stroke
# we are getting close, find the right point_i
path_i = i
for pi, point in enumerate(stroke.points):
point_i = pi
if point.t > ms:
break # stop when finding the next point after in point
break # done :-)
if path_i is None or point_i is None:
logger.warn("in point after last stroke. Not sure if this works")
pass
return (path_i, point_i)
def getIndexForOutPoint(self, ms: Milliseconds) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
The Out point version (so the last index before ms)
Equal to annotations.js findPositionForTime(ms)
"""
return self.getIndexForTime(ms)
def getIndexForTime(self, ms: Milliseconds) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
Equal to annotations.js findPositionForTime(ms)
"""
path_i = None
point_i = None
for i, stroke in enumerate(self.strokes):
start_at = stroke.points[0].t
end_at = stroke.points[-1].t
if start_at > ms:
break # too far
if end_at > ms:
# we are getting close, find the right point_i
path_i = i
for pi, point in enumerate(stroke.points):
if point.t > ms:
break # too far
point_i = pi
break # done :-)
else:
# in case this is our last path, stroe this as
# best option thus far
path_i = i
point_i = len(stroke.points) - 1
if path_i is None or point_i is None:
logger.warn("OUT point after last stroke. Not sure if this works")
pass
return (path_i, point_i)
audiocache = {}
class AudioSlice:
def __init__(self, filename: Filename, drawing: Drawing, t_in: Milliseconds = None, t_out: Milliseconds = None, offset: Milliseconds = None):
self.filename = filename
self.drawing = drawing
self.t_in = t_in # in ms
self.t_out = t_out # in ms
self.offset = offset # in ms TODO: use from self.drawing metadata
def getSlice(self, t_in: float, t_out: float) -> AudioSlice:
return AudioSlice(self.filename, self.drawing, t_in, t_out, self.offset)
def asDict(self):
return {
"file": self.getUrl(),
# "offset": self.offset/1000
}
def getUrl(self):
fn = self.filename.replace("../files/audio", "/file/")
params = []
if self.t_in:
params.append(f"t_in={self.t_in}")
if self.t_out:
params.append(f"t_out={self.t_in}")
if len(params):
fn += "?" + "&".join(params)
return fn
async def export(self, format="mp3"):
"""Returns file descriptor of tempfile"""
# Opening file and extracting segment
start = int(self.t_in - self.offset) # millisecond precision is enough
end = int(self.t_out - self.offset) # millisecond precision is enough
# call ffmpeg directly, with given in and outpoint, so no unnecessary data is loaded, and no double conversion (e.g. ogg -> wav -> ogg ) is performed
out_f = io.BytesIO()
# build converter command to export
conversion_command = [
"ffmpeg",
'-ss', f"{start}ms",
'-to', f"{end}ms",
"-i", self.filename, # ss before input, so not whole file is loaded
]
conversion_command.extend([
"-f", format, '-', # to stdout
])
# read stdin / write stdout
logger.info("ffmpeg start")
proc = await asyncio.create_subprocess_exec(
*conversion_command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL)
p_out, p_err = await proc.communicate()
logger.info("ffmpeg finished")
if proc.returncode != 0:
raise Exception(
"Encoding failed. ffmpeg/avlib returned error code: {0}\n\nCommand:{1}".format(
p.returncode, conversion_command))
out_f.write(p_out)
out_f.seek(0)
return out_f
# old way, use AudioSegment, easy but slow (reads whole ogg to wav, then export segment to ogg again)
# logger.info("loading audio")
# if self.filename in audiocache:
# song = audiocache[self.filename]
# else:
# song = AudioSegment.from_file(self.filename)
# audiocache[self.filename] = song
# logger.info("loaded audio")
# if start < 0 and end < 0:
# extract = AudioSegment.silent(
# duration=end-start, frame_rate=song.frame_rate)
# else:
# if start < 0:
# preroll = AudioSegment.silent(
# duration=start * -1, frame_rate=song.frame_rate)
# start = 0
# else:
# preroll = None
# if end > len(song):
# postroll = AudioSegment.silent(
# duration=end - len(song), frame_rate=song.frame_rate)
# end = len(song) - 1
# else:
# postroll = None
# extract = song[start: end]
# if preroll:
# extract = preroll + extract
# if postroll:
# extract += postroll
# # Saving
# return extract.export(None, format=format)
class AnnotationIndex:
def __init__(
self, filename: Filename, drawing_dir: Filename, metadata_dir: Filename
) -> None:
self.filename = filename
self.drawing_dir = drawing_dir
self.metadata_dir = metadata_dir
self.root_tag = getRootTag()
# disable disk cache because of glitches shelve.open(filename, writeback=True)
self.shelve = {}
def refresh(self):
logger.info("refreshing")
# reset the index
for key in list(self.shelve.keys()):
print(key)
del self.shelve[key]
self.shelve["_drawings"] = {
d.id: d
for d in [
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames()
]
}
self.root_tag = getRootTag()
self.shelve['_tags'] = {tag.id: [] for tag in self.root_tag.descendants}
self.shelve['_annotations'] = {}
drawing: Drawing
for drawing in self.shelve['_drawings'].values():
meta = drawing.get_metadata()
if 'annotations' not in meta:
continue
for ann in meta['annotations']:
annotation = Annotation(
ann['tag'], drawing, ann['t_in'], ann['t_out'], ann['comment'] if 'comment' in ann else "")
self.shelve['_annotations'][annotation.id] = annotation
if annotation.tag not in self.shelve['_tags']:
self.shelve['_tags'][annotation.tag] = [annotation]
logger.error(f"Use of non-existing tag {annotation.tag}")
else:
self.shelve['_tags'][annotation.tag].append(
annotation
)
tag = self.root_tag.find_by_id(annotation.tag)
if tag is not None:
tag.annotation_count += 1
@property
def drawings(self) -> dict[str, Drawing]:
return self.shelve["_drawings"]
@property
def tags(self) -> dict[str, list[Annotation]]:
return self.shelve["_tags"]
@property
def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"]
def has_tag(self, tag):
return tag in self.tags
def get_annotations_for_tag(self, tag_id) -> list[Annotation]:
if tag_id not in self.tags:
return []
return self.tags[tag_id]
def get_drawing_names(self) -> list[str]:
return [
name[:-16]
for name in os.listdir(self.drawing_dir)
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]:
return [
os.path.join(self.drawing_dir, f"{name}.json_appendable")
for name in self.get_drawing_names()
]
def get_nested_annotations_for_tag(self, tag_id) -> list[Annotation]:
tag = self.root_tag.find_by_id(tag_id)
annotations = []
for tag in tag.descendants_incl_self():
annotations.extend(self.get_annotations_for_tag(tag.id))
return annotations
def __del__(self):
self.shelve.close()
# Point = tuple[float, float, float]
class Point:
def __init__(self, x: float, y: float, last: bool, t: Seconds):
self.x = float(x)
self.y = float(y) # if y == 0 it can still be integer.... odd python
self.last = last
self.t = t
@classmethod
def fromTuple(cls, p: tuple[float, float, int, float]):
return cls(p[0], p[1], bool(p[2]), p[3])
def scaledToFit(self, dimensions: dict[str, float]) -> Point:
# TODO: change so that it actually scales to FIT dimensions
return Point(self.x, self.y, self.last, self.t)
def asList(self) -> list:
return [self.x, self.y, 1 if self.last else 0, self.t]
Points = list[Point]
SvgDrawing = Union[svgwrite.container.SVG, svgwrite.container.Group]
class Stroke:
def __init__(self, color: str, points: Points) -> None:
self.color = color
self.points = points
def asDict(self) -> dict:
return {"color": self.color, "points": [p.asList() for p in self.points]}
def add_to_dwg(self, dwg: SvgDrawing):
path = svgwrite.path.Path(d=self.get_as_d()).stroke(
self.color, 1).fill("none")
dwg.add(path)
# def get_bounding_box(self) -> Viewbox:
# min_x, max_x = float("inf"), float("-inf")
# min_y, max_y = float("inf"), float("-inf")
# for p in self.points:
# if p.x < min_x:
# min_x = p.x
# if p.x > max_x:
# max_x = p.x
# if p.y < min_y:
# min_y = p.y
# if p.y > max_y:
# max_y = p.y
# return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
def get_as_d(self):
d = ""
prev_point = None
cmd = ""
for point in self.points:
if not prev_point:
# TODO multiply points by scalars for dimensions (height widht of drawing)
d += f'M{point.x:.6},{point.y:.6} '
cmd = 'M'
else:
if prev_point.last:
d += " m"
cmd = "m"
elif cmd != 'l':
d += ' l '
cmd = 'l'
diff_point = {
"x": point.x - prev_point.x,
"y": point.y - prev_point.y,
}
# TODO multiply points by scalars for dimensions (height widht of drawing)
d += f'{diff_point["x"]:.6},{diff_point["y"]:.6} '
prev_point = point
return d
class StrokeSlice(Stroke):
def __init__(self, stroke: Stroke, i_in: int = None, i_out: int = None, t_offset: Seconds = 0) -> None:
self.stroke = stroke
self.i_in = 0 if i_in is None else i_in
self.i_out = len(self.stroke.points) - 1 if i_out is None else i_out
# deepcopy points, because slices can be offset in time
self.points = copy.deepcopy(self.stroke.points[self.i_in: self.i_out + 1])
for p in self.points:
p.t -= t_offset
def slice_id(self):
return f"{self.i_in}-{self.i_out}"
# @property
# def points(self) -> Points:
# return self.stroke.points[self.i_in: self.i_out + 1]
@property
def color(self) -> str:
return self.stroke.color
def strokes2D(strokes):
# strokes to a d attribute for a path
d = ""
last_stroke = None
cmd = ""
for stroke in strokes:
if not last_stroke:
d += f"M{stroke[0]},{stroke[1]} "
cmd = 'M'
else:
if last_stroke[2] == 1:
d += " m"
cmd = 'm'
elif cmd != 'l':
d += ' l '
cmd = 'l'
rel_stroke = [stroke[0] - last_stroke[0],
stroke[1] - last_stroke[1]]
d += f"{rel_stroke[0]},{rel_stroke[1]} "
last_stroke = stroke
return d
class Tag(NodeMixin):
def __init__(self, id, name = None, description = "", color = None, parent=None, children=None, annotation_count=None):
self.id = id
self.name = self.id if name is None else name
self.color = color
self.description = description
self.parent = parent
if children:
self.children = children
self.annotation_count = 0 #always zero!
if self.id == 'root' and not self.is_root:
logger.error("Root node shouldn't have a parent assigned")
def __repr__(self):
return f"<svganim.strokes.Tag {self.id}>"
def __str__(self):
return RenderTree(self).by_attr('name')
def get_color(self):
if self.color is None and self.parent is not None:
return self.parent.get_color()
return self.color
def descendants_incl_self(self):
return tuple(iterators.PreOrderIter(self))
def find_by_id(self, tag_id) -> Optional[Tag]:
for t in self.descendants:
if t.id == tag_id:
return t
return None
def toJson(self, with_counts=False) -> str:
ignore_counts=lambda attrs: [(k, v) for k, v in attrs if k != "annotation_count"]
attrFilter = None if with_counts else ignore_counts
return JsonExporter(DictExporter(attriter=attrFilter), indent=2).export(self)
def loadTagFromJson(string) -> Tag:
tree: Tag = JsonImporter(DictImporter(Tag)).import_(string)
return tree
def getRootTag(file = 'www/tags.json') -> Tag:
with open(file, 'r') as fp:
tree: Tag = JsonImporter(DictImporter(Tag)).read(fp)
return tree
# print(RenderTree(tree))

View file

@ -1,8 +0,0 @@
from hashlib import md5
def annotation_hash(handler=None, input =""):
return md5(input.encode()).hexdigest()
# def nmbr(handler, lst) -> int:
# leno

View file

@ -1,201 +0,0 @@
<html>
<head>
<title>Annotations</title>
<link rel="stylesheet" href="svganim.css">
<style>
body {
background: rgb(39, 40, 41);
font-family: sans-serif;
color: white
}
#tags {
font-size: 80%;
padding: 0;
margin: 0;
}
#tags li {
line-height: 1.5;
list-style: none;
}
#tags .tag-id-root {
list-style: none;
;
}
#tags .tag-id-root>ul {
margin: 0;
padding: 0;
}
#tags li>div,
#tags li.add-tag {
cursor: pointer;
}
#tags .tag-id-root>div {
display: none;
}
/* #tags li:hover>ul>li.add-tag {
visibility: visible;
;
} */
#tags li.selected>ul>li.add-tag {
display: block;
}
#tags>li>ul>li.add-tag {
display: block;
;
}
#annotations .annotations-actions {
margin-bottom: 10px;
color: darkgray;
font-size: 80%;
border-bottom: solid 3px #444;
position: sticky;
top: 0;
background: rgb(39, 40, 41);
padding: 3px 0;
}
#tags .add-tag {
display: none;
;
color: lightgray;
font-size: 80%;
}
#tags input[type="color"] {
cursor: pointer;
width: 15px;
height: 15px;
padding: 0;
border: solid 1px black;
border-radius: 2px;
vertical-align: middle;
margin-right: 10px;
}
#tags li:hover>div>input.rm-tag {
display: inline-block;
}
#tags li div {
position: relative
}
#tags input.rm-tag:hover {
color: red;
transform: rotate(20deg);
}
#tags input.rm-tag {
/* display: none; */
position: absolute;
right: 0;
top: 0;
padding: 0;
background: none;
border: none;
color: white;
cursor: pointer;
}
#tags .selected>div {
background: lightblue
}
summary h2 {
display: inline-block;
cursor: pointer;
}
details[open] summary {
color: rgb(224, 196, 196);
}
/* details ul{
display: none;
}
details[open] ul{
display: block;;
} */
#annotation_manager {
display: grid;
gap: 20px;
grid-template-columns: 200px auto;
}
#tags {
grid-column: 1;
}
#annotations {
grid-column: 2;
}
#annotations ul {
grid-template-columns: repeat(auto-fill, 320px);
grid-gap: 20px;
display: grid;
list-style: none;
margin: 0;
padding: 0;
}
#annotations li {}
#annotations img {
/* width: 400px; */
background: white;
width: 300px;
height: 200px;
cursor: pointer;
padding: 10px;
}
#annotations .svganim_player,
#annotations annotation-player {
display: inline-block;
position: relative;
width: 300px;
height: 200px;
}
</style>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
<script src="annotations.js"></script>
</head>
<body>
<div id="annotation_manager">
<ul id="tags"></ul>
<div id="annotations"></div>
</div>
<hr>
<a href="?refresh=1">Reload index</a>
</body>
<script>
const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
</script>
</html>

View file

@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Annotate a line animation</title>
<link rel="stylesheet" href="svganim.css">
<style media="screen">
body {
/* background: black;
color: white */
background: lightgray;
}
body.player{
background: rgb(39, 40, 41);;
}
#sample,
svg {
position: absolute;
top: 20px;
left: 20px;
width: calc(100% - 40px);
height: calc(100% - 200px);
font-family: sans-serif;
z-index: 2;
/* background: white; */
/* border: solid 2px lightgray; */
}
body.player svg{
height: calc(100% - 40px);
background-color: white;
}
#wrapper {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: none;
}
img {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.playlist img {
position: static;
width: 250px;
height: 250px;
background: white;
display: block;
}
.playlist .title{
display: block;
font-weight: bold;;
}
.help {
position: absolute;
right: 0;
top: 10px;
left: 220px;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
font-size: 6pt;
z-index: 20;
}
body:not(.annotator) .help{
display: none;
}
.help li {
display: inline-block;
color: gray;
margin-right: 10px;
flex-grow: 1;
}
.help .key {
padding: 2px;
background-color: aliceblue;
border: solid 1px black;
color: black;
border-radius: 4px;
}
#interface:not(.selected-annotation) .help .esc1 {
display: none;
}
#interface.selected-annotation .help .esc2 {
display: none;
}
</style>
<link rel="stylesheet" href="assets/nouislider-15.5.0.css">
<link rel="stylesheet" href="core.css">
</head>
<body>
<div id='interface'>
<ul class="help">
<li><span class='key'>Space</span> play/pause</li>
<li><span class='key'>Shift</span> + <span class='key'>&RightArrow;</span> Skip 1s</li>
<li><span class='key'>Shift</span> + <span class='key'>Ctrl</span> + <span class='key'>&RightArrow;</span>
Skip 10s</li>
<li><span class='key'>i / o</span> set in/out-point</li>
<li><span class='key'>Shift</span> + <span class='key'>i / o</span> Jump to in/out-point</li>
<li><span class='key'>&LeftArrow; / &RightArrow;</span> Shift selected point 1s</li>
<li><span class='key'>PgUp/Dwn</span> Shift selected point 10s</li>
<li class="esc"><span class='key'>Esc</span> <span class='esc1'>Deselect annotation</span><span
class="esc2">reset in & out-points</span></li>
</ul>
</div>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
<script src="playlist.js"></script>
<script type='text/javascript'>
let ann;
if (location.search) {
const params = new URLSearchParams(location.search);
const is_player = !!parseInt(params.get('player'));
const crop_to_fit = !!parseInt(params.get('crop'));
if(is_player) {
document.body.classList.add('player');
} else {
document.body.classList.add('annotator');
}
ann = new Annotator(
document.getElementById("interface"),
"tags.json",
params.get('file'),
{is_player: is_player, crop_to_fit: crop_to_fit}
);
} else {
const playlist = new Playlist(document.getElementById("interface"), '/files/');
}
// Hack to disable hardware media keys starting/stopping the audio playback
navigator.mediaSession.setActionHandler('play', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('pause', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('seekbackward', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('seekforward', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('previoustrack', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('nexttrack', function () { /* Code excerpted. */ });
navigator.mediaSession.setActionHandler('playpause', function () { /* Code excerpted. */ });
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,421 +0,0 @@
class AnnotationManager {
constructor(rootEl, tagsUrl) {
this.rootEl = rootEl;
this.tagsEl = this.rootEl.querySelector('#tags');
this.annotationsEl = this.rootEl.querySelector('#annotations');
this.selectedAnnotations = [];
this.selectedTag = null;
this.tagsUrl = tagsUrl;
this.loadTags();
}
loadTags() {
// tags config
const request = new Request(this.tagsUrl);
return fetch(request)
.then(response => response.json())
.then(rootTag => {
this.rootTag = Tag.from(rootTag);
this.buildTagList()
});
}
buildTagList() {
// build, and rebuild
this.tagsEl.innerHTML = "";
const addTag = (tag, parentEl) => {
const tagLiEl = document.createElement('li');
const tagSubEl = document.createElement('ul');
const tagEl = document.createElement('div');
tagEl.innerText = `${tag.get_name()} (${tag.annotation_count ?? 0})`;
tagEl.addEventListener('click', (ev) => {
this.selectTag(tag);
})
tagEl.addEventListener('dblclick', (ev) => {
this.renameTag(tag);
})
const colorEl = document.createElement('input');
colorEl.type = 'color';
colorEl.value = tag.get_color();
colorEl.addEventListener('change', (ev) => {
this.setTagColor(tag, ev.target.value);
});
tagEl.prepend(colorEl)
const rmEl = document.createElement('input');
rmEl.type = 'button';
rmEl.classList.add('rm-tag');
rmEl.value = '🗑';
rmEl.addEventListener('click', (ev) => {
ev.stopPropagation();
this.removeTag(tag);
});
tagEl.appendChild(rmEl)
tagLiEl.classList.add('tag-id-' + tag.id);
tagLiEl.appendChild(tagEl);
tag.menuLiEl = tagLiEl;
tagLiEl.appendChild(tagSubEl);
tag.children.forEach((tag) => addTag(tag, tagSubEl));
const tagAddSubEl = document.createElement('li');
tagAddSubEl.classList.add('add-tag')
tagAddSubEl.innerText = 'add tag';
tagAddSubEl.addEventListener('click', (ev) => {
const name = prompt(`Add a tag under '${tag.get_name()}':`);
if (name === null || name.length < 1) return //cancel
this.addTag(name, tag);
});
tagSubEl.appendChild(tagAddSubEl);
parentEl.appendChild(tagLiEl);
};
addTag(this.rootTag, this.tagsEl);
}
selectTag(tag) {
this.clearSelectedTag();
this.selectedTag = tag;
tag.menuLiEl.classList.add('selected');
this.loadAnnotationsForTag(tag)
}
clearSelectedTag() {
this.selectedTag = null;
const selected = this.tagsEl.querySelectorAll('.selected');
selected.forEach((s) => s.classList.remove('selected'));
this.resetSelectedAnnotations()
//TODO empty annotationEl
this.annotationsEl.innerHTML = "";
}
selectAnnotation() {
throw new Error("Not implemented");
}
addTag(name, parentTag) {
const tag = new Tag();
tag.id = crypto.randomUUID();
tag.name = name;
tag.parent = parentTag;
parentTag.children.push(tag);
this.buildTagList();
this.saveTags();
}
loadAnnotationsForTag(tag) {
this.annotationsEl.innerHTML = "";
const request = new Request("/tags/" + tag.id);
return fetch(request)
.then(response => response.json())
.then(annotations => {
this.annotations = annotations;
this.buildAnnotationList()
});
}
buildAnnotationList() {
this.annotationsEl.innerHTML = "";
this.actionsEl = document.createElement('div');
this.actionsEl.classList.add('annotations-actions')
this.buildAnnotationActions();
this.annotationsEl.appendChild(this.actionsEl);
const ulEl = document.createElement('ul');
this.annotations.forEach((annotation, idx) => {
const liEl = document.createElement('li');
const playerEl = new AnnotationPlayer(); //document.createElement('annotation-player');
playerEl.setAnnotation(annotation);
const selectEl = document.createElement('input');
selectEl.type = 'checkbox';
selectEl.id = 'select-' + annotation.id_hash;
selectEl.addEventListener('change', (ev) => {
if (ev.target.checked) {
this.addAnnotationToSelection(annotation);
} else {
this.removeAnnotationFromSelection(annotation);
}
})
const tag = this.rootTag.find_by_id(annotation.tag);
console.log(tag)
const infoEl = document.createElement('span');
infoEl.classList.add('annotation-info');
infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`;
const downloadEl = document.createElement('a');
downloadEl.href = annotation.url.replace('files', 'export');
downloadEl.innerHTML = '&darr;';
infoEl.appendChild(downloadEl);
liEl.appendChild(playerEl);
liEl.appendChild(selectEl);
liEl.appendChild(infoEl);
ulEl.appendChild(liEl);
});
this.annotationsEl.appendChild(ulEl);
}
resetSelectedAnnotations() {
this.selectedAnnotations = [];
this.annotationsEl.querySelectorAll("li input[type='checkbox']").forEach((box) => box.checked = false);
this.buildAnnotationActions()
}
addAnnotationToSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) === -1) {
this.selectedAnnotations.push(annotation);
}
this.annotationsEl.querySelector('#select-' + annotation.id_hash).checked = true;
this.buildAnnotationActions()
}
removeAnnotationFromSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) !== -1) {
this.selectedAnnotations.splice(this.selectedAnnotations.indexOf(annotation), 1)
}
this.annotationsEl.querySelector('#select-' + annotation.id_hash).checked = false;
this.buildAnnotationActions()
}
/**
* Build the form items to select & move the annotations
* @returns undefined
*/
buildAnnotationActions() {
if (!this.actionsEl || !this.annotations.length) return
this.actionsEl.innerHTML = "";
const selectAllLabelEl = document.createElement('label');
selectAllLabelEl.innerText = `Select all`
const selectAllEl = document.createElement('input');
selectAllEl.type = 'checkbox';
selectAllEl.checked = this.annotations.length === this.selectedAnnotations.length;
// selectAllEl.innerText = `Select all ${this.annotations.length} items`;
selectAllEl.addEventListener('change', (ev) => {
if (ev.target.checked) {
this.annotations.forEach((a) => this.addAnnotationToSelection(a));
} else {
this.resetSelectedAnnotations();
}
});
selectAllLabelEl.appendChild(selectAllEl)
this.actionsEl.appendChild(selectAllLabelEl)
if (!this.selectedAnnotations.length) return;
const moveLabelEl = document.createElement('label');
moveLabelEl.innerText = `Change tag for ${this.selectedAnnotations.length} items`
const moveSelectEl = document.createElement('select');
this.rootTag.descendants().forEach((tag, i) => {
const tagEl = document.createElement('option');
tagEl.value = tag.id;
if (tag.id == this.selectedTag.id) {
tagEl.selected = true;
}
tagEl.innerHTML = tag.get_indented_name();
moveSelectEl.appendChild(tagEl);
});
moveSelectEl.addEventListener('change', (ev) => {
const tag = this.rootTag.find_by_id(ev.target.value);
console.log(tag);
this.moveSelectedAnnotations(tag);
})
moveLabelEl.appendChild(moveSelectEl)
this.actionsEl.appendChild(moveLabelEl)
}
renameTag(tag) {
// TODO: add button for this
const name = prompt(`Rename tag '${tag.get_name()}':`);
if (name === null || name.length < 1) return //cancel
tag.name = name;
this.saveTags();
this.buildTagList();
}
removeTag(tag) {
if (!confirm(`Do you want to delete ${tag.get_name()}`)) {
return false;
}
// TODO: add button for this
const request = new Request("/tags/" + tag.id);
return fetch(request)
.then(response => {
if (response.status == 404) {
return [] // not existing tag surely has no annotations
} else {
return response.json()
}
})
.then(annotations => {
if (annotations.length) {
alert(`Cannot remove '${tag.get_name()}', as it is used for ${annotations.length} annotations.`)
} else {
// TODO: remove tag
tag.parent.children.splice(tag.parent.children.indexOf(tag), 1);
tag.parent = null;
this.saveTags();
this.buildTagList();
}
})
}
setTagColor(tag, color) {
tag.color = color;
this.buildTagList();
this.saveTags();
}
async moveSelectedAnnotations(tag) {
// TODO: add button for this
// alert(`This doesn't work yet! (move to tag ${tag.get_name()})`)
await Promise.all(this.selectedAnnotations.map(async (annotation) => {
const formData = new FormData();
formData.append('tag_id', tag.id)
return await fetch('/annotation/' + annotation.id, {
method: 'POST',
// headers: {
// 'Content-Type': 'application/json'
// },
body: formData //JSON.stringify({'tag_id': tag.id})
}).catch((e) => alert('Something went wrong saving the tags'));
}));
this.loadAnnotationsForTag(this.selectedTag)
this.loadTags() //updates the counts
}
async saveTags() {
const json = this.rootTag.export();
console.log('save', json)
const response = await fetch('/tags.json', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: json
}).catch((e) => alert('Something went wrong saving the tags'));
}
}
class Tag {
parent = null;
children = [];
static from(json_obj) {
if (json_obj.hasOwnProperty('children')) {
json_obj.children = json_obj.children.map(Tag.from)
} else {
json_obj.children = [];
}
json_obj.parent = null;
const tag = Object.assign(new Tag(), json_obj);
tag.children.map((child) => child.parent = tag);
return tag;
}
is_root() {
return this.parent === null;
}
descendants(inc_self = false) {
let tags = this.children.flatMap((t) => t.descendants(true))
if (inc_self)
tags.unshift(this)
return tags;
}
find_by_id(tag_id) {
const desc = this.descendants().filter((tag) => tag.id == tag_id);
if (desc.length) return desc[0];
return null;
}
depth() {
if (this.parent === null) {
return 0;
}
return this.parent.depth() + 1;
}
root() {
if (this.parent !== null) {
return this.parent.root();
}
return this;
}
get_name() {
if (this.hasOwnProperty('name') && this.name !== null) {
return this.name;
}
return this.id;
}
get_indented_name() {
const name = this.get_name();
return '&nbsp;'.repeat((this.depth() - 1) * 2) + '- ' + name
}
get_color() {
if (this.hasOwnProperty('color') && this.color !== null) {
return this.color;
}
if (this.parent !== null) {
return this.parent.get_color()
}
return 'black';
}
_json_replacer(key, value) {
if (key === 'children' && !value.length) {
return undefined;
}
if (key === 'parent' || key === 'menuLiEl') {
return undefined;
}
if (key === 'color' && this.parent !== null && this.parent.get_color() === value) {
return undefined;
}
return value;
}
export() {
return JSON.stringify(this, this._json_replacer)
}
}

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drawing & Annotating Path Animations</title>
<style>
body{
font-family: sans-serif;
font-size: 20px;
}
a{
line-height: 2;
text-decoration: none;
}
a:hover{
text-decoration: underline;
}
</style>
</head>
<body>
<h1>Diagrams</h1>
<ul>
<li><a href="/index">Tags</a></li>
<li><a href="/annotate.html">Drawings</a></li>
<li><a href="/draw.html">Draw</a></li>
</ul>
</body>
</html>

View file

@ -1,119 +0,0 @@
class Playlist {
constructor(wrapperEl, url) {
this.wrapperEl = wrapperEl;
this.onlyWithTitle = true;
const request = new Request(url, {
method: 'GET',
});
fetch(request)
.then(response => response.json())
.then(data => {
this.files = data;
this.buildList()
});
}
buildList() {
let playlist = this.wrapperEl.querySelector('.playlist');
if (!playlist) {
playlist = document.createElement('nav');
playlist.classList.add('playlist');
this.wrapperEl.appendChild(playlist)
}
else {
playlist.innerHTML = "";
}
const filterEl = document.createElement('label');
filterEl.classList.add('filter');
filterEl.innerText = "Show only diagrams with title"
const filterCheckEl = document.createElement('input');
filterCheckEl.type = "checkbox";
filterCheckEl.checked = this.onlyWithTitle;
filterCheckEl.addEventListener('click', (ev) => {
this.onlyWithTitle = ev.target.checked;
this.buildList()
})
filterEl.appendChild(filterCheckEl)
playlist.appendChild(filterEl)
const listEl = document.createElement("ul");
for (let file of this.files) {
if(this.onlyWithTitle && file.title === null) {
continue;
}
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);
if (file.title) {
const titleEl = document.createElement("span");
titleEl.classList.add('title');
titleEl.innerText = file.title;
liEl.append(titleEl);
}
let time = file.mtime;
if (file.ctime != file.mtime) {
time += ` (orig: ${file.ctime})`;
}
const dateEl = document.createElement("span");
dateEl.classList.add('date');
dateEl.innerText = time;
liEl.append(dateEl);
const nameEl = document.createElement("span");
nameEl.classList.add('name');
nameEl.innerText = file.name;
liEl.append(nameEl);
const linksEl = document.createElement("span");
linksEl.classList.add('links');
liEl.append(linksEl);
const playEl = document.createElement("a");
playEl.classList.add('play');
playEl.innerText = "Play";
playEl.href = location;
playEl.pathname = "annotate.html";
playEl.search = "?file=" + file.name + "&player=1";
linksEl.append(playEl);
const annotateEl = document.createElement("a");
annotateEl.classList.add('annotate');
annotateEl.innerText = "Annotate";
annotateEl.href = location;
annotateEl.pathname = "annotate.html";
annotateEl.search = "?file=" + 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);
// playlist.style.display = "none";
// });
listEl.appendChild(liEl);
}
playlist.appendChild(listEl);
// do something with the data sent in the request
}
}

View file

@ -1,336 +0,0 @@
svg .background {
fill: white
}
path {
fill: none;
stroke: gray;
stroke-width: 1mm;
stroke-linecap: round;
}
g.before path {
opacity: 0.5;
stroke: gray !important;
}
g.after path,
path.before_in {
opacity: .1;
stroke: gray !important;
}
.gray {
position: absolute;
background: rgba(255, 255, 255, 0.7);
}
.controls--playback {
/* display:flex; */
position: relative;
}
.timecode {
position: absolute;
right: 100%;
width: 5%;
font-size: 8px;
}
.controls--playback input[type='range'] {
/* position: absolute;
z-index: 100;
bottom: 0;
left: 0;
right: 0; */
width: 100%;
}
.controls button.paused,
.controls button.playing {
position: absolute;
left: 100%;
width: 30px;
height: 30px;
}
.controls button.paused::before {
content: '⏵';
}
.controls button.playing::before {
content: '⏸';
}
.loading .controls button:is(.playing, .paused)::before {
content: '↺';
display: inline-block;
animation: rotate 1s infinite;
}
@keyframes rotate {
0% {
transform: rotate(359deg)
}
100% {
transform: rotate(0deg)
}
}
.controls {
position: absolute !important;
z-index: 100;
bottom: 10px;
left: 5%;
right: 0;
width: 90%;
}
.scrubber {}
.tags {
line-height: 1.5;
/* display: flex;
flex-direction: row; */
padding: 0;
margin: 0;
}
.tags .tag {
display: block;
padding: 5px;
border: solid 1px darkgray;
flex-grow: 1;
text-align: center;
}
.tags li {
display: block;
}
.tags .subtags {
padding: 0;
font-size: 80%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.tags .subtags .tag {
padding: 2px;
}
.tags .tag:hover {
cursor: pointer;
background: darkgray;
}
.tags .tag.selected {
background: #3FB8AF;
}
.tags .tag.annotation-rm {
/* display: none; */
overflow: hidden;
color: red;
font-size: 30px;
width: 0;
flex-grow: 0;
padding: 5px 0;
transition: width .3s;
pointer-events: none;
border: none;
direction: rtl;
/* hide behind bar, instead into nothing */
}
.selected-annotation .tags .tag.annotation-rm {
color: red;
display: block;
width: 30px;
pointer-events: all;
}
.controls .annotation-comment {
width: 100%;
visibility: hidden;
}
.selected-annotation .controls .annotation-comment {
visibility: visible;
}
.noUi-handle:focus {
/* background: red;; */
border: solid 2px #601be0;
}
/* .noUi-handle:focus::before, .noUi-handle:focus::after{
background: #601be0;
} */
.tags .tag span {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 10px;
vertical-align: middle;
border-radius: 5px;
}
.tags .subtags .tag span {
width: 10px;
height: 10px;
margin-right: 2px;
}
.annotations {
height: 30px;
/* border: solid 1px darkgray; */
position: relative;
}
.annotations>div {
opacity: .4;
background: lightseagreen;
position: absolute;
bottom: 0;
top: 0;
}
.annotations>div:hover,
.annotations>div.selected {
opacity: 1;
cursor: pointer;
}
.unsaved::before {
content: '*';
color: red;
display: inline-block;
text-align: center;
font-size: 30px;
position: absolute;
top: 10px;
left: 10px;
}
.saved::before {
content: '\2713';
display: inline-block;
color: green;
text-align: center;
font-size: 30px;
position: absolute;
top: 10px;
left: 10px;
}
.noUi-horizontal .noUi-touch-area {
cursor: ew-resize;
}
#interface .noUi-horizontal .noUi-tooltip {
/* tooltips go outside of the buttons */
bottom: auto;
left: 110%;
transform: none;
top: -2px;
}
#interface .noUi-horizontal .noUi-handle-lower .noUi-tooltip {
left: auto;
right: 110%;
}
.metadataconfig {
z-index: 9;
background: black;
color: white;
position: relative;
/* width: 100px; */
/* as wide as audio controls only */
overflow:visible;
/* white-space: nowrap; */
/* left: -50px; */
}
.metadataconfig .drawing-title{
font-weight: bold;
padding: 10px;
cursor: pointer;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.metadataconfig .audioconfig{
display: none;;
background-color: black;;
}
.metadataconfig:hover .audioconfig{
/* width: auto;
left: 0px;
overflow: visible;
height: 200px; */
display: block;
}
.audioconfig select,
.audioconfig input {
margin: 10px;
}
audio {
vertical-align: middle;
width: 100px;
/* hides seek head */
}
.svganim_annotator {
display: grid;
flex-direction: column;
height: 100%;
grid-template-rows: 40px 1fr 100px;
grid-template-columns: 200px auto;
gap: 10px;
overflow: hidden;
}
.svganim_annotator svg {
top: auto;
background: white;
margin-left: 0;
position: static;
height: auto;
width: auto;
}
.svganim_annotator .tags {
grid-area: 2/1/4/1;
overflow-y: auto;
overflow-x: hidden;
}
.svganim_annotator .audioconfig {
position: static;
grid-area: 1/1/1/1;
}
.svganim_annotator > svg {
grid-area: 1/2/3/2;
width: 100%;
height: 100%;
}
.svganim_annotator .controls {
grid-area: 3/2/3/2;
margin-left: 5%;
position: static !important;
}

View file

@ -1,321 +0,0 @@
{
"id": "root",
"name": "root",
"color": null,
"description": "",
"annotation_count": 0,
"children": [
{
"id": "human-machine",
"name": "Human/machine Entanglements (Appearance/disappearance)",
"color": "#ffa348",
"description": "",
"annotation_count": 7,
"children": [
{
"id": "vision",
"name": "Vision",
"color": null,
"description": "",
"annotation_count": 3
},
{
"id": "sound",
"name": "sound",
"color": null,
"description": "",
"annotation_count": 0
},
{
"id": "behaviour",
"name": "behaviour",
"color": null,
"description": "",
"annotation_count": 1
},
{
"id": "other-senses",
"name": "Other senses",
"color": null,
"description": "",
"annotation_count": 2
}
]
},
{
"id": "tensions",
"name": "Tensions, contestations & problems",
"color": "#77767b",
"description": "Which problems are identified?, when do they become problems?",
"annotation_count": 28
},
{
"id": "security",
"name": "Security & types of data",
"color": "#3584e4",
"description": "",
"annotation_count": 14,
"children": [
{
"id": "definitions",
"name": "definitions",
"color": null,
"description": "e.g. domain knowledge",
"annotation_count": 13
},
{
"id": "input",
"name": "input",
"color": null,
"description": "e.g. fake data",
"annotation_count": 1
}
]
},
{
"id": "actants",
"name": "Actants in relation",
"color": "#fa08ff",
"description": "",
"annotation_count": 15,
"children": [
{
"id": "algorithm",
"name": "algorithm",
"color": null,
"description": "",
"annotation_count": 11
},
{
"id": "technologies",
"name": "technologies",
"color": null,
"description": "",
"annotation_count": 7
},
{
"id": "frt",
"name": "frt",
"color": null,
"description": "",
"annotation_count": 7
},
{
"id": "cameras",
"name": "CCTV & camera's",
"color": null,
"description": "",
"annotation_count": 10
},
{
"id": "entities",
"name": "Entities: people, institutions etc.",
"color": null,
"description": "",
"annotation_count": 15
},
{
"id": "positioning",
"name": "Positioning",
"color": null,
"description": "the positioning of a field/person/oneself in relation to others",
"annotation_count": 12
},
{
"id": "inside-outside",
"name": "inside-outside",
"color": null,
"description": "",
"annotation_count": 0
},
{
"id": "public-private",
"name": "public-private",
"color": null,
"description": "",
"annotation_count": 5
}
]
},
{
"id": "consequences",
"name": "consequences",
"color": "#0add32",
"description": "",
"annotation_count": 5,
"children": [
{
"id": "effects",
"name": "effects",
"color": null,
"description": "",
"annotation_count": 8
},
{
"id": "future-imaginaries",
"name": "future-imaginaries",
"color": null,
"description": "",
"annotation_count": 14
},
{
"id": "speculations",
"name": "speculations",
"color": null,
"description": "what is & what will/can be done.",
"annotation_count": 7
},
{
"id": "innovations",
"name": "innovations",
"color": null,
"description": "",
"annotation_count": 4
}
]
},
{
"id": "hesitation",
"name": "Hesitations & corrections",
"color": "#f8e45c",
"description": "",
"annotation_count": 24
},
{
"id": "skip",
"name": "skip",
"color": null,
"description": "",
"annotation_count": 18
},
{
"id": "todo",
"name": "to do / interesting",
"color": "#ff0000",
"description": "",
"annotation_count": 9
},
{
"id": "6f9a83a1-e374-4dc9-a6c4-39c5461bb435",
"name": "Threat & Risk - What is being protected by SV",
"color": "#813d9c",
"description": "",
"annotation_count": 22
},
{
"id": "3fda0504-00ff-485e-9faf-ca7d08e4e96c",
"name": "Errors & Glitches (having it wrong)",
"color": "#986a44",
"description": "",
"annotation_count": 11,
"children": [
{
"id": "0f758538-ea27-424f-bc0a-d6a68837832d",
"name": "FPR",
"color": null,
"description": "",
"annotation_count": 8
},
{
"id": "6f10e893-2fcf-4c72-9480-04cad5612cc2",
"name": "Consequences of an error",
"color": null,
"description": "",
"annotation_count": 4
}
]
},
{
"id": "63bd867b-d7d4-4247-b6a6-9157ab24cff8",
"name": "Sites of SV",
"color": "#99c1f1",
"description": "",
"annotation_count": 22,
"children": [
{
"id": "0fd34742-7582-42ec-96a5-ee83ad57fbb2",
"name": "Public space",
"color": null,
"description": "",
"annotation_count": 6
}
]
},
{
"id": "bd8ab328-85e7-44d2-bfee-2ad951587116",
"name": "Temporality",
"color": "#ffff00",
"description": "",
"annotation_count": 9,
"children": [
{
"id": "a20fb815-db9a-4704-8ac1-92a5f8a5497b",
"name": "Static images",
"color": null,
"description": "",
"annotation_count": 0
},
{
"id": "ac8e247a-1ed2-452c-af6f-2b345706e3eb",
"name": "Technicalities of time",
"color": null,
"description": "",
"annotation_count": 5
}
]
},
{
"id": "39f4a6fe-9c15-4417-bce7-8be9ef791bf2",
"name": "Humans & Bodies",
"color": "#dc8add",
"description": "",
"annotation_count": 13,
"children": [
{
"id": "b38c9a6a-837f-4301-9f82-69d9c22438c9",
"name": "Absences of bodies",
"color": null,
"description": "",
"annotation_count": 3
},
{
"id": "89a1924c-ec49-460e-9b79-789237b23429",
"name": "Human-in-the-loop",
"color": null,
"description": "",
"annotation_count": 14
},
{
"id": "e906230f-332e-4bd4-8939-936baa05acc5",
"name": "The human as a crowd",
"color": null,
"description": "",
"annotation_count": 1
}
]
},
{
"id": "0a37911b-a458-491c-9145-534121d5fc92",
"name": "Solutions",
"color": "#f6f5f4",
"description": "",
"annotation_count": 9,
"children": [
{
"id": "dce213cb-1fc6-4a20-9cb2-82862119fa5d",
"name": "Juridical/Law",
"color": null,
"description": "",
"annotation_count": 7
}
]
},
{
"id": "cfcef8bd-219e-4650-bb1c-c3d3b40ce6c1",
"name": "Incentives for SV",
"color": "#a51d2d",
"description": "",
"annotation_count": 5
}
]
}

View file

@ -1,26 +0,0 @@
<VirtualHost *:80>
# This is the HTTP example, ideally use Certbot/LetsEncrypt to encrypt the data transferred
Servername diagramming.WEBSITE
<Location />
# Optionally use some basic auth to prevent access to anyone.
AuthType Basic
Authname "Password Required"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
</Location>
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://localhost:7890/$1 [P,L]
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
RewriteRule /(.*) http://localhost:7890/$1 [P,L]
ProxyPass / http://localhost:7890/
ProxyPassReverse / http://localhost:7890/
ProxyPreserveHost On
</VirtualHost>

View file

@ -1,13 +0,0 @@
version: '3'
services:
svganim:
image: svganim
restart: always
build:
context: .
dockerfile: ./Dockerfile
# entrypoint: "/bin/sh -c \"poetry run python webserver.py --storage /files\""
ports:
- "127.0.0.1:7890:7890"
volumes:
- ./files:/files

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

405
poetry.lock generated
View file

@ -1,405 +0,0 @@
[[package]]
name = "anytree"
version = "2.8.0"
description = "Powerful and Lightweight Python Tree Data Structure.."
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
six = ">=1.9.0"
[package.extras]
dev = ["check-manifest"]
test = ["coverage"]
[[package]]
name = "cairocffi"
version = "1.3.0"
description = "cffi-based cairo bindings for Python"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
cffi = ">=1.1.0"
[package.extras]
doc = ["sphinx", "sphinx-rtd-theme"]
test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"]
xcb = ["xcffib (>=0.3.2)"]
[[package]]
name = "cairosvg"
version = "2.5.2"
description = "A Simple SVG Converter based on Cairo"
category = "main"
optional = false
python-versions = ">=3.5"
[package.dependencies]
cairocffi = "*"
cssselect2 = "*"
defusedxml = "*"
pillow = "*"
tinycss2 = "*"
[package.extras]
doc = ["sphinx", "sphinx-rtd-theme"]
test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"]
[[package]]
name = "cffi"
version = "1.15.0"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "coloredlogs"
version = "15.0.1"
description = "Colored terminal output for Python's logging module"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
humanfriendly = ">=9.1"
[package.extras]
cron = ["capturer (>=2.4)"]
[[package]]
name = "cssselect2"
version = "0.6.0"
description = "CSS selectors for Python ElementTree"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tinycss2 = "*"
webencodings = "*"
[package.extras]
doc = ["sphinx", "sphinx-rtd-theme"]
test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"]
[[package]]
name = "defusedxml"
version = "0.7.1"
description = "XML bomb protection for Python stdlib modules"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "filelock"
version = "3.7.1"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
[[package]]
name = "humanfriendly"
version = "10.0"
description = "Human friendly output for text interfaces using Python"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
[[package]]
name = "pillow"
version = "9.1.1"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pydub"
version = "0.25.1"
description = "Manipulate audio with an simple and easy high level interface"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "pyreadline3"
version = "3.4.1"
description = "A python implementation of GNU readline."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "svgwrite"
version = "1.4.2"
description = "A Python library to create SVG drawings."
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "tinycss2"
version = "1.1.1"
description = "A tiny CSS parser"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
webencodings = ">=0.4"
[package.extras]
doc = ["sphinx", "sphinx-rtd-theme"]
test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"]
[[package]]
name = "tornado"
version = "6.1"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
category = "dev"
optional = false
python-versions = ">= 3.5"
[[package]]
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
category = "main"
optional = false
python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "2d0f6605799313075026037a1a147a31ffcb2098bf686d59b85f0d1ca5108ad2"
[metadata.files]
anytree = [
{file = "anytree-2.8.0-py2.py3-none-any.whl", hash = "sha256:14c55ac77492b11532395049a03b773d14c7e30b22aa012e337b1e983de31521"},
{file = "anytree-2.8.0.tar.gz", hash = "sha256:3f0f93f355a91bc3e6245319bf4c1d50e3416cc7a35cc1133c1ff38306bbccab"},
]
cairocffi = [
{file = "cairocffi-1.3.0.tar.gz", hash = "sha256:108a3a7cb09e203bdd8501d9baad91d786d204561bd71e9364e8b34897c47b91"},
]
cairosvg = [
{file = "CairoSVG-2.5.2-py3-none-any.whl", hash = "sha256:98c276b7e4f0caf01e5c7176765c104ffa1aa1461d63b2053b04ab663cf7052b"},
{file = "CairoSVG-2.5.2.tar.gz", hash = "sha256:b0b9929cf5dba005178d746a8036fcf0025550f498ca54db61873322384783bc"},
]
cffi = [
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
{file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
{file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
{file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
{file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
{file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
{file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
{file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
{file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
{file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
{file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
{file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
{file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
{file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
{file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
{file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
{file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
{file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
{file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
{file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
]
coloredlogs = [
{file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
{file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
]
cssselect2 = [
{file = "cssselect2-0.6.0-py3-none-any.whl", hash = "sha256:3a83b2a68370c69c9cd3fcb88bbfaebe9d22edeef2c22d1ff3e1ed9c7fa45ed8"},
{file = "cssselect2-0.6.0.tar.gz", hash = "sha256:5b5d6dea81a5eb0c9ca39f116c8578dd413778060c94c1f51196371618909325"},
]
defusedxml = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
filelock = [
{file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
{file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
]
humanfriendly = [
{file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
]
pillow = [
{file = "Pillow-9.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:42dfefbef90eb67c10c45a73a9bc1599d4dac920f7dfcbf4ec6b80cb620757fe"},
{file = "Pillow-9.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffde4c6fabb52891d81606411cbfaf77756e3b561b566efd270b3ed3791fde4e"},
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c857532c719fb30fafabd2371ce9b7031812ff3889d75273827633bca0c4602"},
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59789a7d06c742e9d13b883d5e3569188c16acb02eeed2510fd3bfdbc1bd1530"},
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d45dbe4b21a9679c3e8b3f7f4f42a45a7d3ddff8a4a16109dff0e1da30a35b2"},
{file = "Pillow-9.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e9ed59d1b6ee837f4515b9584f3d26cf0388b742a11ecdae0d9237a94505d03a"},
{file = "Pillow-9.1.1-cp310-cp310-win32.whl", hash = "sha256:b3fe2ff1e1715d4475d7e2c3e8dabd7c025f4410f79513b4ff2de3d51ce0fa9c"},
{file = "Pillow-9.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5b650dbbc0969a4e226d98a0b440c2f07a850896aed9266b6fedc0f7e7834108"},
{file = "Pillow-9.1.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:0b4d5ad2cd3a1f0d1df882d926b37dbb2ab6c823ae21d041b46910c8f8cd844b"},
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9370d6744d379f2de5d7fa95cdbd3a4d92f0b0ef29609b4b1687f16bc197063d"},
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b761727ed7d593e49671d1827044b942dd2f4caae6e51bab144d4accf8244a84"},
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a66fe50386162df2da701b3722781cbe90ce043e7d53c1fd6bd801bca6b48d4"},
{file = "Pillow-9.1.1-cp37-cp37m-win32.whl", hash = "sha256:2b291cab8a888658d72b575a03e340509b6b050b62db1f5539dd5cd18fd50578"},
{file = "Pillow-9.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1d4331aeb12f6b3791911a6da82de72257a99ad99726ed6b63f481c0184b6fb9"},
{file = "Pillow-9.1.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8844217cdf66eabe39567118f229e275f0727e9195635a15e0e4b9227458daaf"},
{file = "Pillow-9.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b6617221ff08fbd3b7a811950b5c3f9367f6e941b86259843eab77c8e3d2b56b"},
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20d514c989fa28e73a5adbddd7a171afa5824710d0ab06d4e1234195d2a2e546"},
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:088df396b047477dd1bbc7de6e22f58400dae2f21310d9e2ec2933b2ef7dfa4f"},
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53c27bd452e0f1bc4bfed07ceb235663a1df7c74df08e37fd6b03eb89454946a"},
{file = "Pillow-9.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3f6c1716c473ebd1649663bf3b42702d0d53e27af8b64642be0dd3598c761fb1"},
{file = "Pillow-9.1.1-cp38-cp38-win32.whl", hash = "sha256:c67db410508b9de9c4694c57ed754b65a460e4812126e87f5052ecf23a011a54"},
{file = "Pillow-9.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:f054b020c4d7e9786ae0404278ea318768eb123403b18453e28e47cdb7a0a4bf"},
{file = "Pillow-9.1.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c17770a62a71718a74b7548098a74cd6880be16bcfff5f937f900ead90ca8e92"},
{file = "Pillow-9.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3f6a6034140e9e17e9abc175fc7a266a6e63652028e157750bd98e804a8ed9a"},
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f372d0f08eff1475ef426344efe42493f71f377ec52237bf153c5713de987251"},
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09e67ef6e430f90caa093528bd758b0616f8165e57ed8d8ce014ae32df6a831d"},
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66daa16952d5bf0c9d5389c5e9df562922a59bd16d77e2a276e575d32e38afd1"},
{file = "Pillow-9.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d78ca526a559fb84faaaf84da2dd4addef5edb109db8b81677c0bb1aad342601"},
{file = "Pillow-9.1.1-cp39-cp39-win32.whl", hash = "sha256:55e74faf8359ddda43fee01bffbc5bd99d96ea508d8a08c527099e84eb708f45"},
{file = "Pillow-9.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c150dbbb4a94ea4825d1e5f2c5501af7141ea95825fadd7829f9b11c97aaf6c"},
{file = "Pillow-9.1.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:769a7f131a2f43752455cc72f9f7a093c3ff3856bf976c5fb53a59d0ccc704f6"},
{file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:488f3383cf5159907d48d32957ac6f9ea85ccdcc296c14eca1a4e396ecc32098"},
{file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b525a356680022b0af53385944026d3486fc8c013638cf9900eb87c866afb4c"},
{file = "Pillow-9.1.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6e760cf01259a1c0a50f3c845f9cad1af30577fd8b670339b1659c6d0e7a41dd"},
{file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4165205a13b16a29e1ac57efeee6be2dfd5b5408122d59ef2145bc3239fa340"},
{file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937a54e5694684f74dcbf6e24cc453bfc5b33940216ddd8f4cd8f0f79167f765"},
{file = "Pillow-9.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:baf3be0b9446a4083cc0c5bb9f9c964034be5374b5bc09757be89f5d2fa247b8"},
{file = "Pillow-9.1.1.tar.gz", hash = "sha256:7502539939b53d7565f3d11d87c78e7ec900d3c72945d4ee0e2f250d598309a0"},
]
pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
pydub = [
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
]
pyreadline3 = [
{file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
{file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
svgwrite = [
{file = "svgwrite-1.4.2-py3-none-any.whl", hash = "sha256:ca63d76396d1f6f099a2b2d8cf1419e1c1de8deece9a2b7f4da0632067d71d43"},
{file = "svgwrite-1.4.2.zip", hash = "sha256:d304a929f197d31647c287c10eee0f64378058e1c83a9df83433a5980864e59f"},
]
tinycss2 = [
{file = "tinycss2-1.1.1-py3-none-any.whl", hash = "sha256:fe794ceaadfe3cf3e686b22155d0da5780dd0e273471a51846d0a02bc204fec8"},
{file = "tinycss2-1.1.1.tar.gz", hash = "sha256:b2e44dd8883c360c35dd0d1b5aad0b610e5156c2cb3b33434634e539ead9d8bf"},
]
tornado = [
{file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"},
{file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"},
{file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"},
{file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"},
{file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"},
{file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"},
{file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"},
{file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"},
{file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"},
{file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"},
{file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"},
{file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"},
{file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"},
{file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"},
{file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"},
{file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"},
{file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"},
{file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"},
{file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"},
{file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"},
{file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"},
{file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"},
{file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"},
{file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"},
{file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"},
{file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"},
{file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"},
{file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"},
{file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"},
{file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"},
{file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"},
{file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"},
{file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"},
{file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"},
{file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"},
{file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"},
{file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"},
{file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"},
{file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"},
{file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"},
{file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
]
webencodings = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
]

View file

@ -10,9 +10,6 @@ tornado = "^6.1"
coloredlogs = "^15.0.1"
pydub = "^0.25.1"
svgwrite = "^1.4.1"
anytree = "^2.8.0"
filelock = "^3.7.1"
CairoSVG = "^2.5.2"
[tool.poetry.dev-dependencies]

505
svganim/strokes.py Normal file
View file

@ -0,0 +1,505 @@
from __future__ import annotations
import json
from os import X_OK, PathLike
import os
from typing import Optional, Union
import shelve
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:
self.tag = tag
self.t_in = t_in
self.t_out = t_out
self.drawing = drawing
@property
def id(self) -> str:
return f'{self.drawing.id}:{self.tag}:{self.t_in}:{self.t_out}'
def getAnimationSlice(self) -> AnimationSlice:
return self.drawing.get_animation().getSlice(self.t_in, self.t_out)
def get_as_svg(self) -> str:
return self.getAnimationSlice().get_as_svg()
Filename = Union[str, bytes, PathLike[str], PathLike[bytes]]
class Drawing:
def __init__(self, filename: Filename, metadata_dir: Filename, basedir: Filename) -> None:
self.eventfile = filename
self.id = os.path.splitext(os.path.basename(self.eventfile))[0]
self.metadata_fn = os.path.join(metadata_dir, f"{self.id}.json")
self.basedir = basedir
def get_url(self) -> str:
return f"/files/{self.id}"
def get_annotations_url(self) -> str:
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()
if first_line.endswith(","):
first_line = first_line[:-1]
data = json.loads(first_line)
return {
"date": data[0],
"dimensions": {
"width": data[1],
"height": data[2],
},
}
def get_audio(self) -> Optional[AudioSlice]:
md = self.get_metadata()
if 'audio' not in md:
return None
if 'file' not in md['audio']:
return None
return AudioSlice(filename=os.path.join(self.basedir, md['audio']['file'][1:]), offset=md['audio']['offset']*1000)
def get_animation(self) -> AnimationSlice:
# with open(self.eventfile, "r") as fp:
strokes = []
with open(self.eventfile, "r") as fp:
events = json.loads("[" + fp.read() + "]")
for i, event in enumerate(events):
if i == 0:
# 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":
# 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])])
strokes.append(
Stroke(
event["color"],
[Point.fromTuple(tuple(p))
for p in event["points"]],
)
)
return AnimationSlice(strokes, audioslice=self.get_audio())
def get_metadata(self):
canvas = self.get_canvas_metadata()
if os.path.exists(self.metadata_fn):
with open(self.metadata_fn, "r") as fp:
metadata = json.load(fp)
else:
metadata = {}
metadata["canvas"] = canvas
return metadata
def get_absolute_viewbox(self) -> Viewbox:
return self.get_animation().get_bounding_box()
class Viewbox:
def __init__(self, x: float, y: float, width: float, height: float):
self.x = x
self.y = y
self.width = width
self.height = height
def __str__(self) -> str:
return f"{self.x} {self.y} {self.width} {self.height}"
FrameIndex = tuple[int, int]
class AnimationSlice:
# either a whole drawing or the result of applying an annotation to a drawing (an excerpt)
# TODO rename to AnimationSlice to include audio as well
def __init__(
self, strokes: list[Stroke], t_in: float = 0, t_out: float = None, audioslice: AudioSlice = None
) -> None:
self.strokes = strokes
self.t_in = t_in
self.t_out = t_out
self.audio = audioslice
# TODO: Audio
def get_bounding_box(self) -> Viewbox:
min_x, max_x = float("inf"), float("-inf")
min_y, max_y = float("inf"), float("-inf")
for s in self.strokes:
for p in s.points:
if p.x < min_x:
min_x = p.x
if p.x > max_x:
max_x = p.x
if p.y < min_y:
min_y = p.y
if p.y > max_y:
max_y = p.y
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
def getSlice(self, t_in: float, t_out: float) -> AnimationSlice:
frame_in = self.getIndexForInPoint(t_in)
frame_out = self.getIndexForOutPoint(t_out)
strokes = self.getStrokeSlices(frame_in, frame_out)
audio = self.audio.getSlice(t_in, t_out) if self.audio else None
return AnimationSlice(strokes, t_in, t_out, audio)
def get_as_svg_dwg(self) -> svgwrite.Drawing:
box = self.get_bounding_box()
(_, fn) = tempfile.mkstemp(suffix='.svg', text=True)
dwg = svgwrite.Drawing(fn, size=(box.width, box.height))
dwg.viewbox(box.x, box.y, box.width, box.height)
self.add_to_dwg(dwg)
return dwg
def get_as_svg(self) -> str:
dwg = self.get_as_svg_dwg()
fp = io.StringIO()
dwg.write(fp, pretty=True)
return fp.getvalue()
def add_to_dwg(self, dwg: SvgDrawing):
group = svgwrite.container.Group()
for stroke in self.strokes:
stroke.add_to_dwg(group)
dwg.add(group)
def getStrokeSlices(
self, index_in: FrameIndex, index_out: FrameIndex
) -> list[Stroke]:
"""Get list of Stroke/StrokeSlice based in in and out indexes
Based on annotation.js getStrokesSliceForPathRange(in_point, out_point)
"""
slices = []
for i in range(index_in[0], index_out[0] + 1):
try:
stroke = self.strokes[i]
except IndexError:
# out point can be Infinity. So interrupt whenever the end is reached
break
in_i = index_in[1] if index_in[0] == i else 0
out_i = index_out[1] if index_out[0] == i else len(
stroke.points) - 1
slices.append(StrokeSlice(stroke, in_i, out_i))
return slices
def getIndexForInPoint(self, ms) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
The In point version (so the first index after ms)
Equal to annotations.js findPositionForTime(ms)
"""
path_i = 0
point_i = 0
for i, stroke in enumerate(self.strokes):
start_at = stroke.points[0].t
end_at = stroke.points[-1].t
if end_at < ms:
# certainly not the right point yet
continue
if start_at > ms:
path_i = i
point_i = 0
break # too far, so this is the first point after in point
else:
# our in-point is inbetween first and last of the stroke
# we are getting close, find the right point_i
path_i = i
for pi, point in enumerate(stroke.points):
point_i = pi
if point.t > ms:
break # stop when finding the next point after in point
break # done :-)
return (path_i, point_i)
def getIndexForOutPoint(self, ms) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
The Out point version (so the last index before ms)
Equal to annotations.js findPositionForTime(ms)
"""
return self.getIndexForTime( ms)
def getIndexForTime(self, ms) -> FrameIndex:
"""Get the frame index (path, point) based on the given time
Equal to annotations.js findPositionForTime(ms)
"""
path_i = 0
point_i = 0
for i, stroke in enumerate(self.strokes):
start_at = stroke.points[0].t
end_at = stroke.points[-1].t
if start_at > ms:
break # too far
if end_at > ms:
# we are getting close, find the right point_i
path_i = i
for pi, point in enumerate(stroke.points):
if point.t > ms:
break # too far
point_i = pi
break # done :-)
else:
# in case this is our last path, stroe this as
# best option thus far
path_i = i
point_i = len(stroke.points) - 1
return (path_i, point_i)
class AudioSlice:
def __init__(self, filename: Filename, t_in: float = None, t_out: float = None, offset: float = None):
self.filename = filename
self.t_in = t_in # in ms
self.t_out = t_out # in ms
self.offset = offset # in ms
def getSlice(self, t_in: float, t_out: float) -> AnimationSlice:
return AudioSlice(self.filename, t_in, t_out, self.offset)
def export(self, format="mp3"):
"""Returns file descriptor of tempfile"""
# Opening file and extracting segment
song = AudioSegment.from_file(self.filename)
start = self.t_in - self.offset
end = self.t_out - self.offset
if start < 0 and end < 0:
extract = AudioSegment.silent(
duration=end-start, frame_rate=song.frame_rate)
else:
if start < 0:
preroll = AudioSegment.silent(
duration=start * -1, frame_rate=song.frame_rate)
start = 0
else:
preroll = None
if end > len(song):
postroll = AudioSegment.silent(
duration=end - len(song), frame_rate=song.frame_rate)
end = len(song) - 1
else:
postroll = None
extract = song[start: end]
if preroll:
extract = preroll + extract
if postroll:
extract += postroll
# Saving
return extract.export(None, format=format)
class AnnotationIndex:
def __init__(
self, filename: Filename, drawing_dir: Filename, metadata_dir: Filename
) -> None:
self.filename = filename
self.drawing_dir = drawing_dir
self.metadata_dir = metadata_dir
# disable disk cache because of glitches shelve.open(filename, writeback=True)
self.shelve = {}
def refresh(self):
# reset the index
for key in list(self.shelve.keys()):
print(key)
del self.shelve[key]
self.shelve["_drawings"] = {
d.id: d
for d in [
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames()
]
}
self.shelve['_tags'] = {}
self.shelve['_annotations'] = {}
drawing: Drawing
for drawing in self.shelve['_drawings'].values():
meta = drawing.get_metadata()
if 'annotations' not in meta:
continue
for ann in meta['annotations']:
annotation = Annotation(
ann['tag'], drawing, ann['t_in'], ann['t_out'])
self.shelve['_annotations'][annotation.id] = annotation
if annotation.tag not in self.shelve['_tags']:
self.shelve['_tags'][annotation.tag] = [annotation]
else:
self.shelve['_tags'][annotation.tag].append(
annotation
)
@property
def drawings(self) -> dict[str, Drawing]:
return self.shelve["_drawings"]
@property
def tags(self) -> dict[str, list[Annotation]]:
return self.shelve["_tags"]
@property
def annotations(self) -> dict[str, Annotation]:
return self.shelve["_annotations"]
def get_annotations(self, tag) -> list[Annotation]:
if tag not in self.tags:
return []
return self.tags[tag]
def get_drawing_names(self) -> list[str]:
return [
name[:-16]
for name in os.listdir(self.drawing_dir)
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]:
return [
os.path.join(self.drawing_dir, f"{name}.json_appendable")
for name in self.get_drawing_names()
]
def __del__(self):
self.shelve.close()
# Point = tuple[float, float, float]
class Point:
def __init__(self, x: float, y: float, last: bool, t: float):
self.x = float(x)
self.y = float(y) # if y == 0 it can still be integer.... odd python
self.last = last
self.t = t
@classmethod
def fromTuple(cls, p: tuple[float, float, int, float]):
return cls(p[0], p[1], bool(p[2]), p[3])
def scaledToFit(self, dimensions: dict[str, float]) -> Point:
# TODO: change so that it actually scales to FIT dimensions
return Point(self.x, self.y, self.last, self.t)
Points = list[Point]
SvgDrawing = Union[svgwrite.container.SVG, svgwrite.container.Group]
class Stroke:
def __init__(self, color: str, points: Points) -> None:
self.color = color
self.points = points
def add_to_dwg(self, dwg: SvgDrawing):
path = svgwrite.path.Path(d=self.get_as_d()).stroke(
self.color, 1).fill("none")
dwg.add(path)
def get_bounding_box(self) -> Viewbox:
min_x, max_x = float("inf"), float("-inf")
min_y, max_y = float("inf"), float("-inf")
for p in self.points:
if p.x < min_x:
min_x = p.x
if p.x > max_x:
max_x = p.x
if p.y < min_y:
min_y = p.y
if p.y > max_y:
max_y = p.y
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
def get_as_d(self):
d = ""
prev_point = None
cmd = ""
for point in self.points:
if not prev_point:
# TODO multiply points by scalars for dimensions (height widht of drawing)
d += f'M{point.x:.6},{point.y:.6} '
cmd = 'M'
else:
if prev_point.last:
d += " m"
cmd = "m"
elif cmd != 'l':
d += ' l '
cmd = 'l'
diff_point = {
"x": point.x - prev_point.x,
"y": point.y - prev_point.y,
}
# TODO multiply points by scalars for dimensions (height widht of drawing)
d += f'{diff_point["x"]:.6},{diff_point["y"]:.6} '
prev_point = point
return d
class StrokeSlice(Stroke):
def __init__(self, stroke: Stroke, i_in: int = None, i_out: int = None) -> None:
self.stroke = stroke
self.i_in = 0 if i_in is None else i_in
self.i_out = len(self.stroke.points) - 1 if i_out is None else i_out
def slice_id(self):
return f"{self.i_in}-{self.i_out}"
@property
def points(self) -> Points:
return self.stroke.points[self.i_in: self.i_out + 1]
@property
def color(self) -> str:
return self.stroke.color
def strokes2D(strokes):
# strokes to a d attribute for a path
d = ""
last_stroke = None
cmd = ""
for stroke in strokes:
if not last_stroke:
d += f"M{stroke[0]},{stroke[1]} "
cmd = 'M'
else:
if last_stroke[2] == 1:
d += " m"
cmd = 'm'
elif cmd != 'l':
d += ' l '
cmd = 'l'
rel_stroke = [stroke[0] - last_stroke[0],
stroke[1] - last_stroke[1]]
d += f"{rel_stroke[0]},{rel_stroke[1]} "
last_stroke = stroke
return d

60
templates/index.html Normal file
View file

@ -0,0 +1,60 @@
<html>
<head>
<title>Annotations</title>
<style>
body {
background: darkgray;
}
ul {
margin: 0;
padding: 0;
}
li {
display: inline-block;
;
}
img {
/* width: 400px; */
background: white;
}
</style>
</head>
<body>
{% for tag in index.tags %}
<h2>{{tag}}</h2>
<ul>
{% for annotation in index.tags[tag] %}
<li><img src="/annotation/{{ annotation.id }}.svg" data-audio="/annotation/{{ annotation.id }}.mp3"></li>
{% end %}
</ul>
{% end %}
<!-- <ul>
{% for annotation in index.annotations %}
<li>{{ annotation }}</li>
{% end %}
</ul> -->
<hr>
<a href="?refresh">Reload index</a>
</body>
<script>
let images = document.querySelectorAll('[data-audio]');
for (const image of images) {
const audio = new Audio(image.dataset.audio);
console.log(image, audio);
image.addEventListener('mouseover', (e) => {
audio.play();
});
image.addEventListener('mouseout', (e) => {
audio.pause();
audio.currentTime = 0;
});
}
</script>
</html>

View file

@ -1,10 +1,6 @@
import json
import logging
import os
import shutil
import tempfile
from urllib.error import HTTPError
from zipfile import ZipFile
import tornado.ioloop
import tornado.web
import tornado.websocket
@ -15,10 +11,7 @@ import html
import argparse
import coloredlogs
import glob
import filelock
import svganim.strokes
import svganim.uimethods
import cairosvg
logger = logging.getLogger("svganim.webserver")
@ -83,7 +76,6 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
return
# write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
# TODO use jsonlines -- which is not so much different but (semi-)standardized
with open(
os.path.join(self.config.storage, self.filename +
".json_appendable"), "a"
@ -242,86 +234,15 @@ class AudioListingHandler(tornado.web.RequestHandler):
print(names)
self.write(json.dumps(names))
class ExportHandler(tornado.web.RequestHandler):
"""
Export a player to a zip file
"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
async def get(self, filename):
logger.info(f"file {filename=}")
if filename not in self.index.drawings:
raise tornado.web.HTTPError(404)
t_in = self.get_argument('t_in', None)
t_out = self.get_argument('t_out', None)
animation = self.index.drawings[filename].get_animation()
if t_in is not None and t_out is not None:
animation = animation.getSlice(float(t_in), float(t_out))
identifier = animation.getHash()
with tempfile.TemporaryDirectory() as tdir:
with ZipFile(tdir + f'/annotation-{identifier}.zip', 'w') as archive:
logger.info('write svg')
svgstring = animation.get_as_svg()
archive.writestr(f'annotation-{identifier}.svg', svgstring)
logger.info('write png')
archive.writestr(f'annotation-{identifier}.png', cairosvg.svg2png(bytestring=svgstring))
logger.info('write mp3')
audio = await animation.audio.export(format="mp3")
archive.writestr(f'annotation-{identifier}.mp3', audio.read())
logger.info('write json')
data = animation.asDict(include_full_drawing=True)
data['audio']['file'] = f'annotation-{identifier}.mp3';
archive.writestr(f'annotation-{identifier}.json', json.dumps(data))
logger.info('write js')
with open('www/annotate.js', 'r') as fp:
archive.writestr('annotate.js', fp.read())
with open('www/assets/wNumb-1.2.0.min.js', 'r') as fp:
archive.writestr('wNumb-1.2.0.min.js', fp.read())
logger.info('write html')
html = f"""
<html>
<head>
<script src="wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
</head>
<body>
<annotation-player data-poster-url="annotation-{identifier}.svg" data-annotation-url="annotation-{identifier}.json">
</body>
</html>
"""
archive.writestr(f'annotation-{identifier}.html', html)
with open(tdir + f'/annotation-{identifier}.zip', 'rb') as fp:
self.set_header("Content-Type", "application/zip")
self.set_header("Content-Disposition", f"attachment;filename=annotation-{identifier}.zip")
self.write(fp.read())
logger.info('done')
class AnimationHandler(tornado.web.RequestHandler):
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
def initialize(self, config):
self.config = config
self.index = index
async def get(self, filename):
def get(self, filename):
self.set_header("Content-Type", "application/json")
# filename = self.get_argument("file", None)
if filename == "":
self.set_header("Content-Type", "application/json")
files = []
names = [
name
@ -339,75 +260,50 @@ class AnimationHandler(tornado.web.RequestHandler):
if first_line.endswith(","):
first_line = first_line[:-1]
drawing_specs = json.loads(first_line)
drawing_id = name[:-16]
md = self.index.drawings[drawing_id].get_metadata() if drawing_id in self.index.drawings else {}
title = md['title'] if 'title' in md else None
metadata = json.loads(first_line)
files.append(
{
"name": f"/files/{drawing_id}",
"id": drawing_id,
"title": title,
"ctime": drawing_specs[0],
"name": f"/files/{name[:-16]}",
"id": name[:-16],
"ctime": metadata[0],
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"),
"dimensions": [drawing_specs[1], drawing_specs[2]],
"svg": f"/drawing/{drawing_id}.svg",
"dimensions": [metadata[1], metadata[2]],
"svg": f"/drawing/{name[:-16]}.svg",
}
)
files.sort(key=lambda k: k["mtime"])
self.write(json.dumps(files))
else:
if filename[-4:] == ".svg":
extension = "svg"
filename = filename[:-4]
elif filename[-4:] == ".png":
extension = "png"
filename = filename[:-4]
elif filename[-4:] == ".mp3":
extension = "mp3"
filename = filename[:-4]
elif filename[-4:] == ".wav":
extension = "wav"
filename = filename[:-4]
else:
extension = None
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))
logger.info(f"file {filename=}, {extension=}")
# if annotation_id not in self.index.annotations:
# raise tornado.web.HTTPError(404)
# annotation = self.index.annotations[annotation_id]
t_in = self.get_argument('t_in', None)
t_out = self.get_argument('t_out', None)
animation = self.index.drawings[filename].get_animation()
if t_in is not None and t_out is not None:
animation = animation.getSlice(float(t_in), float(t_out))
if extension == "svg":
self.set_header("Content-Type", "image/svg+xml")
self.write(animation.get_as_svg())
elif extension == "png":
self.set_header("Content-Type", "image/png")
svgstring = animation.get_as_svg()
self.write(cairosvg.svg2png(bytestring=svgstring))
elif extension == "mp3":
self.set_header("Content-Type", "audio/mp3")
audio = await animation.audio.export(format="mp3")
self.write(audio.read())
elif extension == "wav":
self.set_header("Content-Type", "audio/wav")
audio = await animation.audio.export(format="wav")
self.write(audio.read())
else:
self.set_header("Content-Type", "application/json")
self.write(json.dumps(animation.asDict(include_full_drawing=True)))
class TagHandler(tornado.web.RequestHandler):
"""List all tags"""
@ -432,21 +328,12 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag):
if not self.index.has_tag(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])))
annotations = self.index.get_nested_annotations_for_tag(tag)
self.write(json.dumps([{
"id": annotation.id,
"tag": annotation.tag,
"id_hash": svganim.uimethods.annotation_hash(input=annotation.id),
"url": annotation.getJsonUrl(),
"comment": annotation.comment,
"drawing": annotation.drawing.get_url()
} for annotation in annotations]))
annotations = self.index.tags[tag]
self.write(json.dumps(list([a.id for a in annotations])))
class AnnotationHandler(tornado.web.RequestHandler):
@ -461,9 +348,6 @@ class AnnotationHandler(tornado.web.RequestHandler):
if annotation_id[-4:] == ".svg":
extension = "svg"
annotation_id = annotation_id[:-4]
elif annotation_id[-4:] == ".png":
extension = "png"
annotation_id = annotation_id[:-4]
elif annotation_id[-4:] == ".mp3":
extension = "mp3"
annotation_id = annotation_id[:-4]
@ -481,13 +365,7 @@ class AnnotationHandler(tornado.web.RequestHandler):
if extension == "svg":
self.set_header("Content-Type", "image/svg+xml")
self.set_header("Cache-Control", "max-age=31536000, immutable")
self.write(annotation.get_as_svg())
elif extension == "png":
self.set_header("Content-Type", "image/png")
svgstring = annotation.get_as_svg()
self.write(cairosvg.svg2png(bytestring=svgstring))
elif extension == "mp3":
self.set_header("Content-Type", "audio/mp3")
self.write(annotation.getAnimationSlice(
@ -503,41 +381,6 @@ class AnnotationHandler(tornado.web.RequestHandler):
"tag": annotation.tag,
"audio": f"/annotation/{annotation.id}.mp3",
}))
def post(self, annotation_id):
"""change tag for given annotation"""
if annotation_id not in self.index.annotations:
raise tornado.web.HTTPError(404)
# might be set on file level, but let's try to avoid issues by keeping it simple
lock = filelock.FileLock("metadata_write.lock", timeout=10)
with lock:
newTagId = self.get_argument('tag_id')
if not self.index.has_tag(newTagId):
raise tornado.web.HTTPError(400)
annotation: svganim.strokes.Annotation = self.index.annotations[annotation_id]
logger.info(f"change tag from {annotation.tag} to {newTagId}")
# change metadata and reload index
metadata = annotation.drawing.get_metadata()
change = False
for idx, ann in enumerate(metadata['annotations']):
if ann['t_in'] == annotation.t_in and ann['t_out'] == annotation.t_out and annotation.tag == ann['tag']:
#found!?
metadata['annotations'][idx]['tag'] = newTagId
change = True
break
if change == False:
raise HTTPError(409)
with open(annotation.drawing.metadata_fn, "w") as fp:
logger.info(f"save tag in {annotation.drawing.metadata_fn}")
json.dump(metadata, fp)
self.index.refresh()
class DrawingHandler(tornado.web.RequestHandler):
@ -552,9 +395,6 @@ class DrawingHandler(tornado.web.RequestHandler):
if drawing_id[-4:] == ".svg":
extension = "svg"
drawing_id = drawing_id[:-4]
elif drawing_id[-4:] == ".png":
extension = "png"
drawing_id = drawing_id[:-4]
elif drawing_id[-4:] == ".mp3":
extension = "mp3"
drawing_id = drawing_id[:-4]
@ -576,10 +416,6 @@ class DrawingHandler(tornado.web.RequestHandler):
if extension == "svg":
self.set_header("Content-Type", "image/svg+xml")
self.write(drawing.get_animation().get_as_svg())
elif extension == "png":
self.set_header("Content-Type", "image/png")
svgstring =drawing.get_animation().get_as_svg()
self.write(cairosvg.svg2png(bytestring = svgstring))
elif extension == "mp3":
self.set_header("Content-Type", "audio/mp3")
self.write(drawing.get_animation(
@ -649,44 +485,6 @@ class AnnotationsHandler(tornado.web.RequestHandler):
with open(meta_file, "w") as fp:
json.dump(self.json_args, fp)
class TagsHandler(tornado.web.RequestHandler):
def initialize(self, config, index: svganim.strokes.AnnotationIndex) -> None:
self.config = config
self.index = index
def get(self):
self.set_header("Content-Type", "application/json")
self.write(self.index.root_tag.toJson(with_counts=True))
# with open('www/tags.json', 'r') as fp:
# # TODO: enrich with counts
# self.write(fp.read())
def put(self):
# data = json.loads(self.request.body)
tree = svganim.strokes.loadTagFromJson(self.request.body)
logger.info(f"New tag tree:\n{tree}")
newTagsContent = tree.toJson()
# save at minute resolution
now = datetime.datetime.utcnow().isoformat(timespec='minutes')
backup_dir = os.path.join(self.config.storage, 'tag_versions')
if not os.path.exists(backup_dir):
logger.warning(f"Creating tags backupdir {backup_dir}")
os.mkdir(backup_dir)
bakfile = os.path.join(backup_dir, f'tags.{now}.json')
logger.info(f"Creating tags backup {bakfile}" )
shutil.copyfile('www/tags.json', bakfile)
with open('www/tags.json', 'w') as fp:
fp.write(newTagsContent)
# update as to load new tag into cache
self.index.refresh()
self.set_status(204)
# print()
class IndexHandler(tornado.web.RequestHandler):
"""Get annotation as svg"""
@ -698,9 +496,9 @@ class IndexHandler(tornado.web.RequestHandler):
def get(self):
do_refresh = bool(self.get_query_argument('refresh', False))
if do_refresh:
logger.info("Reloading Annotation Index")
self.logger.info("Reloading Annotation Index")
self.index.refresh()
logger.info("\treloaded annotation index")
self.logger.info("\treloaded annotation index")
self.render("templates/index.html", index=self.index)
@ -720,11 +518,8 @@ class Server:
# self.config['server']['port']
self.web_root = os.path.join("www")
if not os.path.exists(self.config.storage):
raise NotADirectoryError("Provided files directory doesn't exist.")
self.index = svganim.strokes.AnnotationIndex(
os.path.join(self.config.storage,"annotation_index.shelve"), self.config.storage, os.path.join(self.config.storage,"metadata")
"annotation_index.shelve", "files", "files/metadata"
)
self.logger.info("Loading Annotation Index")
self.index.refresh()
@ -740,8 +535,7 @@ class Server:
"config": self.config,
},
),
(r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}),
(r"/export/(.*)", ExportHandler, {"config": self.config, "index": self.index}),
(r"/files/(.*)", AnimationHandler, {"config": self.config}),
(
r"/audio/(.+)",
tornado.web.StaticFileHandler,
@ -771,15 +565,11 @@ class Server:
(r"/index", IndexHandler,
{"config": self.config, "index": self.index}),
(r"/tags.json", TagsHandler,
{"config": self.config, "index": self.index}),
(r"/(.*)", StaticFileWithHeaderHandler,
{"path": self.web_root, 'default_filename': 'index.html'}),
],
debug=True,
autoreload=True,
ui_methods= svganim.uimethods
)
application.listen(self.config.port)
tornado.ioloop.IOLoop.current().start()
@ -800,9 +590,6 @@ if __name__ == "__main__":
argParser.add_argument(
"--storage", type=str, default="files", help="directory name for output files"
)
argParser.add_argument(
"--logfile", type=str, default=None, help="log file to output to"
)
argParser.add_argument("--verbose", "-v", action="count", default=0)
args = argParser.parse_args()
@ -822,19 +609,16 @@ if __name__ == "__main__":
)
# File logging
if args.logfile is not None:
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(
args.logfile, maxBytes=1024 * 512, backupCount=5
)
logFileHandler.setFormatter(formatter)
logger.addHandler(logFileHandler)
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)

292
www/annotate.html Normal file
View file

@ -0,0 +1,292 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Annotate a line animation</title>
<style media="screen">
body {
/* background: black;
color: white */
background: lightgray;
}
#sample,
svg {
position: absolute;
top: 20px;
left: 20px;
width: calc(100% - 40px);
height: calc(100% - 200px);
font-family: sans-serif;
z-index: 2;
/* background: white; */
/* border: solid 2px lightgray; */
}
svg .background {
fill: white
}
img {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
path {
fill: none;
stroke: gray;
stroke-width: 1mm;
stroke-linecap: round;
}
g.before path {
opacity: 0.5;
stroke: gray !important;
}
g.after path,
path.before_in {
opacity: .1;
stroke: gray !important;
}
#wrapper {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: none;
}
.gray {
position: absolute;
background: rgba(255, 255, 255, 0.7);
}
input[type='range'] {
/* position: absolute;
z-index: 100;
bottom: 0;
left: 0;
right: 0; */
width: 100%;
}
.controls button.paused, .controls button.playing{
position: absolute;
left: 100%;
width: 30px;
}
.controls button.paused::before{
content: '⏵';
}
.controls button.playing::before{
content: '⏸';
}
.controls {
position: absolute !important;
z-index: 100;
bottom: 10px;
left: 5%;
right: 0;
width: 90%;
}
.scrubber {}
.tags {
line-height: 40px;
display: flex;
flex-direction: row;
padding: 0;
margin: 0;
}
.tags li {
display: block;
padding: 5px;
border: solid 1px darkgray;
flex-grow: 1;
text-align: center;
}
.tags li:hover {
cursor: pointer;
background: darkgray;
}
.tags li.selected {
background: lightsteelblue;
}
.tags li.annotation-rm {
/* display: none; */
overflow: hidden;
color: red;
font-size: 30px;
width: 0;
flex-grow: 0;
padding: 5px 0;
transition: width .3s;
pointer-events: none;
border: none;
direction: rtl;
/* hide behind bar, instead into nothing */
}
.selected-annotation .tags li.annotation-rm {
color: red;
display: block;
width: 30px;
pointer-events: all;
}
.tags li span {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 10px;
vertical-align: middle;
border-radius: 5px;
}
.annotations {
height: 10px;
/* border: solid 1px darkgray; */
position: relative;
}
.annotations>div {
opacity: .4;
background: lightseagreen;
position: absolute;
bottom: 0;
top: 0;
}
.annotations>div:hover,
.annotations>div.selected {
opacity: 1;
cursor: pointer;
}
.annotation-test {
background-color: red !important;
}
.annotation-another {
background-color: blue !important;
}
.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: '*';
color: red;
display: inline-block;
text-align: center;
font-size: 30px;
position: absolute;
top: 10px;
left: 10px;
}
.saved::before {
content: '\2713';
display: inline-block;
color: green;
text-align: center;
font-size: 30px;
position: absolute;
top: 10px;
left: 10px;
}
.noUi-horizontal .noUi-touch-area {
cursor: ew-resize;
}
.audioconfig{
z-index: 9;
background:black;
color: white;
position: relative;
width: 100px; /* as wide as audio controls only */
overflow: hidden;
white-space: nowrap;
}
.audioconfig:hover{
width: auto;
}
.audioconfig select, .audioconfig input{
margin:10px;
}
audio{
vertical-align: middle;
width: 100px; /* hides seek head */
}
.playlist img{
position: static;
width: 250px;
height: 250px;
background: white;
display: block;
}
</style>
<link rel="stylesheet" href="assets/nouislider-15.5.0.css">
<link rel="stylesheet" href="core.css">
</head>
<body>
<div id='interface'>
</div>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
<script src="playlist.js"></script>
<script type='text/javascript'>
let ann;
if (location.search) {
ann = new Annotator(
document.getElementById("interface"),
["map", "text", "relation", "figure"],
location.search.substring(1)
);
} else {
const playlist = new Playlist(document.getElementById("interface"), '/files/');
}
</script>
</body>
</html>

1000
www/annotate.js Normal file

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -119,7 +119,7 @@ class Canvas {
this.setColor(this.colors[0]);
this.socket = new WebSocket(this.url); // TODO: reconnectingwebsocket
this.socket = new WebSocket(this.url);
this.socket.addEventListener('open', (e) => {
this.sendDimensions();
@ -150,10 +150,6 @@ class Canvas {
// this.openTheFloor()
}
});
this.socket.addEventListener('close', (e) => {
this.closeTheFloor();
alert('Internet connection seems to have problems. Please reload the page to reconnect.');
});
}
setPreloaded(json_url) {
@ -211,10 +207,6 @@ class Canvas {
this.wrapperEl.classList.remove('closed');
}
closeTheFloor() {
this.wrapperEl.classList.add('closed');
}
setFilename(filename) {
this.filename = filename;
this.filenameEl.innerText = filename;

88
www/playlist.js Normal file
View file

@ -0,0 +1,88 @@
class Playlist {
constructor(wrapperEl, url) {
this.wrapperEl = wrapperEl;
const request = new Request(url, {
method: 'GET',
});
fetch(request)
.then(response => response.json())
.then(data => {
let playlist = this.wrapperEl.querySelector('.playlist');
if (!playlist) {
playlist = document.createElement('nav');
playlist.classList.add('playlist');
this.wrapperEl.appendChild(playlist)
}
else {
playlist.innerHTML = "";
}
const listEl = document.createElement("ul");
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 = time;
liEl.append(dateEl);
const nameEl = document.createElement("span");
nameEl.classList.add('name');
nameEl.innerText = file.name;
liEl.append(nameEl);
const linksEl = document.createElement("span");
linksEl.classList.add('links');
liEl.append(linksEl);
const playEl = document.createElement("a");
playEl.classList.add('play');
playEl.innerText = "Play";
playEl.href = location;
playEl.pathname = "play.html";
playEl.search = "?"+file.name;
linksEl.append(playEl);
const annotateEl = document.createElement("a");
annotateEl.classList.add('annotate');
annotateEl.innerText = "Annotate";
annotateEl.href = location;
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);
// playlist.style.display = "none";
// });
listEl.appendChild(liEl);
}
playlist.appendChild(listEl);
// do something with the data sent in the request
});
}
}