Compare commits

...

4 commits

Author SHA1 Message Date
Ruben van de Ven
6fbe49473d Annotation export to self-contained zip 2023-02-23 18:52:21 +01:00
Ruben van de Ven
8be08ce9d6 More player controls 2023-02-23 18:28:16 +01:00
Ruben van de Ven
f7e9fd99fc Style Player component and move to annotate.js 2023-02-23 13:25:03 +01:00
Ruben van de Ven
daf0e0dfd4 Annotation Player as rudimentary Web Component 2023-02-23 11:17:24 +01:00
4 changed files with 434 additions and 82 deletions

View file

@ -168,29 +168,16 @@
padding: 10px;
}
#annotations .svganim_player {
#annotations .svganim_player,
#annotations annotation-player {
display: inline-block;
position: relative;
width: 300px;
height: 200px;
overflow: hidden;
padding: 10px;
background: white;
}
#annotations .svganim_player svg {
width: 100%;
height: 100%;
}
#annotations .svganim_player.play:not(.loading) .controls {
visibility: hidden;
}
#annotations .svganim_player:hover .controls {
visibility: visible !important;
}
</style>
<script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script>

View file

@ -2,7 +2,9 @@ import json
import logging
import os
import shutil
import tempfile
from urllib.error import HTTPError
from zipfile import ZipFile
import tornado.ioloop
import tornado.web
import tornado.websocket
@ -240,6 +242,73 @@ class AudioListingHandler(tornado.web.RequestHandler):
print(names)
self.write(json.dumps(names))
class ExportHandler(tornado.web.RequestHandler):
"""
Export a player to a zip file
"""
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
self.config = config
self.index = index
async def get(self, filename):
logger.info(f"file {filename=}")
if filename not in self.index.drawings:
raise tornado.web.HTTPError(404)
t_in = self.get_argument('t_in', None)
t_out = self.get_argument('t_out', None)
animation = self.index.drawings[filename].get_animation()
if t_in is not None and t_out is not None:
animation = animation.getSlice(float(t_in), float(t_out))
with tempfile.TemporaryDirectory() as tdir:
with ZipFile(tdir + '/annotation.zip', 'w') as archive:
logger.info('write svg')
svgstring = animation.get_as_svg()
archive.writestr('drawing.svg', svgstring)
logger.info('write png')
archive.writestr('drawing.png', cairosvg.svg2png(bytestring=svgstring))
logger.info('write mp3')
audio = await animation.audio.export(format="mp3")
archive.writestr('drawing.mp3', audio.read())
logger.info('write json')
data = animation.asDict()
data['audio']['file'] = 'drawing.mp3';
archive.writestr('annotation.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 = """
<html>
<head>
<script src="wNumb-1.2.0.min.js"></script>
<script src="annotate.js"></script>
</head>
<body>
<annotation-player data-poster-url="drawing.svg" data-annotation-url="annotation.json">
</body>
</html>
"""
archive.writestr('drawing.html', html)
with open(tdir + '/annotation.zip', 'rb') as fp:
self.set_header("Content-Type", "application/zip")
self.write(fp.read())
logger.info('done')
class AnimationHandler(tornado.web.RequestHandler):
def initialize(self, config, index: svganim.strokes.AnnotationIndex):
@ -669,6 +738,7 @@ class Server:
},
),
(r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}),
(r"/export/(.*)", ExportHandler, {"config": self.config, "index": self.index}),
(
r"/audio/(.+)",
tornado.web.StaticFileHandler,

View file

@ -130,10 +130,11 @@ class Annotator extends EventTarget {
time *= -1;
}
const s = Math.floor(time / 1000);
const minutes = Math.floor(s / 60);
const seconds = s - minutes * 60;
const ms = Math.floor((time / 1000 - s) * 1000);
return `${neg}${minutes}:${seconds}.${ms}`;
const minutes = String(Math.floor(s / 60)).padStart(2, '0');
const seconds = String(s - minutes * 60).padStart(2, '0');
// show miliseconds only in annotator
const ms = !this.config.is_player ? "." + String(Math.floor((time / 1000 - s) * 1000)).padStart(3, '0') : "";
return `${neg}${minutes}:${seconds}${ms}`;
},
undo: (tc) => {
let [rest, ms] = tc.split(/[\.\,]/);
@ -152,6 +153,7 @@ class Annotator extends EventTarget {
this.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.wrapperEl.appendChild(this.svgEl);
this.wrapperEl.classList.add(this.config.is_player ? "svganim_player" : "svganim_annotator");
this.wrapperEl.classList.add(this.config.crop_to_fit ? "cropped-to-selection" : "follow-drawing");
this.controlsEl = document.createElement('div');
@ -192,15 +194,40 @@ class Annotator extends EventTarget {
ev.preventDefault(); // we don't want to spacebar, as this is captured in the overall keydown event
})
if (!this.config.is_player) {
this.scrubberEl = document.createElement('div');
this.scrubberEl.classList.add('scrubber')
this.controlsEl.appendChild(this.scrubberEl);
if(!this.config.is_player){
this.annotationsEl = document.createElement('div');
this.annotationsEl.classList.add('annotations')
this.controlsEl.appendChild(this.annotationsEl);
} else {
const extraEl = document.createElement('details');
extraEl.classList.add('controls--extra');
const summaryEl = document.createElement('summary');
summaryEl.innerHTML = "&mldr;";
extraEl.appendChild(summaryEl);
const extraControlsEl = document.createElement('ul');
// TODO: add handlers
const toggleFutureEl = document.createElement('li');
toggleFutureEl.innerText = "Show preview"
toggleFutureEl.addEventListener('click', () => this.wrapperEl.classList.toggle('hide-drawing-preview'));
extraControlsEl.appendChild(toggleFutureEl);
const toggleCropPlayerEl = document.createElement('li');
toggleCropPlayerEl.innerText = "Crop to selection";
toggleCropPlayerEl.addEventListener('click', () => this.toggleCrop());
extraControlsEl.appendChild(toggleCropPlayerEl);
extraEl.appendChild(extraControlsEl);
this.playbackControlsEl.appendChild(extraEl);
}
@ -1090,11 +1117,20 @@ class Annotator extends EventTarget {
}
}
toggleCrop(){
this.config.crop_to_fit = !this.config.crop_to_fit;
setCrop(crop_to_fit) {
this.config.crop_to_fit = Boolean(crop_to_fit);
if (this.config.crop_to_fit) {
this.wrapperEl.classList.add('cropped-to-selection');
} else {
this.wrapperEl.classList.remove('cropped-to-selection');
}
this.updateViewbox();
}
toggleCrop() {
this.setCrop(!this.config.crop_to_fit);
}
getNextPosition(path_i, point_i) {
const path = this.strokes[path_i];
let next_path, next_point;
@ -1446,3 +1482,265 @@ class Annotator extends EventTarget {
}
class AnnotationPlayer extends HTMLElement {
constructor() {
super();
// We don't use constructor() because an element's attributes
// are unavailable until connected to the DOM.
// attributes:
// - data-no-crop
// - autoplay
// - preload
// - data-poster-src
// - data-annotation-url
}
connectedCallback() {
// Create a shadow root
this.attachShadow({ mode: "open" });
const imgEl = document.createElement('img');
const playerEl = document.createElement('div');
const config = {
is_player: true,
crop_to_fit: this.hasAttribute('data-no-crop') ? false : true,
autoplay: true,
}
imgEl.src = this.getAttribute('data-poster-url');
imgEl.addEventListener('click', () => {
imgEl.style.display = 'none';
this.annotator = new Annotator(
playerEl,
null, //"tags.json",
this.getAttribute('data-annotation-url'),
config
);
})
playerEl.classList.add('play');
const styleEl = document.createElement('style');
styleEl.textContent = `
:host{
overflow: hidden;
padding: 10px;
background: white;
}
svg, img {
width: 100%;
height: 100%;
}
.play:not(.loading) .controls {
visibility: hidden;
}
:host(:hover) .controls {
visibility: visible !important;
}
.controls--playback {
display:flex;
background: rgba(0,0,0,.5);
border-radius: 3px;
}
.timecode {
width: 30px;
font-size: 8px;
background: none;
border: none;
color: white;
}
.controls--playback input[type='range'] {
flex-grow: 1;
-webkit-appearance: none;
background: none;
}
input[type="range"]::-webkit-slider-runnable-track,
input[type="range"]::-moz-range-track {
background: lightgray;
height: 5px;
border-radius: 3px;
}
input[type="range"]::-moz-range-progress {
background-color: white;
height: 5px;
border-radius: 3px 0 0 3px;
}
input[type="range"]::-webkit-slider-thumb,
input[type="range"]::-moz-range-thumb {
-webkit-appearance: none;
height: 15px;
width: 15px;
background: white;
margin-top: -5px;
border-radius: 50%;
border:none;
}
.controls button.paused,
.controls button.playing {
order: -1;
width: 30px;
height: 30px;
border: none;
background: none;
color: white;
line-height: 1;
}
.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%;
}
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;
}
.hide-drawing-preview g.after path, .hide-drawing-preview path.before_in{
opacity:0;
}
.gray {
position: absolute;
background: rgba(255, 255, 255, 0.7);
}
details{
color: white;
}
summary{
list-style: none;
cursor: pointer;
padding: 0 5px;
}
details > ul{
position: absolute;
bottom: 30px;
background: rgba(0,0,0, .5);
border-radius: 3px;
right: 0;
padding: 5px;
margin: 0;
list-style: none;
font-size: 10pt;
}
details > ul li:hover{
cursor: pointer;
text-decoration: underline;
}
.play:not(.hide-drawing-preview) details > ul li:first-child{
/*text-decoration: line-through;*/
font-weight:bold;
}
.play.cropped-to-selection details > ul li:nth-child(2){
/*text-decoration: line-through;*/
font-weight:bold;
}
`;
this.shadowRoot.appendChild(styleEl);
this.shadowRoot.appendChild(imgEl);
this.shadowRoot.appendChild(playerEl);
}
setAnnotation(annotation) {
// this.annotation = annotation;
this.setAttribute('data-annotation-url', annotation.url)
this.setAttribute('data-poster-url', `/annotation/${annotation.id}.svg`)
}
toggleCrop() {
if (this.hasAttribute('data-no-crop')) {
this.removeAttribute('data-no-crop');
} else {
this.setAttribute('data-no-crop', true);
}
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(name, oldValue, newValue);
if (name == 'data-no-crop') {
if (!this.annotator) {
return;
}
this.annotator.setCrop(!this.hasAttribute('data-no-crop'));
}
}
// required for attributeChangedCallback()
static get observedAttributes() { return ['data-no-crop']; }
}
window.customElements.define('annotation-player', AnnotationPlayer);

View file

@ -141,10 +141,8 @@ class AnnotationManager {
const ulEl = document.createElement('ul');
this.annotations.forEach((annotation, idx) => {
const liEl = document.createElement('li');
const imgEl = document.createElement('img');
const playerEl = document.createElement('div');
const infoEl = document.createElement('span');
infoEl.classList.add('annotation-info');
const playerEl = new AnnotationPlayer(); //document.createElement('annotation-player');
playerEl.setAnnotation(annotation);
const selectEl = document.createElement('input');
selectEl.type = 'checkbox';
@ -157,25 +155,20 @@ class AnnotationManager {
}
})
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}`;
imgEl.src = `/annotation/${annotation.id}.svg`;
imgEl.addEventListener('click', () => {
imgEl.style.display = 'none';
new Annotator(
playerEl,
"tags.json",
annotation.url,
{ is_player: true, crop_to_fit: true, autoplay: true }
);
})
const downloadEl = document.createElement('a');
downloadEl.href = annotation.url.replace('files', 'export');
downloadEl.innerHTML = '&darr;';
playerEl.classList.add('play');
infoEl.appendChild(downloadEl);
liEl.appendChild(imgEl);
liEl.appendChild(playerEl);
liEl.appendChild(selectEl);
liEl.appendChild(infoEl);
@ -209,6 +202,10 @@ class AnnotationManager {
this.buildAnnotationActions()
}
/**
* Build the form items to select & move the annotations
* @returns undefined
*/
buildAnnotationActions() {
if (!this.actionsEl || !this.annotations.length) return
this.actionsEl.innerHTML = "";