Compare commits
No commits in common. "main" and "50d4656ad7079d08fe518a1bb9c82f828fa767b8" have entirely different histories.
main
...
50d4656ad7
38 changed files with 2005 additions and 5269 deletions
|
@ -1 +0,0 @@
|
||||||
files/
|
|
23
Dockerfile
23
Dockerfile
|
@ -1,23 +0,0 @@
|
||||||
FROM python:3.9
|
|
||||||
|
|
||||||
# TODO manually compile ffmpeg, to prevent useless deps and decrease build size
|
|
||||||
# for that, see here: https://stackoverflow.com/questions/53944487/how-to-install-ffmpeg-in-a-docker-container
|
|
||||||
# pin ffmpeg version to prevent issues down the road
|
|
||||||
RUN apt-get update && apt-get -y install ffmpeg=7:4.3.*
|
|
||||||
|
|
||||||
RUN mkdir /app
|
|
||||||
COPY pyproject.toml /app
|
|
||||||
COPY poetry.lock /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ENV PYTHONPATH=${PYTHONPATH}:${PWD}
|
|
||||||
RUN pip3 install poetry
|
|
||||||
RUN poetry config virtualenvs.create false
|
|
||||||
RUN poetry install --no-dev
|
|
||||||
|
|
||||||
# copy files as late as possible, these change the most
|
|
||||||
# forcing a rerun of all layers
|
|
||||||
COPY /app /app
|
|
||||||
|
|
||||||
|
|
||||||
ENTRYPOINT poetry run python webserver.py --storage /files
|
|
21
LICENSE
21
LICENSE
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2022 Ruben van de Ven
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
127
README.md
127
README.md
|
@ -1,124 +1,7 @@
|
||||||
# Annotated Vector Drawing Animations
|
# svganim
|
||||||
|
|
||||||
<p align="center">
|
Create a hand drawn vector animation.
|
||||||
<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>
|
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
poetry run python webserver.py
|
||||||
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): 50–65. https://doi.org/10.7146/aprja.v11i1.134306
|
|
|
@ -1,68 +0,0 @@
|
||||||
"""
|
|
||||||
Some recordings start with the wrong window size, probably because they were started on the laptop screen.
|
|
||||||
This script modifies the opening dimensions and all subsequent viewbox elements to adhere to the new size.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# TODO the whole script (still a copy)
|
|
||||||
|
|
||||||
logger = logging.getLogger("svganim.fixer")
|
|
||||||
argParser = argparse.ArgumentParser(
|
|
||||||
description="""Helper tool to fix the window size when the recording started with a smaller screen that full screen.
|
|
||||||
|
|
||||||
Beware, when used with a shell stdin redirect, do not point it towards the input file. That file will be truncated before reading!
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
argParser.add_argument(
|
|
||||||
"--input", type=str, help="Filename to run on (.json_appendable)"
|
|
||||||
)
|
|
||||||
argParser.add_argument(
|
|
||||||
"--width", type=int, help="New width in px"
|
|
||||||
)
|
|
||||||
argParser.add_argument(
|
|
||||||
"--height", type=int, help="New height in px"
|
|
||||||
)
|
|
||||||
args = argParser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
with open(args.input, "r") as fp:
|
|
||||||
events = json.loads("[" + fp.read() + "]")
|
|
||||||
extrabox = None
|
|
||||||
for i, event in enumerate(events):
|
|
||||||
if type(event) is list:
|
|
||||||
# if event[1] != args.width or event[2] != args.height:
|
|
||||||
# # correct default position
|
|
||||||
# # extrabox = {"event": "viewbox", "viewboxes": [{
|
|
||||||
# # 't': 0,
|
|
||||||
# # 'x': (event[1] - args.width)/2,
|
|
||||||
# # 'y': (event[2] - args.height)/2,
|
|
||||||
# # 'width': args.width,
|
|
||||||
# # 'height': args.height,
|
|
||||||
# # }], 'original_size': [event[1], event[2]]}
|
|
||||||
event[1] = args.width
|
|
||||||
event[2] = args.height
|
|
||||||
elif event["event"] == "viewbox":
|
|
||||||
for key, box in enumerate(event['viewboxes']):
|
|
||||||
# {"t": 1088912, "x": 0, "y": 0, "width": 2048, "height": 1152},
|
|
||||||
box['x'] += (box['width'] - args.width)/2
|
|
||||||
box['y'] += (box['height'] - args.height)/2
|
|
||||||
box['width'] = args.width
|
|
||||||
box['height'] = args.height
|
|
||||||
event['viewboxes'][key] = box
|
|
||||||
# elif event["event"] == "stroke":
|
|
||||||
# event['points'] = [[p[0], p[1], p[2], p[3] + time_offset]
|
|
||||||
# for p in event['points']]
|
|
||||||
|
|
||||||
if i>0:
|
|
||||||
sys.stdout.write(",\n")
|
|
||||||
sys.stdout.write(json.dumps(event))
|
|
||||||
if extrabox:
|
|
||||||
sys.stdout.write(",\n")
|
|
||||||
sys.stdout.write(json.dumps(extrabox))
|
|
||||||
extrabox = None
|
|
||||||
|
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
logger = logging.getLogger("svganim.fixer")
|
|
||||||
argParser = argparse.ArgumentParser(
|
|
||||||
description="Helper tool to fix timings after glitch"
|
|
||||||
)
|
|
||||||
argParser.add_argument(
|
|
||||||
"--input", type=str, help="Filename to run on (.json_appendable)"
|
|
||||||
)
|
|
||||||
args = argParser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
with open(args.input, "r") as fp:
|
|
||||||
events = json.loads("[" + fp.read() + "]")
|
|
||||||
time_offset = 0
|
|
||||||
for i, event in enumerate(events):
|
|
||||||
if type(event) is list:
|
|
||||||
pass
|
|
||||||
elif event['event'] == "offset":
|
|
||||||
time_offset += event['offset']
|
|
||||||
# print a reverseable event that is ignored by this script
|
|
||||||
event = {
|
|
||||||
'event': 'added_offset',
|
|
||||||
'offset': event['offset']
|
|
||||||
}
|
|
||||||
elif event['event'] == 'added_offset':
|
|
||||||
logger.warning('ignore existing offset')
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif event["event"] == "viewbox":
|
|
||||||
for key, box in enumerate(event['viewboxes']):
|
|
||||||
event['viewboxes'][key]['t'] += time_offset
|
|
||||||
elif event["event"] == "stroke":
|
|
||||||
event['points'] = [[p[0], p[1], p[2], p[3] + time_offset]
|
|
||||||
for p in event['points']]
|
|
||||||
|
|
||||||
if i>0:
|
|
||||||
sys.stdout.write(",\n")
|
|
||||||
sys.stdout.write(json.dumps(event))
|
|
||||||
|
|
||||||
if time_offset == 0:
|
|
||||||
logger.warning('\n\nNo offset defined in file, please do so by adding {"event":"offset", "offset": miliseconds} at the line from which the offset should be added.')
|
|
|
@ -1,786 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
import asyncio
|
|
||||||
import copy
|
|
||||||
from ctypes.wintypes import tagMSG
|
|
||||||
import json
|
|
||||||
from os import X_OK, PathLike
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import subprocess
|
|
||||||
from typing import Optional, Union
|
|
||||||
import shelve
|
|
||||||
from pydub import AudioSegment
|
|
||||||
import svgwrite
|
|
||||||
import tempfile
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
from anytree import NodeMixin, RenderTree, iterators
|
|
||||||
from anytree.exporter import JsonExporter, DictExporter
|
|
||||||
from anytree.importer import JsonImporter, DictImporter
|
|
||||||
|
|
||||||
logger = logging.getLogger('svganim.strokes')
|
|
||||||
|
|
||||||
Milliseconds = float
|
|
||||||
Seconds = float
|
|
||||||
|
|
||||||
class Annotation:
|
|
||||||
def __init__(self, tag: str, drawing: Drawing, t_in: Milliseconds, t_out: Milliseconds, comment: str = None) -> None:
|
|
||||||
self.tag = tag
|
|
||||||
self.t_in = t_in
|
|
||||||
self.t_out = t_out
|
|
||||||
self.drawing = drawing
|
|
||||||
self.comment = comment
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> str:
|
|
||||||
return f'{self.drawing.id}:{self.tag}:{self.t_in}:{self.t_out}'
|
|
||||||
|
|
||||||
def getAnimationSlice(self) -> AnimationSlice:
|
|
||||||
return self.drawing.get_animation().getSlice(self.t_in, self.t_out)
|
|
||||||
|
|
||||||
def get_as_svg(self) -> str:
|
|
||||||
return self.getAnimationSlice().get_as_svg()
|
|
||||||
|
|
||||||
def getJsonUrl(self) -> str:
|
|
||||||
return self.drawing.get_url() + f"?t_in={self.t_in}&t_out={self.t_out}"
|
|
||||||
|
|
||||||
|
|
||||||
Filename = Union[str, bytes, PathLike[str], PathLike[bytes]]
|
|
||||||
|
|
||||||
SliceId = [str, float, float]
|
|
||||||
|
|
||||||
|
|
||||||
class Drawing:
|
|
||||||
def __init__(self, filename: Filename, metadata_dir: Filename, basedir: Filename) -> None:
|
|
||||||
self.eventfile = filename
|
|
||||||
self.id = os.path.splitext(os.path.basename(self.eventfile))[0]
|
|
||||||
self.metadata_fn = os.path.join(metadata_dir, f"{self.id}.json")
|
|
||||||
self.basedir = basedir
|
|
||||||
|
|
||||||
def get_url(self) -> str:
|
|
||||||
return f"/files/{self.id}"
|
|
||||||
|
|
||||||
def get_annotations_url(self) -> str:
|
|
||||||
return f"/annotations/{self.id}"
|
|
||||||
|
|
||||||
def get_canvas_metadata(self) -> list:
|
|
||||||
logger.info(f'metadata for {self.id}')
|
|
||||||
with open(self.eventfile, "r") as fp:
|
|
||||||
first_line = fp.readline().strip()
|
|
||||||
|
|
||||||
if first_line.endswith(","):
|
|
||||||
first_line = first_line[:-1]
|
|
||||||
data = json.loads(first_line)
|
|
||||||
return {
|
|
||||||
"date": data[0],
|
|
||||||
"dimensions": {
|
|
||||||
"width": data[1],
|
|
||||||
"height": data[2],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_audio(self) -> Optional[AudioSlice]:
|
|
||||||
md = self.get_metadata()
|
|
||||||
if 'audio' not in md:
|
|
||||||
return None
|
|
||||||
if 'file' not in md['audio']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return AudioSlice(filename=os.path.join(self.basedir, md['audio']['file'][1:]), drawing=self, offset=md['audio']['offset']*1000)
|
|
||||||
|
|
||||||
def get_animation(self) -> AnimationSlice:
|
|
||||||
# with open(self.eventfile, "r") as fp:
|
|
||||||
strokes = []
|
|
||||||
viewboxes = []
|
|
||||||
with open(self.eventfile, "r") as fp:
|
|
||||||
events = json.loads("[" + fp.read() + "]")
|
|
||||||
for i, event in enumerate(events):
|
|
||||||
if i == 0:
|
|
||||||
# metadata on first line, add as initial viewbox to slice
|
|
||||||
viewboxes.append(TimedViewbox(-float('Infinity'), 0, 0, event[1], event[2]))
|
|
||||||
else:
|
|
||||||
if type(event) is list:
|
|
||||||
# ignore double metadatas, which appear when continuaing an existing drawing
|
|
||||||
continue
|
|
||||||
|
|
||||||
if event["event"] == "viewbox":
|
|
||||||
viewboxes.extend([TimedViewbox(
|
|
||||||
b['t'], b['x'], b['y'], b['width'], b['height']) for b in event['viewboxes']])
|
|
||||||
if event["event"] == "stroke":
|
|
||||||
# points = []
|
|
||||||
# for i in range(int(len(stroke) / 4)):
|
|
||||||
# p = stroke[i*4:i*4+4]
|
|
||||||
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
|
|
||||||
strokes.append(
|
|
||||||
Stroke(
|
|
||||||
event["color"],
|
|
||||||
[Point.fromTuple(tuple(p))
|
|
||||||
for p in event["points"]],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return AnimationSlice(self, [self.id, None, None], strokes, viewboxes, audioslice=self.get_audio())
|
|
||||||
|
|
||||||
def get_metadata(self):
|
|
||||||
canvas = self.get_canvas_metadata()
|
|
||||||
if os.path.exists(self.metadata_fn):
|
|
||||||
with open(self.metadata_fn, "r") as fp:
|
|
||||||
metadata = json.load(fp)
|
|
||||||
else:
|
|
||||||
metadata = {}
|
|
||||||
metadata["canvas"] = canvas
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
def get_absolute_viewbox(self) -> Viewbox:
|
|
||||||
return self.get_animation().get_bounding_box()
|
|
||||||
|
|
||||||
|
|
||||||
class Viewbox:
|
|
||||||
def __init__(self, x: float, y: float, width: float, height: float):
|
|
||||||
self.x = x
|
|
||||||
self.y = y
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.x} {self.y} {self.width} {self.height}"
|
|
||||||
|
|
||||||
|
|
||||||
class TimedViewbox(Viewbox):
|
|
||||||
def __init__(self, time: Milliseconds, x: float, y: float, width: float, height: float):
|
|
||||||
super().__init__(x, y, width, height)
|
|
||||||
self.t = time
|
|
||||||
|
|
||||||
|
|
||||||
FrameIndex = tuple[int, int]
|
|
||||||
|
|
||||||
|
|
||||||
class AnimationSlice:
|
|
||||||
# either a whole drawing or the result of applying an annotation to a drawing (an excerpt)
|
|
||||||
# TODO rename to AnimationSlice to include audio as well
|
|
||||||
def __init__(
|
|
||||||
self, drawing: Drawing, slice_id: SliceId, strokes: list[Stroke], viewboxes: list[TimedViewbox] = [], t_in: float = 0, t_out: float = None, audioslice: AudioSlice = None
|
|
||||||
) -> None:
|
|
||||||
self.drawing = drawing
|
|
||||||
self.id = slice_id
|
|
||||||
self.strokes = strokes
|
|
||||||
self.viewboxes = viewboxes
|
|
||||||
self.t_in = t_in
|
|
||||||
self.t_out = t_out
|
|
||||||
self.audio = audioslice
|
|
||||||
|
|
||||||
def asDict(self, include_full_drawing=False) -> dict:
|
|
||||||
"""Can be used to json-ify the animation-slice
|
|
||||||
"""
|
|
||||||
|
|
||||||
# conversion necessary for when no t_in is given
|
|
||||||
boxes = [v.__dict__ for v in self.viewboxes]
|
|
||||||
for box in boxes:
|
|
||||||
if box['t'] == -float('Infinity'):
|
|
||||||
box['t'] = 0
|
|
||||||
|
|
||||||
drawing = {
|
|
||||||
"file": self.getUrl(),
|
|
||||||
"time": "-", # creation date
|
|
||||||
# dimensions of drawing canvas
|
|
||||||
"dimensions": [self.viewboxes[0].width, self.viewboxes[0].height],
|
|
||||||
"shape": [s.asDict() for s in self.strokes],
|
|
||||||
"viewboxes": boxes,
|
|
||||||
"bounding_box": self.get_bounding_box().__dict__,
|
|
||||||
"audio": self.getAudioDict() if self.audio else None,
|
|
||||||
}
|
|
||||||
if include_full_drawing:
|
|
||||||
drawing["background"] = [s.get_as_d() for s in self.drawing.get_animation().strokes]
|
|
||||||
drawing["background_bounding_box"] = self.drawing.get_animation().get_bounding_box().__dict__
|
|
||||||
return drawing
|
|
||||||
|
|
||||||
def getAudioDict(self):
|
|
||||||
"""quick and dirty to not use audio.asDict(), but it avoids passing all around sorts of data"""
|
|
||||||
return {
|
|
||||||
"file": '/files/' + self.getUrl('.mp3'),
|
|
||||||
"offset": 0
|
|
||||||
# "offset": self.audio.offset / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
def getUrl(self, extension = '') -> str:
|
|
||||||
if not self.id[1] and not self.id[2]:
|
|
||||||
return self.id[0]
|
|
||||||
|
|
||||||
return self.id[0] + f"{extension}?t_in={self.t_in}&t_out={self.t_out}"
|
|
||||||
|
|
||||||
def getHash(self):
|
|
||||||
'''
|
|
||||||
A repeatable way to create a relatively unique hash for the slice
|
|
||||||
Used e.g. to export the slice.
|
|
||||||
'''
|
|
||||||
random.seed('-'.join([str(b) if b is not None else "" for b in self.id]))
|
|
||||||
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
|
||||||
|
|
||||||
def get_bounding_box(self, stroke_thickness: float = 3.5) -> Viewbox:
|
|
||||||
"""Stroke_thickness 3.5 == 1mm. If it should not be considered, just set it to 0.
|
|
||||||
"""
|
|
||||||
if len(self.strokes) == 0:
|
|
||||||
# empty set
|
|
||||||
return Viewbox(0,0,0,0)
|
|
||||||
|
|
||||||
min_x, max_x = float("inf"), float("-inf")
|
|
||||||
min_y, max_y = float("inf"), float("-inf")
|
|
||||||
|
|
||||||
for s in self.strokes:
|
|
||||||
for p in s.points:
|
|
||||||
|
|
||||||
x1 = p.x - stroke_thickness/2
|
|
||||||
x2 = p.x + stroke_thickness/2
|
|
||||||
y1 = p.y - stroke_thickness/2
|
|
||||||
y2 = p.y + stroke_thickness/2
|
|
||||||
if x1 < min_x:
|
|
||||||
min_x = x1
|
|
||||||
if x2 > max_x:
|
|
||||||
max_x = x2
|
|
||||||
if y1 < min_y:
|
|
||||||
min_y = y1
|
|
||||||
if y2 > max_y:
|
|
||||||
max_y = y2
|
|
||||||
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
|
|
||||||
|
|
||||||
def getSlice(self, t_in: Milliseconds, t_out: Milliseconds) -> AnimationSlice:
|
|
||||||
"""slice the slice. T in ms"""
|
|
||||||
frame_in = self.getIndexForInPoint(t_in)
|
|
||||||
frame_out = self.getIndexForOutPoint(t_out)
|
|
||||||
strokes = self.getStrokeSlices(frame_in, frame_out, t_in)
|
|
||||||
# TODO shift t of points with t_in
|
|
||||||
viewboxes = self.getViewboxesSlice(t_in, t_out)
|
|
||||||
|
|
||||||
audio = self.audio.getSlice(t_in, t_out) if self.audio else None
|
|
||||||
return AnimationSlice(self.drawing, [self.id[0], t_in, t_out], strokes, viewboxes, t_in, t_out, audio)
|
|
||||||
|
|
||||||
def get_as_svg_dwg(self) -> svgwrite.Drawing:
|
|
||||||
box = self.get_bounding_box()
|
|
||||||
(_, fn) = tempfile.mkstemp(suffix='.svg', text=True)
|
|
||||||
dwg = svgwrite.Drawing(fn, size=(box.width, box.height))
|
|
||||||
dwg.viewbox(box.x, box.y, box.width, box.height)
|
|
||||||
self.add_to_dwg(dwg)
|
|
||||||
dwg.defs.add(
|
|
||||||
dwg.style("path{stroke-width:1mm;stroke-linecap: round;}"))
|
|
||||||
return dwg
|
|
||||||
|
|
||||||
def get_as_svg(self) -> str:
|
|
||||||
dwg = self.get_as_svg_dwg()
|
|
||||||
fp = io.StringIO()
|
|
||||||
dwg.write(fp, pretty=True)
|
|
||||||
return fp.getvalue()
|
|
||||||
|
|
||||||
def add_to_dwg(self, dwg: SvgDrawing):
|
|
||||||
group = svgwrite.container.Group()
|
|
||||||
for stroke in self.strokes:
|
|
||||||
stroke.add_to_dwg(group)
|
|
||||||
dwg.add(group)
|
|
||||||
|
|
||||||
def getViewboxesSlice(self, t_in: Milliseconds, t_out: Milliseconds) -> list[TimedViewbox]:
|
|
||||||
"""Extract the viewboxes for in- and outpoints.
|
|
||||||
If there's one before inpoint, move that to the t_in, so that animation starts at the right position
|
|
||||||
the slice is offset by t_in ms
|
|
||||||
"""
|
|
||||||
viewboxes = [] # Add single empty element, so that we can use viewboxes[0] later
|
|
||||||
lastbox = None
|
|
||||||
for viewbox in self.viewboxes:
|
|
||||||
if viewbox.t > t_out:
|
|
||||||
break
|
|
||||||
|
|
||||||
if viewbox.t <= t_in:
|
|
||||||
# make sure the first box is the last box from _before_ the slice
|
|
||||||
firstbox = TimedViewbox(
|
|
||||||
0, viewbox.x, viewbox.y, viewbox.width, viewbox.height)
|
|
||||||
if not len(viewboxes):
|
|
||||||
viewboxes.append(firstbox)
|
|
||||||
else:
|
|
||||||
viewboxes[0] = firstbox
|
|
||||||
continue
|
|
||||||
|
|
||||||
viewboxes.append(TimedViewbox(viewbox.t-t_in, viewbox.x, viewbox.y, viewbox.width, viewbox.height))
|
|
||||||
return viewboxes
|
|
||||||
|
|
||||||
def getStrokeSlices(
|
|
||||||
self, index_in: FrameIndex, index_out: FrameIndex, t_offset: Seconds = 0
|
|
||||||
) -> list[Stroke]:
|
|
||||||
"""Get list of Stroke/StrokeSlice based in in and out indexes
|
|
||||||
Based on annotation.js getStrokesSliceForPathRange(in_point, out_point)
|
|
||||||
If either in point or out point is [None, None], return an empty set.
|
|
||||||
"""
|
|
||||||
slices = []
|
|
||||||
if index_in[0] is None and index_in[1] is None:
|
|
||||||
# If no inpoint is set, in_point is after the last stroke
|
|
||||||
return slices
|
|
||||||
|
|
||||||
if index_out[0] is None and index_out[1] is None:
|
|
||||||
# If no out point is set, out_point is before the last stroke
|
|
||||||
return slices
|
|
||||||
|
|
||||||
for i in range(index_in[0], index_out[0] + 1):
|
|
||||||
try:
|
|
||||||
stroke = self.strokes[i]
|
|
||||||
except IndexError:
|
|
||||||
# out point can be Infinity. So interrupt whenever the end is reached
|
|
||||||
break
|
|
||||||
|
|
||||||
in_i = index_in[1] if index_in[0] == i else 0
|
|
||||||
out_i = index_out[1] if index_out[0] == i else len(
|
|
||||||
stroke.points) - 1
|
|
||||||
|
|
||||||
slices.append(StrokeSlice(stroke, in_i, out_i, t_offset))
|
|
||||||
return slices
|
|
||||||
|
|
||||||
def getIndexForInPoint(self, ms: Milliseconds) -> FrameIndex:
|
|
||||||
"""Get the frame index (path, point) based on the given time
|
|
||||||
The In point version (so the first index after ms)
|
|
||||||
Equal to annotations.js findPositionForTime(ms)
|
|
||||||
"""
|
|
||||||
path_i = None
|
|
||||||
point_i = None
|
|
||||||
for i, stroke in enumerate(self.strokes):
|
|
||||||
start_at = stroke.points[0].t
|
|
||||||
end_at = stroke.points[-1].t
|
|
||||||
if end_at < ms:
|
|
||||||
# certainly not the right point yet
|
|
||||||
continue
|
|
||||||
if start_at > ms:
|
|
||||||
path_i = i
|
|
||||||
point_i = 0
|
|
||||||
break # too far, so this is the first point after in point
|
|
||||||
else:
|
|
||||||
# our in-point is inbetween first and last of the stroke
|
|
||||||
# we are getting close, find the right point_i
|
|
||||||
path_i = i
|
|
||||||
for pi, point in enumerate(stroke.points):
|
|
||||||
point_i = pi
|
|
||||||
if point.t > ms:
|
|
||||||
break # stop when finding the next point after in point
|
|
||||||
break # done :-)
|
|
||||||
if path_i is None or point_i is None:
|
|
||||||
logger.warn("in point after last stroke. Not sure if this works")
|
|
||||||
pass
|
|
||||||
return (path_i, point_i)
|
|
||||||
|
|
||||||
def getIndexForOutPoint(self, ms: Milliseconds) -> FrameIndex:
|
|
||||||
"""Get the frame index (path, point) based on the given time
|
|
||||||
The Out point version (so the last index before ms)
|
|
||||||
Equal to annotations.js findPositionForTime(ms)
|
|
||||||
"""
|
|
||||||
return self.getIndexForTime(ms)
|
|
||||||
|
|
||||||
def getIndexForTime(self, ms: Milliseconds) -> FrameIndex:
|
|
||||||
"""Get the frame index (path, point) based on the given time
|
|
||||||
Equal to annotations.js findPositionForTime(ms)
|
|
||||||
"""
|
|
||||||
path_i = None
|
|
||||||
point_i = None
|
|
||||||
for i, stroke in enumerate(self.strokes):
|
|
||||||
start_at = stroke.points[0].t
|
|
||||||
end_at = stroke.points[-1].t
|
|
||||||
|
|
||||||
if start_at > ms:
|
|
||||||
break # too far
|
|
||||||
if end_at > ms:
|
|
||||||
# we are getting close, find the right point_i
|
|
||||||
path_i = i
|
|
||||||
for pi, point in enumerate(stroke.points):
|
|
||||||
if point.t > ms:
|
|
||||||
break # too far
|
|
||||||
point_i = pi
|
|
||||||
break # done :-)
|
|
||||||
else:
|
|
||||||
# in case this is our last path, stroe this as
|
|
||||||
# best option thus far
|
|
||||||
path_i = i
|
|
||||||
point_i = len(stroke.points) - 1
|
|
||||||
|
|
||||||
if path_i is None or point_i is None:
|
|
||||||
logger.warn("OUT point after last stroke. Not sure if this works")
|
|
||||||
pass
|
|
||||||
return (path_i, point_i)
|
|
||||||
|
|
||||||
audiocache = {}
|
|
||||||
|
|
||||||
class AudioSlice:
|
|
||||||
def __init__(self, filename: Filename, drawing: Drawing, t_in: Milliseconds = None, t_out: Milliseconds = None, offset: Milliseconds = None):
|
|
||||||
self.filename = filename
|
|
||||||
self.drawing = drawing
|
|
||||||
self.t_in = t_in # in ms
|
|
||||||
self.t_out = t_out # in ms
|
|
||||||
self.offset = offset # in ms TODO: use from self.drawing metadata
|
|
||||||
|
|
||||||
def getSlice(self, t_in: float, t_out: float) -> AudioSlice:
|
|
||||||
return AudioSlice(self.filename, self.drawing, t_in, t_out, self.offset)
|
|
||||||
|
|
||||||
def asDict(self):
|
|
||||||
return {
|
|
||||||
"file": self.getUrl(),
|
|
||||||
# "offset": self.offset/1000
|
|
||||||
}
|
|
||||||
|
|
||||||
def getUrl(self):
|
|
||||||
fn = self.filename.replace("../files/audio", "/file/")
|
|
||||||
|
|
||||||
params = []
|
|
||||||
if self.t_in:
|
|
||||||
params.append(f"t_in={self.t_in}")
|
|
||||||
if self.t_out:
|
|
||||||
params.append(f"t_out={self.t_in}")
|
|
||||||
if len(params):
|
|
||||||
fn += "?" + "&".join(params)
|
|
||||||
return fn
|
|
||||||
|
|
||||||
async def export(self, format="mp3"):
|
|
||||||
"""Returns file descriptor of tempfile"""
|
|
||||||
# Opening file and extracting segment
|
|
||||||
start = int(self.t_in - self.offset) # millisecond precision is enough
|
|
||||||
end = int(self.t_out - self.offset) # millisecond precision is enough
|
|
||||||
|
|
||||||
# call ffmpeg directly, with given in and outpoint, so no unnecessary data is loaded, and no double conversion (e.g. ogg -> wav -> ogg ) is performed
|
|
||||||
out_f = io.BytesIO()
|
|
||||||
|
|
||||||
# build converter command to export
|
|
||||||
conversion_command = [
|
|
||||||
"ffmpeg",
|
|
||||||
'-ss', f"{start}ms",
|
|
||||||
'-to', f"{end}ms",
|
|
||||||
"-i", self.filename, # ss before input, so not whole file is loaded
|
|
||||||
]
|
|
||||||
|
|
||||||
conversion_command.extend([
|
|
||||||
"-f", format, '-', # to stdout
|
|
||||||
])
|
|
||||||
|
|
||||||
# read stdin / write stdout
|
|
||||||
logger.info("ffmpeg start")
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*conversion_command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL)
|
|
||||||
|
|
||||||
p_out, p_err = await proc.communicate()
|
|
||||||
|
|
||||||
logger.info("ffmpeg finished")
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
raise Exception(
|
|
||||||
"Encoding failed. ffmpeg/avlib returned error code: {0}\n\nCommand:{1}".format(
|
|
||||||
p.returncode, conversion_command))
|
|
||||||
|
|
||||||
out_f.write(p_out)
|
|
||||||
|
|
||||||
|
|
||||||
out_f.seek(0)
|
|
||||||
return out_f
|
|
||||||
|
|
||||||
# old way, use AudioSegment, easy but slow (reads whole ogg to wav, then export segment to ogg again)
|
|
||||||
# logger.info("loading audio")
|
|
||||||
# if self.filename in audiocache:
|
|
||||||
# song = audiocache[self.filename]
|
|
||||||
# else:
|
|
||||||
# song = AudioSegment.from_file(self.filename)
|
|
||||||
# audiocache[self.filename] = song
|
|
||||||
# logger.info("loaded audio")
|
|
||||||
|
|
||||||
# if start < 0 and end < 0:
|
|
||||||
# extract = AudioSegment.silent(
|
|
||||||
# duration=end-start, frame_rate=song.frame_rate)
|
|
||||||
# else:
|
|
||||||
# if start < 0:
|
|
||||||
# preroll = AudioSegment.silent(
|
|
||||||
# duration=start * -1, frame_rate=song.frame_rate)
|
|
||||||
# start = 0
|
|
||||||
# else:
|
|
||||||
# preroll = None
|
|
||||||
# if end > len(song):
|
|
||||||
# postroll = AudioSegment.silent(
|
|
||||||
# duration=end - len(song), frame_rate=song.frame_rate)
|
|
||||||
# end = len(song) - 1
|
|
||||||
# else:
|
|
||||||
# postroll = None
|
|
||||||
|
|
||||||
# extract = song[start: end]
|
|
||||||
# if preroll:
|
|
||||||
# extract = preroll + extract
|
|
||||||
# if postroll:
|
|
||||||
# extract += postroll
|
|
||||||
|
|
||||||
# # Saving
|
|
||||||
# return extract.export(None, format=format)
|
|
||||||
|
|
||||||
|
|
||||||
class AnnotationIndex:
|
|
||||||
def __init__(
|
|
||||||
self, filename: Filename, drawing_dir: Filename, metadata_dir: Filename
|
|
||||||
) -> None:
|
|
||||||
self.filename = filename
|
|
||||||
self.drawing_dir = drawing_dir
|
|
||||||
self.metadata_dir = metadata_dir
|
|
||||||
|
|
||||||
self.root_tag = getRootTag()
|
|
||||||
|
|
||||||
# disable disk cache because of glitches shelve.open(filename, writeback=True)
|
|
||||||
self.shelve = {}
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
logger.info("refreshing")
|
|
||||||
# reset the index
|
|
||||||
for key in list(self.shelve.keys()):
|
|
||||||
print(key)
|
|
||||||
del self.shelve[key]
|
|
||||||
|
|
||||||
self.shelve["_drawings"] = {
|
|
||||||
d.id: d
|
|
||||||
for d in [
|
|
||||||
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames()
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
self.root_tag = getRootTag()
|
|
||||||
self.shelve['_tags'] = {tag.id: [] for tag in self.root_tag.descendants}
|
|
||||||
self.shelve['_annotations'] = {}
|
|
||||||
|
|
||||||
drawing: Drawing
|
|
||||||
for drawing in self.shelve['_drawings'].values():
|
|
||||||
meta = drawing.get_metadata()
|
|
||||||
if 'annotations' not in meta:
|
|
||||||
continue
|
|
||||||
for ann in meta['annotations']:
|
|
||||||
annotation = Annotation(
|
|
||||||
ann['tag'], drawing, ann['t_in'], ann['t_out'], ann['comment'] if 'comment' in ann else "")
|
|
||||||
self.shelve['_annotations'][annotation.id] = annotation
|
|
||||||
if annotation.tag not in self.shelve['_tags']:
|
|
||||||
self.shelve['_tags'][annotation.tag] = [annotation]
|
|
||||||
logger.error(f"Use of non-existing tag {annotation.tag}")
|
|
||||||
else:
|
|
||||||
self.shelve['_tags'][annotation.tag].append(
|
|
||||||
annotation
|
|
||||||
)
|
|
||||||
tag = self.root_tag.find_by_id(annotation.tag)
|
|
||||||
if tag is not None:
|
|
||||||
tag.annotation_count += 1
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def drawings(self) -> dict[str, Drawing]:
|
|
||||||
return self.shelve["_drawings"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self) -> dict[str, list[Annotation]]:
|
|
||||||
return self.shelve["_tags"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def annotations(self) -> dict[str, Annotation]:
|
|
||||||
return self.shelve["_annotations"]
|
|
||||||
|
|
||||||
def has_tag(self, tag):
|
|
||||||
return tag in self.tags
|
|
||||||
|
|
||||||
def get_annotations_for_tag(self, tag_id) -> list[Annotation]:
|
|
||||||
if tag_id not in self.tags:
|
|
||||||
return []
|
|
||||||
return self.tags[tag_id]
|
|
||||||
|
|
||||||
def get_drawing_names(self) -> list[str]:
|
|
||||||
return [
|
|
||||||
name[:-16]
|
|
||||||
for name in os.listdir(self.drawing_dir)
|
|
||||||
if name.endswith("json_appendable") and os.stat(os.path.join(self.drawing_dir, name)).st_size > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_drawing_filenames(self) -> list[Filename]:
|
|
||||||
return [
|
|
||||||
os.path.join(self.drawing_dir, f"{name}.json_appendable")
|
|
||||||
for name in self.get_drawing_names()
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_nested_annotations_for_tag(self, tag_id) -> list[Annotation]:
|
|
||||||
tag = self.root_tag.find_by_id(tag_id)
|
|
||||||
annotations = []
|
|
||||||
for tag in tag.descendants_incl_self():
|
|
||||||
annotations.extend(self.get_annotations_for_tag(tag.id))
|
|
||||||
return annotations
|
|
||||||
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
self.shelve.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Point = tuple[float, float, float]
|
|
||||||
|
|
||||||
|
|
||||||
class Point:
|
|
||||||
def __init__(self, x: float, y: float, last: bool, t: Seconds):
|
|
||||||
self.x = float(x)
|
|
||||||
self.y = float(y) # if y == 0 it can still be integer.... odd python
|
|
||||||
self.last = last
|
|
||||||
self.t = t
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def fromTuple(cls, p: tuple[float, float, int, float]):
|
|
||||||
return cls(p[0], p[1], bool(p[2]), p[3])
|
|
||||||
|
|
||||||
def scaledToFit(self, dimensions: dict[str, float]) -> Point:
|
|
||||||
# TODO: change so that it actually scales to FIT dimensions
|
|
||||||
return Point(self.x, self.y, self.last, self.t)
|
|
||||||
|
|
||||||
def asList(self) -> list:
|
|
||||||
return [self.x, self.y, 1 if self.last else 0, self.t]
|
|
||||||
|
|
||||||
|
|
||||||
Points = list[Point]
|
|
||||||
SvgDrawing = Union[svgwrite.container.SVG, svgwrite.container.Group]
|
|
||||||
|
|
||||||
|
|
||||||
class Stroke:
|
|
||||||
def __init__(self, color: str, points: Points) -> None:
|
|
||||||
self.color = color
|
|
||||||
self.points = points
|
|
||||||
|
|
||||||
def asDict(self) -> dict:
|
|
||||||
return {"color": self.color, "points": [p.asList() for p in self.points]}
|
|
||||||
|
|
||||||
def add_to_dwg(self, dwg: SvgDrawing):
|
|
||||||
path = svgwrite.path.Path(d=self.get_as_d()).stroke(
|
|
||||||
self.color, 1).fill("none")
|
|
||||||
dwg.add(path)
|
|
||||||
|
|
||||||
# def get_bounding_box(self) -> Viewbox:
|
|
||||||
# min_x, max_x = float("inf"), float("-inf")
|
|
||||||
# min_y, max_y = float("inf"), float("-inf")
|
|
||||||
|
|
||||||
# for p in self.points:
|
|
||||||
# if p.x < min_x:
|
|
||||||
# min_x = p.x
|
|
||||||
# if p.x > max_x:
|
|
||||||
# max_x = p.x
|
|
||||||
# if p.y < min_y:
|
|
||||||
# min_y = p.y
|
|
||||||
# if p.y > max_y:
|
|
||||||
# max_y = p.y
|
|
||||||
# return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
|
|
||||||
|
|
||||||
def get_as_d(self):
|
|
||||||
d = ""
|
|
||||||
prev_point = None
|
|
||||||
cmd = ""
|
|
||||||
for point in self.points:
|
|
||||||
if not prev_point:
|
|
||||||
# TODO multiply points by scalars for dimensions (height widht of drawing)
|
|
||||||
d += f'M{point.x:.6},{point.y:.6} '
|
|
||||||
cmd = 'M'
|
|
||||||
else:
|
|
||||||
if prev_point.last:
|
|
||||||
d += " m"
|
|
||||||
cmd = "m"
|
|
||||||
elif cmd != 'l':
|
|
||||||
d += ' l '
|
|
||||||
cmd = 'l'
|
|
||||||
diff_point = {
|
|
||||||
"x": point.x - prev_point.x,
|
|
||||||
"y": point.y - prev_point.y,
|
|
||||||
}
|
|
||||||
# TODO multiply points by scalars for dimensions (height widht of drawing)
|
|
||||||
d += f'{diff_point["x"]:.6},{diff_point["y"]:.6} '
|
|
||||||
prev_point = point
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
class StrokeSlice(Stroke):
|
|
||||||
def __init__(self, stroke: Stroke, i_in: int = None, i_out: int = None, t_offset: Seconds = 0) -> None:
|
|
||||||
self.stroke = stroke
|
|
||||||
self.i_in = 0 if i_in is None else i_in
|
|
||||||
self.i_out = len(self.stroke.points) - 1 if i_out is None else i_out
|
|
||||||
|
|
||||||
# deepcopy points, because slices can be offset in time
|
|
||||||
self.points = copy.deepcopy(self.stroke.points[self.i_in: self.i_out + 1])
|
|
||||||
for p in self.points:
|
|
||||||
p.t -= t_offset
|
|
||||||
|
|
||||||
def slice_id(self):
|
|
||||||
return f"{self.i_in}-{self.i_out}"
|
|
||||||
|
|
||||||
# @property
|
|
||||||
# def points(self) -> Points:
|
|
||||||
# return self.stroke.points[self.i_in: self.i_out + 1]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def color(self) -> str:
|
|
||||||
return self.stroke.color
|
|
||||||
|
|
||||||
|
|
||||||
def strokes2D(strokes):
|
|
||||||
# strokes to a d attribute for a path
|
|
||||||
d = ""
|
|
||||||
last_stroke = None
|
|
||||||
cmd = ""
|
|
||||||
for stroke in strokes:
|
|
||||||
if not last_stroke:
|
|
||||||
d += f"M{stroke[0]},{stroke[1]} "
|
|
||||||
cmd = 'M'
|
|
||||||
else:
|
|
||||||
if last_stroke[2] == 1:
|
|
||||||
d += " m"
|
|
||||||
cmd = 'm'
|
|
||||||
elif cmd != 'l':
|
|
||||||
d += ' l '
|
|
||||||
cmd = 'l'
|
|
||||||
|
|
||||||
rel_stroke = [stroke[0] - last_stroke[0],
|
|
||||||
stroke[1] - last_stroke[1]]
|
|
||||||
d += f"{rel_stroke[0]},{rel_stroke[1]} "
|
|
||||||
last_stroke = stroke
|
|
||||||
return d
|
|
||||||
|
|
||||||
class Tag(NodeMixin):
|
|
||||||
def __init__(self, id, name = None, description = "", color = None, parent=None, children=None, annotation_count=None):
|
|
||||||
self.id = id
|
|
||||||
self.name = self.id if name is None else name
|
|
||||||
self.color = color
|
|
||||||
self.description = description
|
|
||||||
self.parent = parent
|
|
||||||
if children:
|
|
||||||
self.children = children
|
|
||||||
|
|
||||||
self.annotation_count = 0 #always zero!
|
|
||||||
|
|
||||||
if self.id == 'root' and not self.is_root:
|
|
||||||
logger.error("Root node shouldn't have a parent assigned")
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<svganim.strokes.Tag {self.id}>"
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return RenderTree(self).by_attr('name')
|
|
||||||
|
|
||||||
def get_color(self):
|
|
||||||
if self.color is None and self.parent is not None:
|
|
||||||
return self.parent.get_color()
|
|
||||||
return self.color
|
|
||||||
|
|
||||||
def descendants_incl_self(self):
|
|
||||||
return tuple(iterators.PreOrderIter(self))
|
|
||||||
|
|
||||||
def find_by_id(self, tag_id) -> Optional[Tag]:
|
|
||||||
for t in self.descendants:
|
|
||||||
if t.id == tag_id:
|
|
||||||
return t
|
|
||||||
return None
|
|
||||||
|
|
||||||
def toJson(self, with_counts=False) -> str:
|
|
||||||
ignore_counts=lambda attrs: [(k, v) for k, v in attrs if k != "annotation_count"]
|
|
||||||
attrFilter = None if with_counts else ignore_counts
|
|
||||||
|
|
||||||
return JsonExporter(DictExporter(attriter=attrFilter), indent=2).export(self)
|
|
||||||
|
|
||||||
def loadTagFromJson(string) -> Tag:
|
|
||||||
tree: Tag = JsonImporter(DictImporter(Tag)).import_(string)
|
|
||||||
return tree
|
|
||||||
|
|
||||||
def getRootTag(file = 'www/tags.json') -> Tag:
|
|
||||||
with open(file, 'r') as fp:
|
|
||||||
tree: Tag = JsonImporter(DictImporter(Tag)).read(fp)
|
|
||||||
|
|
||||||
return tree
|
|
||||||
|
|
||||||
# print(RenderTree(tree))
|
|
|
@ -1,8 +0,0 @@
|
||||||
from hashlib import md5
|
|
||||||
|
|
||||||
|
|
||||||
def annotation_hash(handler=None, input =""):
|
|
||||||
return md5(input.encode()).hexdigest()
|
|
||||||
|
|
||||||
# def nmbr(handler, lst) -> int:
|
|
||||||
# leno
|
|
|
@ -1,201 +0,0 @@
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<title>Annotations</title>
|
|
||||||
<link rel="stylesheet" href="svganim.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background: rgb(39, 40, 41);
|
|
||||||
font-family: sans-serif;
|
|
||||||
color: white
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags {
|
|
||||||
font-size: 80%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags li {
|
|
||||||
line-height: 1.5;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags .tag-id-root {
|
|
||||||
list-style: none;
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags .tag-id-root>ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags li>div,
|
|
||||||
#tags li.add-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags .tag-id-root>div {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* #tags li:hover>ul>li.add-tag {
|
|
||||||
visibility: visible;
|
|
||||||
;
|
|
||||||
} */
|
|
||||||
#tags li.selected>ul>li.add-tag {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags>li>ul>li.add-tag {
|
|
||||||
display: block;
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
#annotations .annotations-actions {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: darkgray;
|
|
||||||
font-size: 80%;
|
|
||||||
border-bottom: solid 3px #444;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: rgb(39, 40, 41);
|
|
||||||
padding: 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#tags .add-tag {
|
|
||||||
display: none;
|
|
||||||
;
|
|
||||||
color: lightgray;
|
|
||||||
font-size: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags input[type="color"] {
|
|
||||||
cursor: pointer;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
padding: 0;
|
|
||||||
border: solid 1px black;
|
|
||||||
border-radius: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags li:hover>div>input.rm-tag {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags li div {
|
|
||||||
position: relative
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags input.rm-tag:hover {
|
|
||||||
color: red;
|
|
||||||
transform: rotate(20deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags input.rm-tag {
|
|
||||||
/* display: none; */
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags .selected>div {
|
|
||||||
background: lightblue
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
summary h2 {
|
|
||||||
display: inline-block;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[open] summary {
|
|
||||||
color: rgb(224, 196, 196);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* details ul{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
details[open] ul{
|
|
||||||
display: block;;
|
|
||||||
} */
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#annotation_manager {
|
|
||||||
display: grid;
|
|
||||||
gap: 20px;
|
|
||||||
grid-template-columns: 200px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tags {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#annotations {
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#annotations ul {
|
|
||||||
grid-template-columns: repeat(auto-fill, 320px);
|
|
||||||
grid-gap: 20px;
|
|
||||||
display: grid;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#annotations li {}
|
|
||||||
|
|
||||||
#annotations img {
|
|
||||||
/* width: 400px; */
|
|
||||||
background: white;
|
|
||||||
width: 300px;
|
|
||||||
height: 200px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#annotations .svganim_player,
|
|
||||||
#annotations annotation-player {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 300px;
|
|
||||||
height: 200px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<script src="assets/nouislider-15.5.0.js"></script>
|
|
||||||
<script src="assets/wNumb-1.2.0.min.js"></script>
|
|
||||||
<script src="annotate.js"></script>
|
|
||||||
<script src="annotations.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="annotation_manager">
|
|
||||||
<ul id="tags"></ul>
|
|
||||||
<div id="annotations"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<a href="?refresh=1">Reload index</a>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
const am = new AnnotationManager(document.getElementById('annotation_manager'), 'tags.json');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,168 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" dir="ltr">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Annotate a line animation</title>
|
|
||||||
<link rel="stylesheet" href="svganim.css">
|
|
||||||
<style media="screen">
|
|
||||||
body {
|
|
||||||
/* background: black;
|
|
||||||
color: white */
|
|
||||||
background: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.player{
|
|
||||||
background: rgb(39, 40, 41);;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sample,
|
|
||||||
svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
left: 20px;
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
height: calc(100% - 200px);
|
|
||||||
font-family: sans-serif;
|
|
||||||
z-index: 2;
|
|
||||||
/* background: white; */
|
|
||||||
/* border: solid 2px lightgray; */
|
|
||||||
}
|
|
||||||
|
|
||||||
body.player svg{
|
|
||||||
height: calc(100% - 40px);
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
#wrapper {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.playlist img {
|
|
||||||
position: static;
|
|
||||||
width: 250px;
|
|
||||||
height: 250px;
|
|
||||||
background: white;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.playlist .title{
|
|
||||||
display: block;
|
|
||||||
font-weight: bold;;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 10px;
|
|
||||||
left: 220px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 6pt;
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.annotator) .help{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help li {
|
|
||||||
display: inline-block;
|
|
||||||
color: gray;
|
|
||||||
margin-right: 10px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help .key {
|
|
||||||
padding: 2px;
|
|
||||||
background-color: aliceblue;
|
|
||||||
border: solid 1px black;
|
|
||||||
color: black;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#interface:not(.selected-annotation) .help .esc1 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#interface.selected-annotation .help .esc2 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<link rel="stylesheet" href="assets/nouislider-15.5.0.css">
|
|
||||||
<link rel="stylesheet" href="core.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id='interface'>
|
|
||||||
<ul class="help">
|
|
||||||
<li><span class='key'>Space</span> play/pause</li>
|
|
||||||
<li><span class='key'>Shift</span> + <span class='key'>→</span> Skip 1s</li>
|
|
||||||
<li><span class='key'>Shift</span> + <span class='key'>Ctrl</span> + <span class='key'>→</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'>← / →</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
1873
app/www/annotate.js
File diff suppressed because it is too large
Load diff
|
@ -1,421 +0,0 @@
|
||||||
class AnnotationManager {
|
|
||||||
constructor(rootEl, tagsUrl) {
|
|
||||||
this.rootEl = rootEl;
|
|
||||||
|
|
||||||
|
|
||||||
this.tagsEl = this.rootEl.querySelector('#tags');
|
|
||||||
this.annotationsEl = this.rootEl.querySelector('#annotations');
|
|
||||||
|
|
||||||
this.selectedAnnotations = [];
|
|
||||||
this.selectedTag = null;
|
|
||||||
this.tagsUrl = tagsUrl;
|
|
||||||
this.loadTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTags() {
|
|
||||||
// tags config
|
|
||||||
const request = new Request(this.tagsUrl);
|
|
||||||
return fetch(request)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(rootTag => {
|
|
||||||
this.rootTag = Tag.from(rootTag);
|
|
||||||
this.buildTagList()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTagList() {
|
|
||||||
// build, and rebuild
|
|
||||||
this.tagsEl.innerHTML = "";
|
|
||||||
|
|
||||||
const addTag = (tag, parentEl) => {
|
|
||||||
const tagLiEl = document.createElement('li');
|
|
||||||
const tagSubEl = document.createElement('ul');
|
|
||||||
const tagEl = document.createElement('div');
|
|
||||||
|
|
||||||
tagEl.innerText = `${tag.get_name()} (${tag.annotation_count ?? 0})`;
|
|
||||||
tagEl.addEventListener('click', (ev) => {
|
|
||||||
this.selectTag(tag);
|
|
||||||
})
|
|
||||||
tagEl.addEventListener('dblclick', (ev) => {
|
|
||||||
this.renameTag(tag);
|
|
||||||
})
|
|
||||||
|
|
||||||
const colorEl = document.createElement('input');
|
|
||||||
colorEl.type = 'color';
|
|
||||||
colorEl.value = tag.get_color();
|
|
||||||
colorEl.addEventListener('change', (ev) => {
|
|
||||||
this.setTagColor(tag, ev.target.value);
|
|
||||||
});
|
|
||||||
tagEl.prepend(colorEl)
|
|
||||||
|
|
||||||
const rmEl = document.createElement('input');
|
|
||||||
rmEl.type = 'button';
|
|
||||||
rmEl.classList.add('rm-tag');
|
|
||||||
rmEl.value = '🗑';
|
|
||||||
rmEl.addEventListener('click', (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.removeTag(tag);
|
|
||||||
});
|
|
||||||
tagEl.appendChild(rmEl)
|
|
||||||
|
|
||||||
tagLiEl.classList.add('tag-id-' + tag.id);
|
|
||||||
tagLiEl.appendChild(tagEl);
|
|
||||||
|
|
||||||
tag.menuLiEl = tagLiEl;
|
|
||||||
|
|
||||||
tagLiEl.appendChild(tagSubEl);
|
|
||||||
tag.children.forEach((tag) => addTag(tag, tagSubEl));
|
|
||||||
const tagAddSubEl = document.createElement('li');
|
|
||||||
tagAddSubEl.classList.add('add-tag')
|
|
||||||
tagAddSubEl.innerText = 'add tag';
|
|
||||||
tagAddSubEl.addEventListener('click', (ev) => {
|
|
||||||
const name = prompt(`Add a tag under '${tag.get_name()}':`);
|
|
||||||
if (name === null || name.length < 1) return //cancel
|
|
||||||
this.addTag(name, tag);
|
|
||||||
});
|
|
||||||
tagSubEl.appendChild(tagAddSubEl);
|
|
||||||
|
|
||||||
|
|
||||||
parentEl.appendChild(tagLiEl);
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
addTag(this.rootTag, this.tagsEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
selectTag(tag) {
|
|
||||||
this.clearSelectedTag();
|
|
||||||
this.selectedTag = tag;
|
|
||||||
tag.menuLiEl.classList.add('selected');
|
|
||||||
this.loadAnnotationsForTag(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSelectedTag() {
|
|
||||||
this.selectedTag = null;
|
|
||||||
const selected = this.tagsEl.querySelectorAll('.selected');
|
|
||||||
selected.forEach((s) => s.classList.remove('selected'));
|
|
||||||
this.resetSelectedAnnotations()
|
|
||||||
//TODO empty annotationEl
|
|
||||||
this.annotationsEl.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAnnotation() {
|
|
||||||
throw new Error("Not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
addTag(name, parentTag) {
|
|
||||||
const tag = new Tag();
|
|
||||||
tag.id = crypto.randomUUID();
|
|
||||||
tag.name = name;
|
|
||||||
tag.parent = parentTag;
|
|
||||||
parentTag.children.push(tag);
|
|
||||||
|
|
||||||
this.buildTagList();
|
|
||||||
this.saveTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAnnotationsForTag(tag) {
|
|
||||||
this.annotationsEl.innerHTML = "";
|
|
||||||
|
|
||||||
const request = new Request("/tags/" + tag.id);
|
|
||||||
return fetch(request)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(annotations => {
|
|
||||||
this.annotations = annotations;
|
|
||||||
this.buildAnnotationList()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buildAnnotationList() {
|
|
||||||
this.annotationsEl.innerHTML = "";
|
|
||||||
|
|
||||||
this.actionsEl = document.createElement('div');
|
|
||||||
this.actionsEl.classList.add('annotations-actions')
|
|
||||||
|
|
||||||
this.buildAnnotationActions();
|
|
||||||
|
|
||||||
this.annotationsEl.appendChild(this.actionsEl);
|
|
||||||
|
|
||||||
|
|
||||||
const ulEl = document.createElement('ul');
|
|
||||||
this.annotations.forEach((annotation, idx) => {
|
|
||||||
const liEl = document.createElement('li');
|
|
||||||
const playerEl = new AnnotationPlayer(); //document.createElement('annotation-player');
|
|
||||||
playerEl.setAnnotation(annotation);
|
|
||||||
|
|
||||||
const selectEl = document.createElement('input');
|
|
||||||
selectEl.type = 'checkbox';
|
|
||||||
selectEl.id = 'select-' + annotation.id_hash;
|
|
||||||
selectEl.addEventListener('change', (ev) => {
|
|
||||||
if (ev.target.checked) {
|
|
||||||
this.addAnnotationToSelection(annotation);
|
|
||||||
} else {
|
|
||||||
this.removeAnnotationFromSelection(annotation);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const tag = this.rootTag.find_by_id(annotation.tag);
|
|
||||||
console.log(tag)
|
|
||||||
|
|
||||||
const infoEl = document.createElement('span');
|
|
||||||
infoEl.classList.add('annotation-info');
|
|
||||||
infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`;
|
|
||||||
|
|
||||||
const downloadEl = document.createElement('a');
|
|
||||||
downloadEl.href = annotation.url.replace('files', 'export');
|
|
||||||
downloadEl.innerHTML = '↓';
|
|
||||||
|
|
||||||
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 ' '.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Drawing & Annotating Path Animations</title>
|
|
||||||
<style>
|
|
||||||
body{
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
a{
|
|
||||||
line-height: 2;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover{
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Diagrams</h1>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/index">Tags</a></li>
|
|
||||||
<li><a href="/annotate.html">Drawings</a></li>
|
|
||||||
<li><a href="/draw.html">Draw</a></li>
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,119 +0,0 @@
|
||||||
class Playlist {
|
|
||||||
constructor(wrapperEl, url) {
|
|
||||||
this.wrapperEl = wrapperEl;
|
|
||||||
|
|
||||||
this.onlyWithTitle = true;
|
|
||||||
|
|
||||||
const request = new Request(url, {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch(request)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
this.files = data;
|
|
||||||
this.buildList()
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
buildList() {
|
|
||||||
let playlist = this.wrapperEl.querySelector('.playlist');
|
|
||||||
if (!playlist) {
|
|
||||||
playlist = document.createElement('nav');
|
|
||||||
playlist.classList.add('playlist');
|
|
||||||
this.wrapperEl.appendChild(playlist)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
playlist.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterEl = document.createElement('label');
|
|
||||||
filterEl.classList.add('filter');
|
|
||||||
filterEl.innerText = "Show only diagrams with title"
|
|
||||||
const filterCheckEl = document.createElement('input');
|
|
||||||
filterCheckEl.type = "checkbox";
|
|
||||||
filterCheckEl.checked = this.onlyWithTitle;
|
|
||||||
filterCheckEl.addEventListener('click', (ev) => {
|
|
||||||
this.onlyWithTitle = ev.target.checked;
|
|
||||||
this.buildList()
|
|
||||||
})
|
|
||||||
filterEl.appendChild(filterCheckEl)
|
|
||||||
|
|
||||||
playlist.appendChild(filterEl)
|
|
||||||
|
|
||||||
const listEl = document.createElement("ul");
|
|
||||||
for (let file of this.files) {
|
|
||||||
|
|
||||||
if(this.onlyWithTitle && file.title === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const liEl = document.createElement("li");
|
|
||||||
|
|
||||||
const imgEl = document.createElement("img");
|
|
||||||
imgEl.classList.add('img');
|
|
||||||
imgEl.title = file.id;
|
|
||||||
imgEl.src = file.svg;
|
|
||||||
liEl.append(imgEl);
|
|
||||||
|
|
||||||
if (file.title) {
|
|
||||||
const titleEl = document.createElement("span");
|
|
||||||
titleEl.classList.add('title');
|
|
||||||
titleEl.innerText = file.title;
|
|
||||||
liEl.append(titleEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let time = file.mtime;
|
|
||||||
if (file.ctime != file.mtime) {
|
|
||||||
time += ` (orig: ${file.ctime})`;
|
|
||||||
}
|
|
||||||
const dateEl = document.createElement("span");
|
|
||||||
dateEl.classList.add('date');
|
|
||||||
dateEl.innerText = time;
|
|
||||||
liEl.append(dateEl);
|
|
||||||
|
|
||||||
|
|
||||||
const nameEl = document.createElement("span");
|
|
||||||
nameEl.classList.add('name');
|
|
||||||
nameEl.innerText = file.name;
|
|
||||||
liEl.append(nameEl);
|
|
||||||
|
|
||||||
const linksEl = document.createElement("span");
|
|
||||||
linksEl.classList.add('links');
|
|
||||||
liEl.append(linksEl);
|
|
||||||
|
|
||||||
const playEl = document.createElement("a");
|
|
||||||
playEl.classList.add('play');
|
|
||||||
playEl.innerText = "Play";
|
|
||||||
playEl.href = location;
|
|
||||||
playEl.pathname = "annotate.html";
|
|
||||||
playEl.search = "?file=" + file.name + "&player=1";
|
|
||||||
linksEl.append(playEl);
|
|
||||||
|
|
||||||
const annotateEl = document.createElement("a");
|
|
||||||
annotateEl.classList.add('annotate');
|
|
||||||
annotateEl.innerText = "Annotate";
|
|
||||||
annotateEl.href = location;
|
|
||||||
annotateEl.pathname = "annotate.html";
|
|
||||||
annotateEl.search = "?file=" + file.name;
|
|
||||||
linksEl.append(annotateEl);
|
|
||||||
|
|
||||||
const drawEl = document.createElement("a");
|
|
||||||
drawEl.classList.add('draw');
|
|
||||||
drawEl.innerText = "Draw";
|
|
||||||
drawEl.href = location;
|
|
||||||
drawEl.pathname = "draw.html";
|
|
||||||
drawEl.hash = file.id;
|
|
||||||
linksEl.append(drawEl);
|
|
||||||
|
|
||||||
// liEl.addEventListener('click', (e) => {
|
|
||||||
// this.play(fileUrl);
|
|
||||||
// playlist.style.display = "none";
|
|
||||||
// });
|
|
||||||
listEl.appendChild(liEl);
|
|
||||||
}
|
|
||||||
playlist.appendChild(listEl);
|
|
||||||
// do something with the data sent in the request
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,336 +0,0 @@
|
||||||
svg .background {
|
|
||||||
fill: white
|
|
||||||
}
|
|
||||||
|
|
||||||
path {
|
|
||||||
fill: none;
|
|
||||||
stroke: gray;
|
|
||||||
stroke-width: 1mm;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
g.before path {
|
|
||||||
opacity: 0.5;
|
|
||||||
stroke: gray !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
g.after path,
|
|
||||||
path.before_in {
|
|
||||||
opacity: .1;
|
|
||||||
stroke: gray !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gray {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.controls--playback {
|
|
||||||
/* display:flex; */
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timecode {
|
|
||||||
position: absolute;
|
|
||||||
right: 100%;
|
|
||||||
width: 5%;
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls--playback input[type='range'] {
|
|
||||||
/* position: absolute;
|
|
||||||
z-index: 100;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0; */
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button.paused,
|
|
||||||
.controls button.playing {
|
|
||||||
position: absolute;
|
|
||||||
left: 100%;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button.paused::before {
|
|
||||||
content: '⏵';
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button.playing::before {
|
|
||||||
content: '⏸';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.loading .controls button:is(.playing, .paused)::before {
|
|
||||||
content: '↺';
|
|
||||||
display: inline-block;
|
|
||||||
animation: rotate 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
0% {
|
|
||||||
transform: rotate(359deg)
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(0deg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
position: absolute !important;
|
|
||||||
z-index: 100;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 5%;
|
|
||||||
right: 0;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrubber {}
|
|
||||||
|
|
||||||
.tags {
|
|
||||||
line-height: 1.5;
|
|
||||||
/* display: flex;
|
|
||||||
flex-direction: row; */
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .tag {
|
|
||||||
display: block;
|
|
||||||
padding: 5px;
|
|
||||||
border: solid 1px darkgray;
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags li {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .subtags {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 80%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .subtags .tag {
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .tag:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
background: darkgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .tag.selected {
|
|
||||||
background: #3FB8AF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .tag.annotation-rm {
|
|
||||||
/* display: none; */
|
|
||||||
overflow: hidden;
|
|
||||||
color: red;
|
|
||||||
font-size: 30px;
|
|
||||||
width: 0;
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 5px 0;
|
|
||||||
transition: width .3s;
|
|
||||||
pointer-events: none;
|
|
||||||
border: none;
|
|
||||||
direction: rtl;
|
|
||||||
/* hide behind bar, instead into nothing */
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-annotation .tags .tag.annotation-rm {
|
|
||||||
color: red;
|
|
||||||
display: block;
|
|
||||||
width: 30px;
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .annotation-comment {
|
|
||||||
width: 100%;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-annotation .controls .annotation-comment {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noUi-handle:focus {
|
|
||||||
/* background: red;; */
|
|
||||||
border: solid 2px #601be0;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .noUi-handle:focus::before, .noUi-handle:focus::after{
|
|
||||||
background: #601be0;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.tags .tag span {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-right: 10px;
|
|
||||||
vertical-align: middle;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags .subtags .tag span {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin-right: 2px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotations {
|
|
||||||
height: 30px;
|
|
||||||
/* border: solid 1px darkgray; */
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotations>div {
|
|
||||||
opacity: .4;
|
|
||||||
background: lightseagreen;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annotations>div:hover,
|
|
||||||
.annotations>div.selected {
|
|
||||||
opacity: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsaved::before {
|
|
||||||
content: '*';
|
|
||||||
color: red;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 30px;
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved::before {
|
|
||||||
content: '\2713';
|
|
||||||
display: inline-block;
|
|
||||||
color: green;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 30px;
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noUi-horizontal .noUi-touch-area {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
#interface .noUi-horizontal .noUi-tooltip {
|
|
||||||
/* tooltips go outside of the buttons */
|
|
||||||
bottom: auto;
|
|
||||||
left: 110%;
|
|
||||||
transform: none;
|
|
||||||
top: -2px;
|
|
||||||
}
|
|
||||||
#interface .noUi-horizontal .noUi-handle-lower .noUi-tooltip {
|
|
||||||
left: auto;
|
|
||||||
right: 110%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadataconfig {
|
|
||||||
z-index: 9;
|
|
||||||
background: black;
|
|
||||||
color: white;
|
|
||||||
position: relative;
|
|
||||||
/* width: 100px; */
|
|
||||||
/* as wide as audio controls only */
|
|
||||||
overflow:visible;
|
|
||||||
/* white-space: nowrap; */
|
|
||||||
/* left: -50px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadataconfig .drawing-title{
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadataconfig .audioconfig{
|
|
||||||
display: none;;
|
|
||||||
background-color: black;;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadataconfig:hover .audioconfig{
|
|
||||||
/* width: auto;
|
|
||||||
left: 0px;
|
|
||||||
overflow: visible;
|
|
||||||
height: 200px; */
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audioconfig select,
|
|
||||||
.audioconfig input {
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
audio {
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 100px;
|
|
||||||
/* hides seek head */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.svganim_annotator {
|
|
||||||
display: grid;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
grid-template-rows: 40px 1fr 100px;
|
|
||||||
grid-template-columns: 200px auto;
|
|
||||||
gap: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svganim_annotator svg {
|
|
||||||
top: auto;
|
|
||||||
background: white;
|
|
||||||
margin-left: 0;
|
|
||||||
position: static;
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svganim_annotator .tags {
|
|
||||||
grid-area: 2/1/4/1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
.svganim_annotator .audioconfig {
|
|
||||||
position: static;
|
|
||||||
grid-area: 1/1/1/1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svganim_annotator > svg {
|
|
||||||
grid-area: 1/2/3/2;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.svganim_annotator .controls {
|
|
||||||
grid-area: 3/2/3/2;
|
|
||||||
margin-left: 5%;
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
|
@ -1,321 +0,0 @@
|
||||||
{
|
|
||||||
"id": "root",
|
|
||||||
"name": "root",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 0,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "human-machine",
|
|
||||||
"name": "Human/machine Entanglements (Appearance/disappearance)",
|
|
||||||
"color": "#ffa348",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 7,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "vision",
|
|
||||||
"name": "Vision",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "sound",
|
|
||||||
"name": "sound",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "behaviour",
|
|
||||||
"name": "behaviour",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "other-senses",
|
|
||||||
"name": "Other senses",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tensions",
|
|
||||||
"name": "Tensions, contestations & problems",
|
|
||||||
"color": "#77767b",
|
|
||||||
"description": "Which problems are identified?, when do they become problems?",
|
|
||||||
"annotation_count": 28
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "security",
|
|
||||||
"name": "Security & types of data",
|
|
||||||
"color": "#3584e4",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 14,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "definitions",
|
|
||||||
"name": "definitions",
|
|
||||||
"color": null,
|
|
||||||
"description": "e.g. domain knowledge",
|
|
||||||
"annotation_count": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "input",
|
|
||||||
"name": "input",
|
|
||||||
"color": null,
|
|
||||||
"description": "e.g. fake data",
|
|
||||||
"annotation_count": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "actants",
|
|
||||||
"name": "Actants in relation",
|
|
||||||
"color": "#fa08ff",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 15,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "algorithm",
|
|
||||||
"name": "algorithm",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 11
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "technologies",
|
|
||||||
"name": "technologies",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "frt",
|
|
||||||
"name": "frt",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cameras",
|
|
||||||
"name": "CCTV & camera's",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "entities",
|
|
||||||
"name": "Entities: people, institutions etc.",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "positioning",
|
|
||||||
"name": "Positioning",
|
|
||||||
"color": null,
|
|
||||||
"description": "the positioning of a field/person/oneself in relation to others",
|
|
||||||
"annotation_count": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "inside-outside",
|
|
||||||
"name": "inside-outside",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "public-private",
|
|
||||||
"name": "public-private",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "consequences",
|
|
||||||
"name": "consequences",
|
|
||||||
"color": "#0add32",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 5,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "effects",
|
|
||||||
"name": "effects",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "future-imaginaries",
|
|
||||||
"name": "future-imaginaries",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 14
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "speculations",
|
|
||||||
"name": "speculations",
|
|
||||||
"color": null,
|
|
||||||
"description": "what is & what will/can be done.",
|
|
||||||
"annotation_count": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "innovations",
|
|
||||||
"name": "innovations",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "hesitation",
|
|
||||||
"name": "Hesitations & corrections",
|
|
||||||
"color": "#f8e45c",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 24
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "skip",
|
|
||||||
"name": "skip",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 18
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "todo",
|
|
||||||
"name": "to do / interesting",
|
|
||||||
"color": "#ff0000",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "6f9a83a1-e374-4dc9-a6c4-39c5461bb435",
|
|
||||||
"name": "Threat & Risk - What is being protected by SV",
|
|
||||||
"color": "#813d9c",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 22
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "3fda0504-00ff-485e-9faf-ca7d08e4e96c",
|
|
||||||
"name": "Errors & Glitches (having it wrong)",
|
|
||||||
"color": "#986a44",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 11,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "0f758538-ea27-424f-bc0a-d6a68837832d",
|
|
||||||
"name": "FPR",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "6f10e893-2fcf-4c72-9480-04cad5612cc2",
|
|
||||||
"name": "Consequences of an error",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "63bd867b-d7d4-4247-b6a6-9157ab24cff8",
|
|
||||||
"name": "Sites of SV",
|
|
||||||
"color": "#99c1f1",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 22,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "0fd34742-7582-42ec-96a5-ee83ad57fbb2",
|
|
||||||
"name": "Public space",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 6
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bd8ab328-85e7-44d2-bfee-2ad951587116",
|
|
||||||
"name": "Temporality",
|
|
||||||
"color": "#ffff00",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 9,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "a20fb815-db9a-4704-8ac1-92a5f8a5497b",
|
|
||||||
"name": "Static images",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ac8e247a-1ed2-452c-af6f-2b345706e3eb",
|
|
||||||
"name": "Technicalities of time",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "39f4a6fe-9c15-4417-bce7-8be9ef791bf2",
|
|
||||||
"name": "Humans & Bodies",
|
|
||||||
"color": "#dc8add",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 13,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "b38c9a6a-837f-4301-9f82-69d9c22438c9",
|
|
||||||
"name": "Absences of bodies",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "89a1924c-ec49-460e-9b79-789237b23429",
|
|
||||||
"name": "Human-in-the-loop",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 14
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "e906230f-332e-4bd4-8939-936baa05acc5",
|
|
||||||
"name": "The human as a crowd",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0a37911b-a458-491c-9145-534121d5fc92",
|
|
||||||
"name": "Solutions",
|
|
||||||
"color": "#f6f5f4",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 9,
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "dce213cb-1fc6-4a20-9cb2-82862119fa5d",
|
|
||||||
"name": "Juridical/Law",
|
|
||||||
"color": null,
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 7
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "cfcef8bd-219e-4650-bb1c-c3d3b40ce6c1",
|
|
||||||
"name": "Incentives for SV",
|
|
||||||
"color": "#a51d2d",
|
|
||||||
"description": "",
|
|
||||||
"annotation_count": 5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
<VirtualHost *:80>
|
|
||||||
# This is the HTTP example, ideally use Certbot/LetsEncrypt to encrypt the data transferred
|
|
||||||
Servername diagramming.WEBSITE
|
|
||||||
|
|
||||||
<Location />
|
|
||||||
# Optionally use some basic auth to prevent access to anyone.
|
|
||||||
AuthType Basic
|
|
||||||
Authname "Password Required"
|
|
||||||
AuthUserFile /etc/apache2/.htpasswd
|
|
||||||
Require valid-user
|
|
||||||
</Location>
|
|
||||||
|
|
||||||
|
|
||||||
RewriteEngine On
|
|
||||||
|
|
||||||
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
|
||||||
RewriteRule /(.*) ws://localhost:7890/$1 [P,L]
|
|
||||||
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
|
|
||||||
RewriteRule /(.*) http://localhost:7890/$1 [P,L]
|
|
||||||
|
|
||||||
|
|
||||||
ProxyPass / http://localhost:7890/
|
|
||||||
ProxyPassReverse / http://localhost:7890/
|
|
||||||
ProxyPreserveHost On
|
|
||||||
|
|
||||||
</VirtualHost>
|
|
|
@ -1,13 +0,0 @@
|
||||||
version: '3'
|
|
||||||
services:
|
|
||||||
svganim:
|
|
||||||
image: svganim
|
|
||||||
restart: always
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
# entrypoint: "/bin/sh -c \"poetry run python webserver.py --storage /files\""
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:7890:7890"
|
|
||||||
volumes:
|
|
||||||
- ./files:/files
|
|
BIN
docs/Figure1.png
BIN
docs/Figure1.png
Binary file not shown.
Before Width: | Height: | Size: 115 KiB |
405
poetry.lock
generated
405
poetry.lock
generated
|
@ -1,405 +0,0 @@
|
||||||
[[package]]
|
|
||||||
name = "anytree"
|
|
||||||
version = "2.8.0"
|
|
||||||
description = "Powerful and Lightweight Python Tree Data Structure.."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
six = ">=1.9.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["check-manifest"]
|
|
||||||
test = ["coverage"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cairocffi"
|
|
||||||
version = "1.3.0"
|
|
||||||
description = "cffi-based cairo bindings for Python"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cffi = ">=1.1.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"]
|
|
||||||
xcb = ["xcffib (>=0.3.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cairosvg"
|
|
||||||
version = "2.5.2"
|
|
||||||
description = "A Simple SVG Converter based on Cairo"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
cairocffi = "*"
|
|
||||||
cssselect2 = "*"
|
|
||||||
defusedxml = "*"
|
|
||||||
pillow = "*"
|
|
||||||
tinycss2 = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
test = ["pytest-runner", "pytest-cov", "pytest-flake8", "pytest-isort"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cffi"
|
|
||||||
version = "1.15.0"
|
|
||||||
description = "Foreign Function Interface for Python calling C code."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pycparser = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "coloredlogs"
|
|
||||||
version = "15.0.1"
|
|
||||||
description = "Colored terminal output for Python's logging module"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
humanfriendly = ">=9.1"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
cron = ["capturer (>=2.4)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cssselect2"
|
|
||||||
version = "0.6.0"
|
|
||||||
description = "CSS selectors for Python ElementTree"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
tinycss2 = "*"
|
|
||||||
webencodings = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "defusedxml"
|
|
||||||
version = "0.7.1"
|
|
||||||
description = "XML bomb protection for Python stdlib modules"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "filelock"
|
|
||||||
version = "3.7.1"
|
|
||||||
description = "A platform independent file lock."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
|
|
||||||
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "humanfriendly"
|
|
||||||
version = "10.0"
|
|
||||||
description = "Human friendly output for text interfaces using Python"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pillow"
|
|
||||||
version = "9.1.1"
|
|
||||||
description = "Python Imaging Library (Fork)"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"]
|
|
||||||
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycparser"
|
|
||||||
version = "2.21"
|
|
||||||
description = "C parser in Python"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydub"
|
|
||||||
version = "0.25.1"
|
|
||||||
description = "Manipulate audio with an simple and easy high level interface"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyreadline3"
|
|
||||||
version = "3.4.1"
|
|
||||||
description = "A python implementation of GNU readline."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.16.0"
|
|
||||||
description = "Python 2 and 3 compatibility utilities"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "svgwrite"
|
|
||||||
version = "1.4.2"
|
|
||||||
description = "A Python library to create SVG drawings."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinycss2"
|
|
||||||
version = "1.1.1"
|
|
||||||
description = "A tiny CSS parser"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
webencodings = ">=0.4"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
|
||||||
test = ["pytest", "pytest-cov", "pytest-flake8", "pytest-isort", "coverage"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tornado"
|
|
||||||
version = "6.1"
|
|
||||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">= 3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webencodings"
|
|
||||||
version = "0.5.1"
|
|
||||||
description = "Character encoding aliases for legacy web content"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "1.1"
|
|
||||||
python-versions = "^3.9"
|
|
||||||
content-hash = "2d0f6605799313075026037a1a147a31ffcb2098bf686d59b85f0d1ca5108ad2"
|
|
||||||
|
|
||||||
[metadata.files]
|
|
||||||
anytree = [
|
|
||||||
{file = "anytree-2.8.0-py2.py3-none-any.whl", hash = "sha256:14c55ac77492b11532395049a03b773d14c7e30b22aa012e337b1e983de31521"},
|
|
||||||
{file = "anytree-2.8.0.tar.gz", hash = "sha256:3f0f93f355a91bc3e6245319bf4c1d50e3416cc7a35cc1133c1ff38306bbccab"},
|
|
||||||
]
|
|
||||||
cairocffi = [
|
|
||||||
{file = "cairocffi-1.3.0.tar.gz", hash = "sha256:108a3a7cb09e203bdd8501d9baad91d786d204561bd71e9364e8b34897c47b91"},
|
|
||||||
]
|
|
||||||
cairosvg = [
|
|
||||||
{file = "CairoSVG-2.5.2-py3-none-any.whl", hash = "sha256:98c276b7e4f0caf01e5c7176765c104ffa1aa1461d63b2053b04ab663cf7052b"},
|
|
||||||
{file = "CairoSVG-2.5.2.tar.gz", hash = "sha256:b0b9929cf5dba005178d746a8036fcf0025550f498ca54db61873322384783bc"},
|
|
||||||
]
|
|
||||||
cffi = [
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"},
|
|
||||||
{file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"},
|
|
||||||
{file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"},
|
|
||||||
{file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"},
|
|
||||||
{file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"},
|
|
||||||
{file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"},
|
|
||||||
{file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
|
|
||||||
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
|
|
||||||
]
|
|
||||||
coloredlogs = [
|
|
||||||
{file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
|
|
||||||
{file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
|
|
||||||
]
|
|
||||||
cssselect2 = [
|
|
||||||
{file = "cssselect2-0.6.0-py3-none-any.whl", hash = "sha256:3a83b2a68370c69c9cd3fcb88bbfaebe9d22edeef2c22d1ff3e1ed9c7fa45ed8"},
|
|
||||||
{file = "cssselect2-0.6.0.tar.gz", hash = "sha256:5b5d6dea81a5eb0c9ca39f116c8578dd413778060c94c1f51196371618909325"},
|
|
||||||
]
|
|
||||||
defusedxml = [
|
|
||||||
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
|
|
||||||
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
|
|
||||||
]
|
|
||||||
filelock = [
|
|
||||||
{file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"},
|
|
||||||
{file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"},
|
|
||||||
]
|
|
||||||
humanfriendly = [
|
|
||||||
{file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
|
|
||||||
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
|
|
||||||
]
|
|
||||||
pillow = [
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:42dfefbef90eb67c10c45a73a9bc1599d4dac920f7dfcbf4ec6b80cb620757fe"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffde4c6fabb52891d81606411cbfaf77756e3b561b566efd270b3ed3791fde4e"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c857532c719fb30fafabd2371ce9b7031812ff3889d75273827633bca0c4602"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59789a7d06c742e9d13b883d5e3569188c16acb02eeed2510fd3bfdbc1bd1530"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d45dbe4b21a9679c3e8b3f7f4f42a45a7d3ddff8a4a16109dff0e1da30a35b2"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e9ed59d1b6ee837f4515b9584f3d26cf0388b742a11ecdae0d9237a94505d03a"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-win32.whl", hash = "sha256:b3fe2ff1e1715d4475d7e2c3e8dabd7c025f4410f79513b4ff2de3d51ce0fa9c"},
|
|
||||||
{file = "Pillow-9.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5b650dbbc0969a4e226d98a0b440c2f07a850896aed9266b6fedc0f7e7834108"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:0b4d5ad2cd3a1f0d1df882d926b37dbb2ab6c823ae21d041b46910c8f8cd844b"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9370d6744d379f2de5d7fa95cdbd3a4d92f0b0ef29609b4b1687f16bc197063d"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b761727ed7d593e49671d1827044b942dd2f4caae6e51bab144d4accf8244a84"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a66fe50386162df2da701b3722781cbe90ce043e7d53c1fd6bd801bca6b48d4"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-win32.whl", hash = "sha256:2b291cab8a888658d72b575a03e340509b6b050b62db1f5539dd5cd18fd50578"},
|
|
||||||
{file = "Pillow-9.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1d4331aeb12f6b3791911a6da82de72257a99ad99726ed6b63f481c0184b6fb9"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8844217cdf66eabe39567118f229e275f0727e9195635a15e0e4b9227458daaf"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b6617221ff08fbd3b7a811950b5c3f9367f6e941b86259843eab77c8e3d2b56b"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20d514c989fa28e73a5adbddd7a171afa5824710d0ab06d4e1234195d2a2e546"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:088df396b047477dd1bbc7de6e22f58400dae2f21310d9e2ec2933b2ef7dfa4f"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53c27bd452e0f1bc4bfed07ceb235663a1df7c74df08e37fd6b03eb89454946a"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3f6c1716c473ebd1649663bf3b42702d0d53e27af8b64642be0dd3598c761fb1"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-win32.whl", hash = "sha256:c67db410508b9de9c4694c57ed754b65a460e4812126e87f5052ecf23a011a54"},
|
|
||||||
{file = "Pillow-9.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:f054b020c4d7e9786ae0404278ea318768eb123403b18453e28e47cdb7a0a4bf"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c17770a62a71718a74b7548098a74cd6880be16bcfff5f937f900ead90ca8e92"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3f6a6034140e9e17e9abc175fc7a266a6e63652028e157750bd98e804a8ed9a"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f372d0f08eff1475ef426344efe42493f71f377ec52237bf153c5713de987251"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09e67ef6e430f90caa093528bd758b0616f8165e57ed8d8ce014ae32df6a831d"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66daa16952d5bf0c9d5389c5e9df562922a59bd16d77e2a276e575d32e38afd1"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d78ca526a559fb84faaaf84da2dd4addef5edb109db8b81677c0bb1aad342601"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-win32.whl", hash = "sha256:55e74faf8359ddda43fee01bffbc5bd99d96ea508d8a08c527099e84eb708f45"},
|
|
||||||
{file = "Pillow-9.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c150dbbb4a94ea4825d1e5f2c5501af7141ea95825fadd7829f9b11c97aaf6c"},
|
|
||||||
{file = "Pillow-9.1.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:769a7f131a2f43752455cc72f9f7a093c3ff3856bf976c5fb53a59d0ccc704f6"},
|
|
||||||
{file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:488f3383cf5159907d48d32957ac6f9ea85ccdcc296c14eca1a4e396ecc32098"},
|
|
||||||
{file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b525a356680022b0af53385944026d3486fc8c013638cf9900eb87c866afb4c"},
|
|
||||||
{file = "Pillow-9.1.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6e760cf01259a1c0a50f3c845f9cad1af30577fd8b670339b1659c6d0e7a41dd"},
|
|
||||||
{file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4165205a13b16a29e1ac57efeee6be2dfd5b5408122d59ef2145bc3239fa340"},
|
|
||||||
{file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937a54e5694684f74dcbf6e24cc453bfc5b33940216ddd8f4cd8f0f79167f765"},
|
|
||||||
{file = "Pillow-9.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:baf3be0b9446a4083cc0c5bb9f9c964034be5374b5bc09757be89f5d2fa247b8"},
|
|
||||||
{file = "Pillow-9.1.1.tar.gz", hash = "sha256:7502539939b53d7565f3d11d87c78e7ec900d3c72945d4ee0e2f250d598309a0"},
|
|
||||||
]
|
|
||||||
pycparser = [
|
|
||||||
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
|
|
||||||
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
|
|
||||||
]
|
|
||||||
pydub = [
|
|
||||||
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
|
|
||||||
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
|
|
||||||
]
|
|
||||||
pyreadline3 = [
|
|
||||||
{file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
|
|
||||||
{file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
|
|
||||||
]
|
|
||||||
six = [
|
|
||||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
|
||||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
|
||||||
]
|
|
||||||
svgwrite = [
|
|
||||||
{file = "svgwrite-1.4.2-py3-none-any.whl", hash = "sha256:ca63d76396d1f6f099a2b2d8cf1419e1c1de8deece9a2b7f4da0632067d71d43"},
|
|
||||||
{file = "svgwrite-1.4.2.zip", hash = "sha256:d304a929f197d31647c287c10eee0f64378058e1c83a9df83433a5980864e59f"},
|
|
||||||
]
|
|
||||||
tinycss2 = [
|
|
||||||
{file = "tinycss2-1.1.1-py3-none-any.whl", hash = "sha256:fe794ceaadfe3cf3e686b22155d0da5780dd0e273471a51846d0a02bc204fec8"},
|
|
||||||
{file = "tinycss2-1.1.1.tar.gz", hash = "sha256:b2e44dd8883c360c35dd0d1b5aad0b610e5156c2cb3b33434634e539ead9d8bf"},
|
|
||||||
]
|
|
||||||
tornado = [
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"},
|
|
||||||
{file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"},
|
|
||||||
{file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"},
|
|
||||||
{file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"},
|
|
||||||
{file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"},
|
|
||||||
{file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"},
|
|
||||||
{file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
|
|
||||||
]
|
|
||||||
webencodings = [
|
|
||||||
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
|
|
||||||
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
|
|
||||||
]
|
|
|
@ -10,9 +10,6 @@ tornado = "^6.1"
|
||||||
coloredlogs = "^15.0.1"
|
coloredlogs = "^15.0.1"
|
||||||
pydub = "^0.25.1"
|
pydub = "^0.25.1"
|
||||||
svgwrite = "^1.4.1"
|
svgwrite = "^1.4.1"
|
||||||
anytree = "^2.8.0"
|
|
||||||
filelock = "^3.7.1"
|
|
||||||
CairoSVG = "^2.5.2"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
|
||||||
|
|
505
svganim/strokes.py
Normal file
505
svganim/strokes.py
Normal file
|
@ -0,0 +1,505 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
from os import X_OK, PathLike
|
||||||
|
import os
|
||||||
|
from typing import Optional, Union
|
||||||
|
import shelve
|
||||||
|
from pydub import AudioSegment
|
||||||
|
import svgwrite
|
||||||
|
import tempfile
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger('svganim.strokes')
|
||||||
|
|
||||||
|
|
||||||
|
class Annotation:
|
||||||
|
def __init__(self, tag: str, drawing: Drawing, t_in: float, t_out: float) -> None:
|
||||||
|
self.tag = tag
|
||||||
|
self.t_in = t_in
|
||||||
|
self.t_out = t_out
|
||||||
|
self.drawing = drawing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return f'{self.drawing.id}:{self.tag}:{self.t_in}:{self.t_out}'
|
||||||
|
|
||||||
|
def getAnimationSlice(self) -> AnimationSlice:
|
||||||
|
return self.drawing.get_animation().getSlice(self.t_in, self.t_out)
|
||||||
|
|
||||||
|
def get_as_svg(self) -> str:
|
||||||
|
return self.getAnimationSlice().get_as_svg()
|
||||||
|
|
||||||
|
|
||||||
|
Filename = Union[str, bytes, PathLike[str], PathLike[bytes]]
|
||||||
|
|
||||||
|
|
||||||
|
class Drawing:
|
||||||
|
def __init__(self, filename: Filename, metadata_dir: Filename, basedir: Filename) -> None:
|
||||||
|
self.eventfile = filename
|
||||||
|
self.id = os.path.splitext(os.path.basename(self.eventfile))[0]
|
||||||
|
self.metadata_fn = os.path.join(metadata_dir, f"{self.id}.json")
|
||||||
|
self.basedir = basedir
|
||||||
|
|
||||||
|
def get_url(self) -> str:
|
||||||
|
return f"/files/{self.id}"
|
||||||
|
|
||||||
|
def get_annotations_url(self) -> str:
|
||||||
|
return f"/annotations/{self.id}"
|
||||||
|
|
||||||
|
def get_canvas_metadata(self) -> list:
|
||||||
|
logger.info(f'metadata for {self.id}')
|
||||||
|
with open(self.eventfile, "r") as fp:
|
||||||
|
first_line = fp.readline().strip()
|
||||||
|
|
||||||
|
if first_line.endswith(","):
|
||||||
|
first_line = first_line[:-1]
|
||||||
|
data = json.loads(first_line)
|
||||||
|
return {
|
||||||
|
"date": data[0],
|
||||||
|
"dimensions": {
|
||||||
|
"width": data[1],
|
||||||
|
"height": data[2],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_audio(self) -> Optional[AudioSlice]:
|
||||||
|
md = self.get_metadata()
|
||||||
|
if 'audio' not in md:
|
||||||
|
return None
|
||||||
|
if 'file' not in md['audio']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AudioSlice(filename=os.path.join(self.basedir, md['audio']['file'][1:]), offset=md['audio']['offset']*1000)
|
||||||
|
|
||||||
|
def get_animation(self) -> AnimationSlice:
|
||||||
|
# with open(self.eventfile, "r") as fp:
|
||||||
|
strokes = []
|
||||||
|
with open(self.eventfile, "r") as fp:
|
||||||
|
events = json.loads("[" + fp.read() + "]")
|
||||||
|
for i, event in enumerate(events):
|
||||||
|
if i == 0:
|
||||||
|
# metadata on first line
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if type(event) is list:
|
||||||
|
# ignore double metadatas, which appear when continuaing an existing drawing
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event["event"] == "viewbox":
|
||||||
|
pass
|
||||||
|
if event["event"] == "stroke":
|
||||||
|
# points = []
|
||||||
|
# for i in range(int(len(stroke) / 4)):
|
||||||
|
# p = stroke[i*4:i*4+4]
|
||||||
|
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
|
||||||
|
strokes.append(
|
||||||
|
Stroke(
|
||||||
|
event["color"],
|
||||||
|
[Point.fromTuple(tuple(p))
|
||||||
|
for p in event["points"]],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AnimationSlice(strokes, audioslice=self.get_audio())
|
||||||
|
|
||||||
|
def get_metadata(self):
|
||||||
|
canvas = self.get_canvas_metadata()
|
||||||
|
if os.path.exists(self.metadata_fn):
|
||||||
|
with open(self.metadata_fn, "r") as fp:
|
||||||
|
metadata = json.load(fp)
|
||||||
|
else:
|
||||||
|
metadata = {}
|
||||||
|
metadata["canvas"] = canvas
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def get_absolute_viewbox(self) -> Viewbox:
|
||||||
|
return self.get_animation().get_bounding_box()
|
||||||
|
|
||||||
|
|
||||||
|
class Viewbox:
|
||||||
|
def __init__(self, x: float, y: float, width: float, height: float):
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.x} {self.y} {self.width} {self.height}"
|
||||||
|
|
||||||
|
|
||||||
|
FrameIndex = tuple[int, int]
|
||||||
|
|
||||||
|
|
||||||
|
class AnimationSlice:
|
||||||
|
# either a whole drawing or the result of applying an annotation to a drawing (an excerpt)
|
||||||
|
# TODO rename to AnimationSlice to include audio as well
|
||||||
|
def __init__(
|
||||||
|
self, strokes: list[Stroke], t_in: float = 0, t_out: float = None, audioslice: AudioSlice = None
|
||||||
|
) -> None:
|
||||||
|
self.strokes = strokes
|
||||||
|
self.t_in = t_in
|
||||||
|
self.t_out = t_out
|
||||||
|
self.audio = audioslice
|
||||||
|
# TODO: Audio
|
||||||
|
|
||||||
|
def get_bounding_box(self) -> Viewbox:
|
||||||
|
min_x, max_x = float("inf"), float("-inf")
|
||||||
|
min_y, max_y = float("inf"), float("-inf")
|
||||||
|
|
||||||
|
for s in self.strokes:
|
||||||
|
for p in s.points:
|
||||||
|
if p.x < min_x:
|
||||||
|
min_x = p.x
|
||||||
|
if p.x > max_x:
|
||||||
|
max_x = p.x
|
||||||
|
if p.y < min_y:
|
||||||
|
min_y = p.y
|
||||||
|
if p.y > max_y:
|
||||||
|
max_y = p.y
|
||||||
|
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||||
|
|
||||||
|
def getSlice(self, t_in: float, t_out: float) -> AnimationSlice:
|
||||||
|
frame_in = self.getIndexForInPoint(t_in)
|
||||||
|
frame_out = self.getIndexForOutPoint(t_out)
|
||||||
|
strokes = self.getStrokeSlices(frame_in, frame_out)
|
||||||
|
audio = self.audio.getSlice(t_in, t_out) if self.audio else None
|
||||||
|
return AnimationSlice(strokes, t_in, t_out, audio)
|
||||||
|
|
||||||
|
def get_as_svg_dwg(self) -> svgwrite.Drawing:
|
||||||
|
box = self.get_bounding_box()
|
||||||
|
(_, fn) = tempfile.mkstemp(suffix='.svg', text=True)
|
||||||
|
dwg = svgwrite.Drawing(fn, size=(box.width, box.height))
|
||||||
|
dwg.viewbox(box.x, box.y, box.width, box.height)
|
||||||
|
self.add_to_dwg(dwg)
|
||||||
|
return dwg
|
||||||
|
|
||||||
|
def get_as_svg(self) -> str:
|
||||||
|
dwg = self.get_as_svg_dwg()
|
||||||
|
fp = io.StringIO()
|
||||||
|
dwg.write(fp, pretty=True)
|
||||||
|
return fp.getvalue()
|
||||||
|
|
||||||
|
def add_to_dwg(self, dwg: SvgDrawing):
|
||||||
|
group = svgwrite.container.Group()
|
||||||
|
for stroke in self.strokes:
|
||||||
|
stroke.add_to_dwg(group)
|
||||||
|
dwg.add(group)
|
||||||
|
|
||||||
|
def getStrokeSlices(
|
||||||
|
self, index_in: FrameIndex, index_out: FrameIndex
|
||||||
|
) -> list[Stroke]:
|
||||||
|
"""Get list of Stroke/StrokeSlice based in in and out indexes
|
||||||
|
Based on annotation.js getStrokesSliceForPathRange(in_point, out_point)
|
||||||
|
"""
|
||||||
|
slices = []
|
||||||
|
for i in range(index_in[0], index_out[0] + 1):
|
||||||
|
try:
|
||||||
|
stroke = self.strokes[i]
|
||||||
|
except IndexError:
|
||||||
|
# out point can be Infinity. So interrupt whenever the end is reached
|
||||||
|
break
|
||||||
|
|
||||||
|
in_i = index_in[1] if index_in[0] == i else 0
|
||||||
|
out_i = index_out[1] if index_out[0] == i else len(
|
||||||
|
stroke.points) - 1
|
||||||
|
|
||||||
|
slices.append(StrokeSlice(stroke, in_i, out_i))
|
||||||
|
return slices
|
||||||
|
|
||||||
|
def getIndexForInPoint(self, ms) -> FrameIndex:
|
||||||
|
"""Get the frame index (path, point) based on the given time
|
||||||
|
The In point version (so the first index after ms)
|
||||||
|
Equal to annotations.js findPositionForTime(ms)
|
||||||
|
"""
|
||||||
|
path_i = 0
|
||||||
|
point_i = 0
|
||||||
|
for i, stroke in enumerate(self.strokes):
|
||||||
|
start_at = stroke.points[0].t
|
||||||
|
end_at = stroke.points[-1].t
|
||||||
|
if end_at < ms:
|
||||||
|
# certainly not the right point yet
|
||||||
|
continue
|
||||||
|
if start_at > ms:
|
||||||
|
path_i = i
|
||||||
|
point_i = 0
|
||||||
|
break # too far, so this is the first point after in point
|
||||||
|
else:
|
||||||
|
# our in-point is inbetween first and last of the stroke
|
||||||
|
# we are getting close, find the right point_i
|
||||||
|
path_i = i
|
||||||
|
for pi, point in enumerate(stroke.points):
|
||||||
|
point_i = pi
|
||||||
|
if point.t > ms:
|
||||||
|
break # stop when finding the next point after in point
|
||||||
|
break # done :-)
|
||||||
|
return (path_i, point_i)
|
||||||
|
|
||||||
|
def getIndexForOutPoint(self, ms) -> FrameIndex:
|
||||||
|
"""Get the frame index (path, point) based on the given time
|
||||||
|
The Out point version (so the last index before ms)
|
||||||
|
Equal to annotations.js findPositionForTime(ms)
|
||||||
|
"""
|
||||||
|
return self.getIndexForTime( ms)
|
||||||
|
|
||||||
|
def getIndexForTime(self, ms) -> FrameIndex:
|
||||||
|
"""Get the frame index (path, point) based on the given time
|
||||||
|
Equal to annotations.js findPositionForTime(ms)
|
||||||
|
"""
|
||||||
|
path_i = 0
|
||||||
|
point_i = 0
|
||||||
|
for i, stroke in enumerate(self.strokes):
|
||||||
|
start_at = stroke.points[0].t
|
||||||
|
end_at = stroke.points[-1].t
|
||||||
|
|
||||||
|
if start_at > ms:
|
||||||
|
break # too far
|
||||||
|
if end_at > ms:
|
||||||
|
# we are getting close, find the right point_i
|
||||||
|
path_i = i
|
||||||
|
for pi, point in enumerate(stroke.points):
|
||||||
|
if point.t > ms:
|
||||||
|
break # too far
|
||||||
|
point_i = pi
|
||||||
|
break # done :-)
|
||||||
|
else:
|
||||||
|
# in case this is our last path, stroe this as
|
||||||
|
# best option thus far
|
||||||
|
path_i = i
|
||||||
|
point_i = len(stroke.points) - 1
|
||||||
|
return (path_i, point_i)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioSlice:
|
||||||
|
def __init__(self, filename: Filename, t_in: float = None, t_out: float = None, offset: float = None):
|
||||||
|
self.filename = filename
|
||||||
|
self.t_in = t_in # in ms
|
||||||
|
self.t_out = t_out # in ms
|
||||||
|
self.offset = offset # in ms
|
||||||
|
|
||||||
|
def getSlice(self, t_in: float, t_out: float) -> AnimationSlice:
|
||||||
|
return AudioSlice(self.filename, t_in, t_out, self.offset)
|
||||||
|
|
||||||
|
def export(self, format="mp3"):
|
||||||
|
"""Returns file descriptor of tempfile"""
|
||||||
|
# Opening file and extracting segment
|
||||||
|
song = AudioSegment.from_file(self.filename)
|
||||||
|
start = self.t_in - self.offset
|
||||||
|
end = self.t_out - self.offset
|
||||||
|
|
||||||
|
if start < 0 and end < 0:
|
||||||
|
extract = AudioSegment.silent(
|
||||||
|
duration=end-start, frame_rate=song.frame_rate)
|
||||||
|
else:
|
||||||
|
if start < 0:
|
||||||
|
preroll = AudioSegment.silent(
|
||||||
|
duration=start * -1, frame_rate=song.frame_rate)
|
||||||
|
start = 0
|
||||||
|
else:
|
||||||
|
preroll = None
|
||||||
|
if end > len(song):
|
||||||
|
postroll = AudioSegment.silent(
|
||||||
|
duration=end - len(song), frame_rate=song.frame_rate)
|
||||||
|
end = len(song) - 1
|
||||||
|
else:
|
||||||
|
postroll = None
|
||||||
|
|
||||||
|
extract = song[start: end]
|
||||||
|
if preroll:
|
||||||
|
extract = preroll + extract
|
||||||
|
if postroll:
|
||||||
|
extract += postroll
|
||||||
|
|
||||||
|
# Saving
|
||||||
|
return extract.export(None, format=format)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationIndex:
|
||||||
|
def __init__(
|
||||||
|
self, filename: Filename, drawing_dir: Filename, metadata_dir: Filename
|
||||||
|
) -> None:
|
||||||
|
self.filename = filename
|
||||||
|
self.drawing_dir = drawing_dir
|
||||||
|
self.metadata_dir = metadata_dir
|
||||||
|
|
||||||
|
# disable disk cache because of glitches shelve.open(filename, writeback=True)
|
||||||
|
self.shelve = {}
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
# reset the index
|
||||||
|
for key in list(self.shelve.keys()):
|
||||||
|
print(key)
|
||||||
|
del self.shelve[key]
|
||||||
|
|
||||||
|
self.shelve["_drawings"] = {
|
||||||
|
d.id: d
|
||||||
|
for d in [
|
||||||
|
Drawing(fn, self.metadata_dir, self.drawing_dir) for fn in self.get_drawing_filenames()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.shelve['_tags'] = {}
|
||||||
|
self.shelve['_annotations'] = {}
|
||||||
|
|
||||||
|
drawing: Drawing
|
||||||
|
for drawing in self.shelve['_drawings'].values():
|
||||||
|
meta = drawing.get_metadata()
|
||||||
|
if 'annotations' not in meta:
|
||||||
|
continue
|
||||||
|
for ann in meta['annotations']:
|
||||||
|
annotation = Annotation(
|
||||||
|
ann['tag'], drawing, ann['t_in'], ann['t_out'])
|
||||||
|
self.shelve['_annotations'][annotation.id] = annotation
|
||||||
|
if annotation.tag not in self.shelve['_tags']:
|
||||||
|
self.shelve['_tags'][annotation.tag] = [annotation]
|
||||||
|
else:
|
||||||
|
self.shelve['_tags'][annotation.tag].append(
|
||||||
|
annotation
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drawings(self) -> dict[str, Drawing]:
|
||||||
|
return self.shelve["_drawings"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self) -> dict[str, list[Annotation]]:
|
||||||
|
return self.shelve["_tags"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def annotations(self) -> dict[str, Annotation]:
|
||||||
|
return self.shelve["_annotations"]
|
||||||
|
|
||||||
|
def get_annotations(self, tag) -> list[Annotation]:
|
||||||
|
if tag not in self.tags:
|
||||||
|
return []
|
||||||
|
return self.tags[tag]
|
||||||
|
|
||||||
|
def get_drawing_names(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
name[:-16]
|
||||||
|
for name in os.listdir(self.drawing_dir)
|
||||||
|
if name.endswith("json_appendable") and os.stat(os.path.join(self.drawing_dir, name)).st_size > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_drawing_filenames(self) -> list[Filename]:
|
||||||
|
return [
|
||||||
|
os.path.join(self.drawing_dir, f"{name}.json_appendable")
|
||||||
|
for name in self.get_drawing_names()
|
||||||
|
]
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.shelve.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Point = tuple[float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
class Point:
|
||||||
|
def __init__(self, x: float, y: float, last: bool, t: float):
|
||||||
|
self.x = float(x)
|
||||||
|
self.y = float(y) # if y == 0 it can still be integer.... odd python
|
||||||
|
self.last = last
|
||||||
|
self.t = t
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromTuple(cls, p: tuple[float, float, int, float]):
|
||||||
|
return cls(p[0], p[1], bool(p[2]), p[3])
|
||||||
|
|
||||||
|
def scaledToFit(self, dimensions: dict[str, float]) -> Point:
|
||||||
|
# TODO: change so that it actually scales to FIT dimensions
|
||||||
|
return Point(self.x, self.y, self.last, self.t)
|
||||||
|
|
||||||
|
|
||||||
|
Points = list[Point]
|
||||||
|
SvgDrawing = Union[svgwrite.container.SVG, svgwrite.container.Group]
|
||||||
|
|
||||||
|
|
||||||
|
class Stroke:
|
||||||
|
def __init__(self, color: str, points: Points) -> None:
|
||||||
|
self.color = color
|
||||||
|
self.points = points
|
||||||
|
|
||||||
|
def add_to_dwg(self, dwg: SvgDrawing):
|
||||||
|
path = svgwrite.path.Path(d=self.get_as_d()).stroke(
|
||||||
|
self.color, 1).fill("none")
|
||||||
|
dwg.add(path)
|
||||||
|
|
||||||
|
def get_bounding_box(self) -> Viewbox:
|
||||||
|
min_x, max_x = float("inf"), float("-inf")
|
||||||
|
min_y, max_y = float("inf"), float("-inf")
|
||||||
|
|
||||||
|
for p in self.points:
|
||||||
|
if p.x < min_x:
|
||||||
|
min_x = p.x
|
||||||
|
if p.x > max_x:
|
||||||
|
max_x = p.x
|
||||||
|
if p.y < min_y:
|
||||||
|
min_y = p.y
|
||||||
|
if p.y > max_y:
|
||||||
|
max_y = p.y
|
||||||
|
return Viewbox(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||||
|
|
||||||
|
def get_as_d(self):
|
||||||
|
d = ""
|
||||||
|
prev_point = None
|
||||||
|
cmd = ""
|
||||||
|
for point in self.points:
|
||||||
|
if not prev_point:
|
||||||
|
# TODO multiply points by scalars for dimensions (height widht of drawing)
|
||||||
|
d += f'M{point.x:.6},{point.y:.6} '
|
||||||
|
cmd = 'M'
|
||||||
|
else:
|
||||||
|
if prev_point.last:
|
||||||
|
d += " m"
|
||||||
|
cmd = "m"
|
||||||
|
elif cmd != 'l':
|
||||||
|
d += ' l '
|
||||||
|
cmd = 'l'
|
||||||
|
diff_point = {
|
||||||
|
"x": point.x - prev_point.x,
|
||||||
|
"y": point.y - prev_point.y,
|
||||||
|
}
|
||||||
|
# TODO multiply points by scalars for dimensions (height widht of drawing)
|
||||||
|
d += f'{diff_point["x"]:.6},{diff_point["y"]:.6} '
|
||||||
|
prev_point = point
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class StrokeSlice(Stroke):
|
||||||
|
def __init__(self, stroke: Stroke, i_in: int = None, i_out: int = None) -> None:
|
||||||
|
self.stroke = stroke
|
||||||
|
self.i_in = 0 if i_in is None else i_in
|
||||||
|
self.i_out = len(self.stroke.points) - 1 if i_out is None else i_out
|
||||||
|
|
||||||
|
def slice_id(self):
|
||||||
|
return f"{self.i_in}-{self.i_out}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def points(self) -> Points:
|
||||||
|
return self.stroke.points[self.i_in: self.i_out + 1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self) -> str:
|
||||||
|
return self.stroke.color
|
||||||
|
|
||||||
|
|
||||||
|
def strokes2D(strokes):
|
||||||
|
# strokes to a d attribute for a path
|
||||||
|
d = ""
|
||||||
|
last_stroke = None
|
||||||
|
cmd = ""
|
||||||
|
for stroke in strokes:
|
||||||
|
if not last_stroke:
|
||||||
|
d += f"M{stroke[0]},{stroke[1]} "
|
||||||
|
cmd = 'M'
|
||||||
|
else:
|
||||||
|
if last_stroke[2] == 1:
|
||||||
|
d += " m"
|
||||||
|
cmd = 'm'
|
||||||
|
elif cmd != 'l':
|
||||||
|
d += ' l '
|
||||||
|
cmd = 'l'
|
||||||
|
|
||||||
|
rel_stroke = [stroke[0] - last_stroke[0],
|
||||||
|
stroke[1] - last_stroke[1]]
|
||||||
|
d += f"{rel_stroke[0]},{rel_stroke[1]} "
|
||||||
|
last_stroke = stroke
|
||||||
|
return d
|
60
templates/index.html
Normal file
60
templates/index.html
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Annotations</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: darkgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
/* width: 400px; */
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% for tag in index.tags %}
|
||||||
|
<h2>{{tag}}</h2>
|
||||||
|
<ul>
|
||||||
|
{% for annotation in index.tags[tag] %}
|
||||||
|
<li><img src="/annotation/{{ annotation.id }}.svg" data-audio="/annotation/{{ annotation.id }}.mp3"></li>
|
||||||
|
{% end %}
|
||||||
|
</ul>
|
||||||
|
{% end %}
|
||||||
|
<!-- <ul>
|
||||||
|
{% for annotation in index.annotations %}
|
||||||
|
<li>{{ annotation }}</li>
|
||||||
|
{% end %}
|
||||||
|
</ul> -->
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<a href="?refresh">Reload index</a>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
let images = document.querySelectorAll('[data-audio]');
|
||||||
|
for (const image of images) {
|
||||||
|
const audio = new Audio(image.dataset.audio);
|
||||||
|
console.log(image, audio);
|
||||||
|
image.addEventListener('mouseover', (e) => {
|
||||||
|
audio.play();
|
||||||
|
});
|
||||||
|
image.addEventListener('mouseout', (e) => {
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
|
@ -1,10 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from urllib.error import HTTPError
|
|
||||||
from zipfile import ZipFile
|
|
||||||
import tornado.ioloop
|
import tornado.ioloop
|
||||||
import tornado.web
|
import tornado.web
|
||||||
import tornado.websocket
|
import tornado.websocket
|
||||||
|
@ -15,10 +11,7 @@ import html
|
||||||
import argparse
|
import argparse
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
import glob
|
import glob
|
||||||
import filelock
|
|
||||||
import svganim.strokes
|
import svganim.strokes
|
||||||
import svganim.uimethods
|
|
||||||
import cairosvg
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("svganim.webserver")
|
logger = logging.getLogger("svganim.webserver")
|
||||||
|
@ -83,7 +76,6 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||||
return
|
return
|
||||||
|
|
||||||
# write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
|
# write to an appendable json format. So basically a file that should be wrapped in [] to be json-parsable
|
||||||
# TODO use jsonlines -- which is not so much different but (semi-)standardized
|
|
||||||
with open(
|
with open(
|
||||||
os.path.join(self.config.storage, self.filename +
|
os.path.join(self.config.storage, self.filename +
|
||||||
".json_appendable"), "a"
|
".json_appendable"), "a"
|
||||||
|
@ -242,86 +234,15 @@ class AudioListingHandler(tornado.web.RequestHandler):
|
||||||
print(names)
|
print(names)
|
||||||
self.write(json.dumps(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):
|
class AnimationHandler(tornado.web.RequestHandler):
|
||||||
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
|
def initialize(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.index = index
|
|
||||||
|
def get(self, filename):
|
||||||
async def get(self, filename):
|
self.set_header("Content-Type", "application/json")
|
||||||
# filename = self.get_argument("file", None)
|
# filename = self.get_argument("file", None)
|
||||||
if filename == "":
|
if filename == "":
|
||||||
self.set_header("Content-Type", "application/json")
|
|
||||||
files = []
|
files = []
|
||||||
names = [
|
names = [
|
||||||
name
|
name
|
||||||
|
@ -339,75 +260,50 @@ class AnimationHandler(tornado.web.RequestHandler):
|
||||||
if first_line.endswith(","):
|
if first_line.endswith(","):
|
||||||
first_line = first_line[:-1]
|
first_line = first_line[:-1]
|
||||||
|
|
||||||
drawing_specs = json.loads(first_line)
|
metadata = 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(
|
files.append(
|
||||||
{
|
{
|
||||||
"name": f"/files/{drawing_id}",
|
"name": f"/files/{name[:-16]}",
|
||||||
"id": drawing_id,
|
"id": name[:-16],
|
||||||
"title": title,
|
"ctime": metadata[0],
|
||||||
"ctime": drawing_specs[0],
|
|
||||||
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"),
|
"mtime": datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %T"),
|
||||||
"dimensions": [drawing_specs[1], drawing_specs[2]],
|
"dimensions": [metadata[1], metadata[2]],
|
||||||
"svg": f"/drawing/{drawing_id}.svg",
|
"svg": f"/drawing/{name[:-16]}.svg",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
files.sort(key=lambda k: k["mtime"])
|
files.sort(key=lambda k: k["mtime"])
|
||||||
self.write(json.dumps(files))
|
self.write(json.dumps(files))
|
||||||
else:
|
else:
|
||||||
if filename[-4:] == ".svg":
|
path = os.path.join(
|
||||||
extension = "svg"
|
self.config.storage, os.path.basename(
|
||||||
filename = filename[:-4]
|
filename) + ".json_appendable"
|
||||||
elif filename[-4:] == ".png":
|
)
|
||||||
extension = "png"
|
drawing = {"file": filename, "shape": []}
|
||||||
filename = filename[:-4]
|
with open(path, "r") as fp:
|
||||||
elif filename[-4:] == ".mp3":
|
events = json.loads("[" + fp.read() + "]")
|
||||||
extension = "mp3"
|
for i, event in enumerate(events):
|
||||||
filename = filename[:-4]
|
if i == 0:
|
||||||
elif filename[-4:] == ".wav":
|
# metadata on first line
|
||||||
extension = "wav"
|
drawing["time"] = event[0]
|
||||||
filename = filename[:-4]
|
drawing["dimensions"] = [event[1], event[2]]
|
||||||
else:
|
else:
|
||||||
extension = None
|
if type(event) is list:
|
||||||
|
# ignore double metadatas, which appear when continuaing an existing drawing
|
||||||
|
continue
|
||||||
|
if event["event"] == "viewbox":
|
||||||
|
pass
|
||||||
|
if event["event"] == "stroke":
|
||||||
|
# points = []
|
||||||
|
# for i in range(int(len(stroke) / 4)):
|
||||||
|
# p = stroke[i*4:i*4+4]
|
||||||
|
# points.append([float(p[0]), float(p[1]), int(p[2]), float(p[3])])
|
||||||
|
drawing["shape"].append(
|
||||||
|
{"color": event["color"],
|
||||||
|
"points": event["points"]}
|
||||||
|
)
|
||||||
|
self.write(json.dumps(drawing))
|
||||||
|
|
||||||
logger.info(f"file {filename=}, {extension=}")
|
|
||||||
# if annotation_id not in self.index.annotations:
|
|
||||||
# raise tornado.web.HTTPError(404)
|
|
||||||
|
|
||||||
# annotation = self.index.annotations[annotation_id]
|
|
||||||
|
|
||||||
|
|
||||||
t_in = self.get_argument('t_in', None)
|
|
||||||
t_out = self.get_argument('t_out', None)
|
|
||||||
|
|
||||||
animation = self.index.drawings[filename].get_animation()
|
|
||||||
|
|
||||||
if t_in is not None and t_out is not None:
|
|
||||||
animation = animation.getSlice(float(t_in), float(t_out))
|
|
||||||
|
|
||||||
|
|
||||||
if extension == "svg":
|
|
||||||
self.set_header("Content-Type", "image/svg+xml")
|
|
||||||
self.write(animation.get_as_svg())
|
|
||||||
elif extension == "png":
|
|
||||||
self.set_header("Content-Type", "image/png")
|
|
||||||
svgstring = animation.get_as_svg()
|
|
||||||
self.write(cairosvg.svg2png(bytestring=svgstring))
|
|
||||||
elif extension == "mp3":
|
|
||||||
self.set_header("Content-Type", "audio/mp3")
|
|
||||||
audio = await animation.audio.export(format="mp3")
|
|
||||||
self.write(audio.read())
|
|
||||||
elif extension == "wav":
|
|
||||||
self.set_header("Content-Type", "audio/wav")
|
|
||||||
audio = await animation.audio.export(format="wav")
|
|
||||||
self.write(audio.read())
|
|
||||||
else:
|
|
||||||
self.set_header("Content-Type", "application/json")
|
|
||||||
self.write(json.dumps(animation.asDict(include_full_drawing=True)))
|
|
||||||
|
|
||||||
|
|
||||||
class TagHandler(tornado.web.RequestHandler):
|
class TagHandler(tornado.web.RequestHandler):
|
||||||
"""List all tags"""
|
"""List all tags"""
|
||||||
|
@ -432,21 +328,12 @@ class TagAnnotationsHandler(tornado.web.RequestHandler):
|
||||||
self.metadir = os.path.join(self.config.storage, "metadata")
|
self.metadir = os.path.join(self.config.storage, "metadata")
|
||||||
|
|
||||||
def get(self, tag):
|
def get(self, tag):
|
||||||
if not self.index.has_tag(tag):
|
if tag not in self.index.tags:
|
||||||
raise tornado.web.HTTPError(404)
|
raise tornado.web.HTTPError(404)
|
||||||
|
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
# annotations = self.index.tags[tag]
|
annotations = self.index.tags[tag]
|
||||||
# self.write(json.dumps(list([a.id for a in annotations])))
|
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):
|
class AnnotationHandler(tornado.web.RequestHandler):
|
||||||
|
@ -461,9 +348,6 @@ class AnnotationHandler(tornado.web.RequestHandler):
|
||||||
if annotation_id[-4:] == ".svg":
|
if annotation_id[-4:] == ".svg":
|
||||||
extension = "svg"
|
extension = "svg"
|
||||||
annotation_id = annotation_id[:-4]
|
annotation_id = annotation_id[:-4]
|
||||||
elif annotation_id[-4:] == ".png":
|
|
||||||
extension = "png"
|
|
||||||
annotation_id = annotation_id[:-4]
|
|
||||||
elif annotation_id[-4:] == ".mp3":
|
elif annotation_id[-4:] == ".mp3":
|
||||||
extension = "mp3"
|
extension = "mp3"
|
||||||
annotation_id = annotation_id[:-4]
|
annotation_id = annotation_id[:-4]
|
||||||
|
@ -481,13 +365,7 @@ class AnnotationHandler(tornado.web.RequestHandler):
|
||||||
|
|
||||||
if extension == "svg":
|
if extension == "svg":
|
||||||
self.set_header("Content-Type", "image/svg+xml")
|
self.set_header("Content-Type", "image/svg+xml")
|
||||||
self.set_header("Cache-Control", "max-age=31536000, immutable")
|
|
||||||
|
|
||||||
self.write(annotation.get_as_svg())
|
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":
|
elif extension == "mp3":
|
||||||
self.set_header("Content-Type", "audio/mp3")
|
self.set_header("Content-Type", "audio/mp3")
|
||||||
self.write(annotation.getAnimationSlice(
|
self.write(annotation.getAnimationSlice(
|
||||||
|
@ -503,41 +381,6 @@ class AnnotationHandler(tornado.web.RequestHandler):
|
||||||
"tag": annotation.tag,
|
"tag": annotation.tag,
|
||||||
"audio": f"/annotation/{annotation.id}.mp3",
|
"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):
|
class DrawingHandler(tornado.web.RequestHandler):
|
||||||
|
@ -552,9 +395,6 @@ class DrawingHandler(tornado.web.RequestHandler):
|
||||||
if drawing_id[-4:] == ".svg":
|
if drawing_id[-4:] == ".svg":
|
||||||
extension = "svg"
|
extension = "svg"
|
||||||
drawing_id = drawing_id[:-4]
|
drawing_id = drawing_id[:-4]
|
||||||
elif drawing_id[-4:] == ".png":
|
|
||||||
extension = "png"
|
|
||||||
drawing_id = drawing_id[:-4]
|
|
||||||
elif drawing_id[-4:] == ".mp3":
|
elif drawing_id[-4:] == ".mp3":
|
||||||
extension = "mp3"
|
extension = "mp3"
|
||||||
drawing_id = drawing_id[:-4]
|
drawing_id = drawing_id[:-4]
|
||||||
|
@ -576,10 +416,6 @@ class DrawingHandler(tornado.web.RequestHandler):
|
||||||
if extension == "svg":
|
if extension == "svg":
|
||||||
self.set_header("Content-Type", "image/svg+xml")
|
self.set_header("Content-Type", "image/svg+xml")
|
||||||
self.write(drawing.get_animation().get_as_svg())
|
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":
|
elif extension == "mp3":
|
||||||
self.set_header("Content-Type", "audio/mp3")
|
self.set_header("Content-Type", "audio/mp3")
|
||||||
self.write(drawing.get_animation(
|
self.write(drawing.get_animation(
|
||||||
|
@ -649,44 +485,6 @@ class AnnotationsHandler(tornado.web.RequestHandler):
|
||||||
with open(meta_file, "w") as fp:
|
with open(meta_file, "w") as fp:
|
||||||
json.dump(self.json_args, fp)
|
json.dump(self.json_args, fp)
|
||||||
|
|
||||||
class 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):
|
class IndexHandler(tornado.web.RequestHandler):
|
||||||
"""Get annotation as svg"""
|
"""Get annotation as svg"""
|
||||||
|
@ -698,9 +496,9 @@ class IndexHandler(tornado.web.RequestHandler):
|
||||||
def get(self):
|
def get(self):
|
||||||
do_refresh = bool(self.get_query_argument('refresh', False))
|
do_refresh = bool(self.get_query_argument('refresh', False))
|
||||||
if do_refresh:
|
if do_refresh:
|
||||||
logger.info("Reloading Annotation Index")
|
self.logger.info("Reloading Annotation Index")
|
||||||
self.index.refresh()
|
self.index.refresh()
|
||||||
logger.info("\treloaded annotation index")
|
self.logger.info("\treloaded annotation index")
|
||||||
|
|
||||||
self.render("templates/index.html", index=self.index)
|
self.render("templates/index.html", index=self.index)
|
||||||
|
|
||||||
|
@ -720,11 +518,8 @@ class Server:
|
||||||
# self.config['server']['port']
|
# self.config['server']['port']
|
||||||
self.web_root = os.path.join("www")
|
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(
|
self.index = svganim.strokes.AnnotationIndex(
|
||||||
os.path.join(self.config.storage,"annotation_index.shelve"), self.config.storage, os.path.join(self.config.storage,"metadata")
|
"annotation_index.shelve", "files", "files/metadata"
|
||||||
)
|
)
|
||||||
self.logger.info("Loading Annotation Index")
|
self.logger.info("Loading Annotation Index")
|
||||||
self.index.refresh()
|
self.index.refresh()
|
||||||
|
@ -740,8 +535,7 @@ class Server:
|
||||||
"config": self.config,
|
"config": self.config,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}),
|
(r"/files/(.*)", AnimationHandler, {"config": self.config}),
|
||||||
(r"/export/(.*)", ExportHandler, {"config": self.config, "index": self.index}),
|
|
||||||
(
|
(
|
||||||
r"/audio/(.+)",
|
r"/audio/(.+)",
|
||||||
tornado.web.StaticFileHandler,
|
tornado.web.StaticFileHandler,
|
||||||
|
@ -771,15 +565,11 @@ class Server:
|
||||||
(r"/index", IndexHandler,
|
(r"/index", IndexHandler,
|
||||||
{"config": self.config, "index": self.index}),
|
{"config": self.config, "index": self.index}),
|
||||||
|
|
||||||
(r"/tags.json", TagsHandler,
|
|
||||||
{"config": self.config, "index": self.index}),
|
|
||||||
|
|
||||||
(r"/(.*)", StaticFileWithHeaderHandler,
|
(r"/(.*)", StaticFileWithHeaderHandler,
|
||||||
{"path": self.web_root, 'default_filename': 'index.html'}),
|
{"path": self.web_root, 'default_filename': 'index.html'}),
|
||||||
],
|
],
|
||||||
debug=True,
|
debug=True,
|
||||||
autoreload=True,
|
autoreload=True,
|
||||||
ui_methods= svganim.uimethods
|
|
||||||
)
|
)
|
||||||
application.listen(self.config.port)
|
application.listen(self.config.port)
|
||||||
tornado.ioloop.IOLoop.current().start()
|
tornado.ioloop.IOLoop.current().start()
|
||||||
|
@ -800,9 +590,6 @@ if __name__ == "__main__":
|
||||||
argParser.add_argument(
|
argParser.add_argument(
|
||||||
"--storage", type=str, default="files", help="directory name for output files"
|
"--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)
|
argParser.add_argument("--verbose", "-v", action="count", default=0)
|
||||||
|
|
||||||
args = argParser.parse_args()
|
args = argParser.parse_args()
|
||||||
|
@ -822,19 +609,16 @@ if __name__ == "__main__":
|
||||||
)
|
)
|
||||||
|
|
||||||
# File logging
|
# File logging
|
||||||
if args.logfile is not None:
|
formatter = logging.Formatter(
|
||||||
formatter = logging.Formatter(
|
fmt="%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s",
|
||||||
fmt="%(asctime)s %(module)s:%(lineno)d %(levelname)8s | %(message)s",
|
datefmt="%Y/%m/%d %H:%M:%S",
|
||||||
datefmt="%Y/%m/%d %H:%M:%S",
|
) # %I:%M:%S %p AM|PM format
|
||||||
) # %I:%M:%S %p AM|PM format
|
logFileHandler = logging.handlers.RotatingFileHandler(
|
||||||
logFileHandler = logging.handlers.RotatingFileHandler(
|
"log/draw_log.log", maxBytes=1024 * 512, backupCount=5
|
||||||
args.logfile, maxBytes=1024 * 512, backupCount=5
|
)
|
||||||
)
|
logFileHandler.setFormatter(formatter)
|
||||||
logFileHandler.setFormatter(formatter)
|
|
||||||
|
|
||||||
logger.addHandler(logFileHandler)
|
|
||||||
|
|
||||||
|
|
||||||
|
logger.addHandler(logFileHandler)
|
||||||
logger.info(f"Start server: http://localhost:{args.port}")
|
logger.info(f"Start server: http://localhost:{args.port}")
|
||||||
|
|
||||||
server = Server(args, logger)
|
server = Server(args, logger)
|
292
www/annotate.html
Normal file
292
www/annotate.html
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Annotate a line animation</title>
|
||||||
|
<style media="screen">
|
||||||
|
body {
|
||||||
|
/* background: black;
|
||||||
|
color: white */
|
||||||
|
background: lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sample,
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: calc(100% - 200px);
|
||||||
|
font-family: sans-serif;
|
||||||
|
z-index: 2;
|
||||||
|
/* background: white; */
|
||||||
|
/* border: solid 2px lightgray; */
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .background {
|
||||||
|
fill: white
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke: gray;
|
||||||
|
stroke-width: 1mm;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.before path {
|
||||||
|
opacity: 0.5;
|
||||||
|
stroke: gray !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.after path,
|
||||||
|
path.before_in {
|
||||||
|
opacity: .1;
|
||||||
|
stroke: gray !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
/* position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0; */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button.paused, .controls button.playing{
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
.controls button.paused::before{
|
||||||
|
content: '⏵';
|
||||||
|
}
|
||||||
|
.controls button.playing::before{
|
||||||
|
content: '⏸';
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 100;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 5%;
|
||||||
|
right: 0;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrubber {}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
line-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags li {
|
||||||
|
display: block;
|
||||||
|
padding: 5px;
|
||||||
|
border: solid 1px darkgray;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags li:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background: darkgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags li.selected {
|
||||||
|
background: lightsteelblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags li.annotation-rm {
|
||||||
|
/* display: none; */
|
||||||
|
overflow: hidden;
|
||||||
|
color: red;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
transition: width .3s;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
direction: rtl;
|
||||||
|
/* hide behind bar, instead into nothing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-annotation .tags li.annotation-rm {
|
||||||
|
color: red;
|
||||||
|
display: block;
|
||||||
|
width: 30px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags li span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotations {
|
||||||
|
height: 10px;
|
||||||
|
/* border: solid 1px darkgray; */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotations>div {
|
||||||
|
opacity: .4;
|
||||||
|
background: lightseagreen;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotations>div:hover,
|
||||||
|
.annotations>div.selected {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-test {
|
||||||
|
background-color: red !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-another {
|
||||||
|
background-color: blue !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-google {
|
||||||
|
background-color: blueviolet !important;
|
||||||
|
}
|
||||||
|
.annotation-map {
|
||||||
|
background-color: red !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-relation {
|
||||||
|
background-color: blue !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-text {
|
||||||
|
background-color: blueviolet !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-figure {
|
||||||
|
background-color: pink !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsaved::before {
|
||||||
|
content: '*';
|
||||||
|
color: red;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30px;
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved::before {
|
||||||
|
content: '\2713';
|
||||||
|
display: inline-block;
|
||||||
|
color: green;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30px;
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noUi-horizontal .noUi-touch-area {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioconfig{
|
||||||
|
z-index: 9;
|
||||||
|
background:black;
|
||||||
|
color: white;
|
||||||
|
position: relative;
|
||||||
|
width: 100px; /* as wide as audio controls only */
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.audioconfig:hover{
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.audioconfig select, .audioconfig input{
|
||||||
|
margin:10px;
|
||||||
|
}
|
||||||
|
audio{
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 100px; /* hides seek head */
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist img{
|
||||||
|
position: static;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
background: white;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="assets/nouislider-15.5.0.css">
|
||||||
|
<link rel="stylesheet" href="core.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id='interface'>
|
||||||
|
</div>
|
||||||
|
<script src="assets/nouislider-15.5.0.js"></script>
|
||||||
|
<script src="assets/wNumb-1.2.0.min.js"></script>
|
||||||
|
<script src="annotate.js"></script>
|
||||||
|
<script src="playlist.js"></script>
|
||||||
|
<script type='text/javascript'>
|
||||||
|
let ann;
|
||||||
|
if (location.search) {
|
||||||
|
ann = new Annotator(
|
||||||
|
document.getElementById("interface"),
|
||||||
|
["map", "text", "relation", "figure"],
|
||||||
|
location.search.substring(1)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const playlist = new Playlist(document.getElementById("interface"), '/files/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1000
www/annotate.js
Normal file
1000
www/annotate.js
Normal file
File diff suppressed because it is too large
Load diff
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
@ -119,7 +119,7 @@ class Canvas {
|
||||||
this.setColor(this.colors[0]);
|
this.setColor(this.colors[0]);
|
||||||
|
|
||||||
|
|
||||||
this.socket = new WebSocket(this.url); // TODO: reconnectingwebsocket
|
this.socket = new WebSocket(this.url);
|
||||||
this.socket.addEventListener('open', (e) => {
|
this.socket.addEventListener('open', (e) => {
|
||||||
this.sendDimensions();
|
this.sendDimensions();
|
||||||
|
|
||||||
|
@ -150,10 +150,6 @@ class Canvas {
|
||||||
// this.openTheFloor()
|
// 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) {
|
setPreloaded(json_url) {
|
||||||
|
@ -211,10 +207,6 @@ class Canvas {
|
||||||
this.wrapperEl.classList.remove('closed');
|
this.wrapperEl.classList.remove('closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
closeTheFloor() {
|
|
||||||
this.wrapperEl.classList.add('closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilename(filename) {
|
setFilename(filename) {
|
||||||
this.filename = filename;
|
this.filename = filename;
|
||||||
this.filenameEl.innerText = filename;
|
this.filenameEl.innerText = filename;
|
88
www/playlist.js
Normal file
88
www/playlist.js
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
class Playlist {
|
||||||
|
constructor(wrapperEl, url) {
|
||||||
|
this.wrapperEl = wrapperEl;
|
||||||
|
|
||||||
|
const request = new Request(url, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(request)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let playlist = this.wrapperEl.querySelector('.playlist');
|
||||||
|
if (!playlist) {
|
||||||
|
playlist = document.createElement('nav');
|
||||||
|
playlist.classList.add('playlist');
|
||||||
|
this.wrapperEl.appendChild(playlist)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
playlist.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEl = document.createElement("ul");
|
||||||
|
for (let file of data) {
|
||||||
|
const liEl = document.createElement("li");
|
||||||
|
|
||||||
|
|
||||||
|
const imgEl = document.createElement("img");
|
||||||
|
imgEl.classList.add('img');
|
||||||
|
imgEl.title = file.id;
|
||||||
|
imgEl.src = file.svg;
|
||||||
|
liEl.append(imgEl);
|
||||||
|
|
||||||
|
|
||||||
|
let time = file.mtime;
|
||||||
|
if (file.ctime != file.mtime){
|
||||||
|
time += ` (orig: ${file.ctime})`;
|
||||||
|
}
|
||||||
|
const dateEl = document.createElement("span");
|
||||||
|
dateEl.classList.add('date');
|
||||||
|
dateEl.innerText = time;
|
||||||
|
liEl.append(dateEl);
|
||||||
|
|
||||||
|
|
||||||
|
const nameEl = document.createElement("span");
|
||||||
|
nameEl.classList.add('name');
|
||||||
|
nameEl.innerText = file.name;
|
||||||
|
liEl.append(nameEl);
|
||||||
|
|
||||||
|
const linksEl = document.createElement("span");
|
||||||
|
linksEl.classList.add('links');
|
||||||
|
liEl.append(linksEl);
|
||||||
|
|
||||||
|
const playEl = document.createElement("a");
|
||||||
|
playEl.classList.add('play');
|
||||||
|
playEl.innerText = "Play";
|
||||||
|
playEl.href = location;
|
||||||
|
playEl.pathname = "play.html";
|
||||||
|
playEl.search = "?"+file.name;
|
||||||
|
linksEl.append(playEl);
|
||||||
|
|
||||||
|
const annotateEl = document.createElement("a");
|
||||||
|
annotateEl.classList.add('annotate');
|
||||||
|
annotateEl.innerText = "Annotate";
|
||||||
|
annotateEl.href = location;
|
||||||
|
annotateEl.pathname = "annotate.html";
|
||||||
|
annotateEl.search = "?"+file.name;
|
||||||
|
linksEl.append(annotateEl);
|
||||||
|
|
||||||
|
const drawEl = document.createElement("a");
|
||||||
|
drawEl.classList.add('draw');
|
||||||
|
drawEl.innerText = "Draw";
|
||||||
|
drawEl.href = location;
|
||||||
|
drawEl.pathname = "draw.html";
|
||||||
|
drawEl.hash = file.id;
|
||||||
|
linksEl.append(drawEl);
|
||||||
|
|
||||||
|
// liEl.addEventListener('click', (e) => {
|
||||||
|
// this.play(fileUrl);
|
||||||
|
// playlist.style.display = "none";
|
||||||
|
// });
|
||||||
|
listEl.appendChild(liEl);
|
||||||
|
}
|
||||||
|
playlist.appendChild(listEl);
|
||||||
|
// do something with the data sent in the request
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue