Compare commits

...

70 commits

Author SHA1 Message Date
Ruben van de Ven
e898121c17 Citation message 2023-03-23 14:31:52 +01:00
Ruben van de Ven
6c26f1d118 fix position 2023-03-23 14:26:25 +01:00
Ruben van de Ven
11e17b24aa fix color 2023-03-23 14:25:55 +01:00
Ruben van de Ven
2466f12a9f Add play button to signal playability 2023-03-23 14:23:28 +01:00
Ruben van de Ven
4bab75ca5b Optional url prefix to put audio and poster into subdir 2023-03-23 14:22:53 +01:00
Ruben van de Ven
23283212d6 Format controls and add Fullscreen option 2023-02-27 20:04:38 +01:00
Ruben van de Ven
9a4a89c9c7 Crop option cycle button (TODO: pollish) 2023-02-27 16:38:40 +01:00
Ruben van de Ven
3ed2448545 Option to show whole images as background in player 2023-02-27 16:02:12 +01:00
Ruben van de Ven
4d08b0b4ad Annotation zip uses unique filenames so they can be extracted into a single folder 2023-02-24 14:12:06 +01:00
Ruben van de Ven
c4c22848e8 Update APRJA 2023-02-24 13:29:10 +01:00
Ruben van de Ven
84a47d1a13 Update documentation to reflect export and APRJA 2023-02-24 13:28:29 +01:00
Ruben van de Ven
e9d2809a4e undo autoformat nonsense 2023-02-23 19:11:18 +01:00
Ruben van de Ven
ec110deae7 fix annotator 2023-02-23 19:07:12 +01:00
Ruben van de Ven
960e9f7132 annotation count in tags.json 2023-02-23 18:55:09 +01:00
Ruben van de Ven
6fbe49473d Annotation export to self-contained zip 2023-02-23 18:52:21 +01:00
Ruben van de Ven
8be08ce9d6 More player controls 2023-02-23 18:28:16 +01:00
Ruben van de Ven
f7e9fd99fc Style Player component and move to annotate.js 2023-02-23 13:25:03 +01:00
Ruben van de Ven
daf0e0dfd4 Annotation Player as rudimentary Web Component 2023-02-23 11:17:24 +01:00
Ruben van de Ven
55475451cf Access all tags in annotator on smaller screen 2023-02-10 11:48:41 +01:00
Ruben van de Ven
8e1da0810c Access all tags in annotator on smaller screen 2023-02-10 11:47:13 +01:00
Ruben van de Ven
3be8e1fe4c Add poetry instructions 2023-02-10 11:18:49 +01:00
Ruben van de Ven
102fb78c02 Helper tool to fix json_appendable files when the screen size has changed during recording 2023-02-10 11:18:00 +01:00
Ruben van de Ven
58e869c969 Fix svg drawing generation 2023-02-09 14:00:21 +01:00
Ruben van de Ven
a1bc3acd5d Latest tags file 2023-02-09 13:51:09 +01:00
Ruben van de Ven
47837fa9eb change README title 2022-09-12 11:26:32 +02:00
Ruben van de Ven
e6cbd19718 change README title 2022-09-12 11:25:54 +02:00
Ruben van de Ven
044a537ef3 add example apache config 2022-09-12 11:23:05 +02:00
Ruben van de Ven
8c850d22b4 Add parse_offsets utility script 2022-09-12 11:20:07 +02:00
Ruben van de Ven
6c3f960ffb Expand Readme and add LICENSE file 2022-09-12 11:19:45 +02:00
Ruben van de Ven
d68450361e PNG output option for images 2022-06-28 17:10:15 +02:00
Ruben van de Ven
02dd1a69d0 New tags version 2022-06-28 17:09:16 +02:00
Ruben van de Ven
c916dfaec1 Sticky annotation actions 2022-06-14 17:32:20 +02:00
Ruben van de Ven
d39548ff95 Fix: selection glitch in annotation index 2022-06-14 17:31:49 +02:00
Ruben van de Ven
06a881525f Annotator: Nicer flow for log diagram title 2022-06-10 13:53:27 +02:00
Ruben van de Ven
f8945549b0 Fix annotation color in annotator 2022-06-10 13:49:12 +02:00
Ruben van de Ven
eeacb623c2 Fix annotation color in annotator 2022-06-10 13:45:01 +02:00
Ruben van de Ven
95405ddd31 Clean some console.logs 2022-06-10 13:13:13 +02:00
Ruben van de Ven
89253454f9 Feature: give diagrams a title 2022-06-10 13:10:19 +02:00
Ruben van de Ven
adce8d067c Counts for tags in index 2022-06-08 18:51:22 +02:00
Ruben van de Ven
4b0b5c2c16 Feature: move annotations from one tag to another 2022-06-08 18:22:04 +02:00
Ruben van de Ven
5fa5096c44 WIP upgraded index, slash annotation editor 2022-06-08 17:26:30 +02:00
Ruben van de Ven
ab9c66794c Start with tag management. Incl. fancier index 2022-06-08 13:28:36 +02:00
Ruben van de Ven
232903c0ff WIP annotations 2022-06-01 16:08:49 +02:00
Ruben van de Ven
9c54b2f8d7 fix annotator grid. 2022-05-31 15:32:51 +02:00
Ruben van de Ven
c908bf16af Change: tags in sidebar 2022-05-31 14:03:50 +02:00
Ruben van de Ven
fd408f549c tooltips not in the way of the annotation overview 2022-05-31 13:37:15 +02:00
Ruben van de Ven
06bdd0dad1 Fix playback of empty slice. 2022-05-31 13:15:22 +02:00
Ruben van de Ven
4cc69a4d29 poetry lock update 2022-05-31 11:50:10 +02:00
Ruben van de Ven
3e91e04bfe Change dockerfile for faster builds. Incl. ffmpeg 2022-05-31 11:49:58 +02:00
Ruben van de Ven
cc362e5ceb Attempt to cache items on index page 2022-05-31 11:19:07 +02:00
Ruben van de Ven
5eb9b934f4 Big set of changes to load annotations incl. audio. Fixes player mode. 2022-05-30 15:02:28 +02:00
Ruben van de Ven
cc253295ee feature: escape on comment now deselects 2022-05-25 10:14:34 +02:00
Ruben van de Ven
7e946cb3f6 feature: comment on annotations 2022-05-25 10:04:55 +02:00
Ruben van de Ven
eb6d31742e Annotator now supports player mode. Buffer audio befor playback. 2022-05-24 21:23:03 +02:00
Ruben van de Ven
d55c5b5486 fix (hopefully): piaying single point lines 2022-05-24 17:21:20 +02:00
Ruben van de Ven
38b8814d60 fix: keyboard i/o now modify selected annotation 2022-05-24 16:50:59 +02:00
Ruben van de Ven
5a3c2a5368 new (yet intermediary) tags 2022-05-03 21:54:11 +02:00
Ruben van de Ven
d4512a2580 smaller help 2022-05-03 10:57:16 +02:00
Ruben van de Ven
de95c5afa0 tidy html 2022-05-03 10:00:27 +02:00
Ruben van de Ven
da7c5700d6 more keyboard events and help info 2022-05-03 10:00:12 +02:00
Ruben van de Ven
666b91b3c7 small fixes for clarity 2022-05-02 22:49:56 +02:00
Ruben van de Ven
111bfeeb7a More keyboard input, and no reset on pause() 2022-05-02 22:41:37 +02:00
Ruben van de Ven
4a0d605595 Annotate using tags.json 2022-04-29 12:05:48 +02:00
Ruben van de Ven
438c09a48f Add timecode input field 2022-04-19 13:29:26 +02:00
Ruben van de Ven
6d7a223a69 viewbox drag now visible on playback annotator. 2022-03-29 15:07:18 +02:00
Ruben van de Ven
949a16b01f Alert on websocket close 2022-03-25 09:32:37 +01:00
Ruben van de Ven
1e1cb5cfd0 update compose to listen to localhost only 2022-03-15 18:23:42 +01:00
Ruben van de Ven
f1a077da50 auto start container 2022-03-15 17:57:31 +01:00
Ruben van de Ven
fd881a0745 Add missing index file 2022-03-15 15:08:17 +01:00
Ruben van de Ven
8d8eedb940 Run in docker 2022-03-15 15:04:03 +01:00
38 changed files with 5269 additions and 2005 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
files/

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
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 Normal file
View file

@ -0,0 +1,21 @@
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,7 +1,124 @@
# svganim
# Annotated Vector Drawing Animations
Create a hand drawn vector animation.
<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>
```bash
poetry run python webserver.py
```
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

68
app/fix_window_size.py Normal file
View file

@ -0,0 +1,68 @@
"""
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

45
app/parse_offsets.py Normal file
View file

@ -0,0 +1,45 @@
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.')

786
app/svganim/strokes.py Normal file
View file

@ -0,0 +1,786 @@
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))

8
app/svganim/uimethods.py Normal file
View file

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

201
app/templates/index.html Normal file
View file

@ -0,0 +1,201 @@
<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,6 +1,10 @@
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
@ -11,7 +15,10 @@ import html
import argparse
import coloredlogs
import glob
import filelock
import svganim.strokes
import svganim.uimethods
import cairosvg
logger = logging.getLogger("svganim.webserver")
@ -76,6 +83,7 @@ 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"
@ -234,15 +242,86 @@ 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):
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
def get(self, filename):
self.set_header("Content-Type", "application/json")
self.index = index
async def get(self, filename):
# filename = self.get_argument("file", None)
if filename == "":
self.set_header("Content-Type", "application/json")
files = []
names = [
name
@ -260,50 +339,75 @@ class AnimationHandler(tornado.web.RequestHandler):
if first_line.endswith(","):
first_line = first_line[:-1]
metadata = json.loads(first_line)
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
files.append(
{
"name": f"/files/{name[:-16]}",
"id": name[:-16],
"ctime": metadata[0],
"name": f"/files/{drawing_id}",
"id": drawing_id,
"title": title,
"ctime": drawing_specs[0],
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"),
"dimensions": [metadata[1], metadata[2]],
"svg": f"/drawing/{name[:-16]}.svg",
"dimensions": [drawing_specs[1], drawing_specs[2]],
"svg": f"/drawing/{drawing_id}.svg",
}
)
files.sort(key=lambda k: k["mtime"])
self.write(json.dumps(files))
else:
path = os.path.join(
self.config.storage, os.path.basename(
filename) + ".json_appendable"
)
drawing = {"file": filename, "shape": []}
with open(path, "r") as fp:
events = json.loads("[" + fp.read() + "]")
for i, event in enumerate(events):
if i == 0:
# metadata on first line
drawing["time"] = event[0]
drawing["dimensions"] = [event[1], event[2]]
else:
if type(event) is list:
# ignore double metadatas, which appear when continuaing an existing drawing
continue
if event["event"] == "viewbox":
pass
if event["event"] == "stroke":
# points = []
# for i in range(int(len(stroke) / 4)):
# p = stroke[i*4:i*4+4]
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
drawing["shape"].append(
{"color": event["color"],
"points": event["points"]}
)
self.write(json.dumps(drawing))
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
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"""
@ -328,12 +432,21 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
self.metadir = os.path.join(self.config.storage, "metadata")
def get(self, tag):
if tag not in self.index.tags:
if not self.index.has_tag(tag):
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.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]))
class AnnotationHandler(tornado.web.RequestHandler):
@ -348,6 +461,9 @@ 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]
@ -365,7 +481,13 @@ 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(
@ -381,6 +503,41 @@ 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):
@ -395,6 +552,9 @@ 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]
@ -416,6 +576,10 @@ 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(
@ -485,6 +649,44 @@ 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"""
@ -496,9 +698,9 @@ class IndexHandler(tornado.web.RequestHandler):
def get(self):
do_refresh = bool(self.get_query_argument('refresh', False))
if do_refresh:
self.logger.info("Reloading Annotation Index")
logger.info("Reloading Annotation Index")
self.index.refresh()
self.logger.info("\treloaded annotation index")
logger.info("\treloaded annotation index")
self.render("templates/index.html", index=self.index)
@ -518,8 +720,11 @@ 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(
"annotation_index.shelve", "files", "files/metadata"
os.path.join(self.config.storage,"annotation_index.shelve"), self.config.storage, os.path.join(self.config.storage,"metadata")
)
self.logger.info("Loading Annotation Index")
self.index.refresh()
@ -535,7 +740,8 @@ class Server:
"config": self.config,
},
),
(r"/files/(.*)", AnimationHandler, {"config": self.config}),
(r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}),
(r"/export/(.*)", ExportHandler, {"config": self.config, "index": self.index}),
(
r"/audio/(.+)",
tornado.web.StaticFileHandler,
@ -565,11 +771,15 @@ 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()
@ -590,6 +800,9 @@ 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()
@ -609,16 +822,19 @@ if __name__ == "__main__":
)
# File logging
formatter = logging.Formatter(
fmt="%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s",
datefmt="%Y/%m/%d %H:%M:%S",
) # %I:%M:%S %p AM|PM format
logFileHandler = logging.handlers.RotatingFileHandler(
"log/draw_log.log", maxBytes=1024 * 512, backupCount=5
)
logFileHandler.setFormatter(formatter)
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)
logger.addHandler(logFileHandler)
logger.info(f"Start server: http://localhost:{args.port}")
server = Server(args, logger)

168
app/www/annotate.html Normal file
View file

@ -0,0 +1,168 @@
<!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>

1873
app/www/annotate.js Normal file

File diff suppressed because it is too large Load diff

421
app/www/annotations.js Normal file
View file

@ -0,0 +1,421 @@
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

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);
this.socket = new WebSocket(this.url); // TODO: reconnectingwebsocket
this.socket.addEventListener('open', (e) => {
this.sendDimensions();
@ -150,6 +150,10 @@ 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) {
@ -207,6 +211,10 @@ class Canvas {
this.wrapperEl.classList.remove('closed');
}
closeTheFloor() {
this.wrapperEl.classList.add('closed');
}
setFilename(filename) {
this.filename = filename;
this.filenameEl.innerText = filename;

30
app/www/index.html Normal file
View file

@ -0,0 +1,30 @@
<!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>

119
app/www/playlist.js Normal file
View file

@ -0,0 +1,119 @@
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
}
}

336
app/www/svganim.css Normal file
View file

@ -0,0 +1,336 @@
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;
}

321
app/www/tags.json Normal file
View file

@ -0,0 +1,321 @@
{
"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

@ -0,0 +1,26 @@
<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>

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
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

BIN
docs/Figure1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

405
poetry.lock generated Normal file
View file

@ -0,0 +1,405 @@
[[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,6 +10,9 @@ 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]

View file

@ -1,505 +0,0 @@
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

View file

@ -1,60 +0,0 @@
<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,292 +0,0 @@
<!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>

File diff suppressed because it is too large Load diff

View file

@ -1,88 +0,0 @@
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
});
}
}