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; padding: 10px;
} }
#annotations .svganim_player { #annotations .svganim_player,
#annotations annotation-player {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 300px; width: 300px;
height: 200px; 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> </style>
<script src="assets/nouislider-15.5.0.js"></script> <script src="assets/nouislider-15.5.0.js"></script>
<script src="assets/wNumb-1.2.0.min.js"></script> <script src="assets/wNumb-1.2.0.min.js"></script>

View file

@ -2,7 +2,9 @@ import json
import logging import logging
import os import os
import shutil import shutil
import tempfile
from urllib.error import HTTPError 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
@ -240,6 +242,73 @@ 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))
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): class AnimationHandler(tornado.web.RequestHandler):
def initialize(self, config, index: svganim.strokes.AnnotationIndex): def initialize(self, config, index: svganim.strokes.AnnotationIndex):
@ -669,6 +738,7 @@ class Server:
}, },
), ),
(r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}), (r"/files/(.*)", AnimationHandler, {"config": self.config, "index": self.index}),
(r"/export/(.*)", ExportHandler, {"config": self.config, "index": self.index}),
( (
r"/audio/(.+)", r"/audio/(.+)",
tornado.web.StaticFileHandler, tornado.web.StaticFileHandler,

View file

@ -130,10 +130,11 @@ class Annotator extends EventTarget {
time *= -1; time *= -1;
} }
const s = Math.floor(time / 1000); const s = Math.floor(time / 1000);
const minutes = Math.floor(s / 60); const minutes = String(Math.floor(s / 60)).padStart(2, '0');
const seconds = s - minutes * 60; const seconds = String(s - minutes * 60).padStart(2, '0');
const ms = Math.floor((time / 1000 - s) * 1000); // show miliseconds only in annotator
return `${neg}${minutes}:${seconds}.${ms}`; const ms = !this.config.is_player ? "." + String(Math.floor((time / 1000 - s) * 1000)).padStart(3, '0') : "";
return `${neg}${minutes}:${seconds}${ms}`;
}, },
undo: (tc) => { undo: (tc) => {
let [rest, ms] = tc.split(/[\.\,]/); 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.svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.wrapperEl.appendChild(this.svgEl); this.wrapperEl.appendChild(this.svgEl);
this.wrapperEl.classList.add(this.config.is_player ? "svganim_player" : "svganim_annotator"); 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'); 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 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 = document.createElement('div');
this.scrubberEl.classList.add('scrubber') this.scrubberEl.classList.add('scrubber')
this.controlsEl.appendChild(this.scrubberEl); this.controlsEl.appendChild(this.scrubberEl);
if(!this.config.is_player){
this.annotationsEl = document.createElement('div'); this.annotationsEl = document.createElement('div');
this.annotationsEl.classList.add('annotations') this.annotationsEl.classList.add('annotations')
this.controlsEl.appendChild(this.annotationsEl); 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(){ setCrop(crop_to_fit) {
this.config.crop_to_fit = !this.config.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(); this.updateViewbox();
} }
toggleCrop() {
this.setCrop(!this.config.crop_to_fit);
}
getNextPosition(path_i, point_i) { getNextPosition(path_i, point_i) {
const path = this.strokes[path_i]; const path = this.strokes[path_i];
let next_path, next_point; 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'); const ulEl = document.createElement('ul');
this.annotations.forEach((annotation, idx) => { this.annotations.forEach((annotation, idx) => {
const liEl = document.createElement('li'); const liEl = document.createElement('li');
const imgEl = document.createElement('img'); const playerEl = new AnnotationPlayer(); //document.createElement('annotation-player');
const playerEl = document.createElement('div'); playerEl.setAnnotation(annotation);
const infoEl = document.createElement('span');
infoEl.classList.add('annotation-info');
const selectEl = document.createElement('input'); const selectEl = document.createElement('input');
selectEl.type = 'checkbox'; selectEl.type = 'checkbox';
@ -157,25 +155,20 @@ class AnnotationManager {
} }
}) })
const tag = this.rootTag.find_by_id(annotation.tag); const tag = this.rootTag.find_by_id(annotation.tag);
console.log(tag) console.log(tag)
const infoEl = document.createElement('span');
infoEl.classList.add('annotation-info');
infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`; infoEl.innerText = `[${tag.get_name()}] ${annotation.comment}`;
imgEl.src = `/annotation/${annotation.id}.svg`; const downloadEl = document.createElement('a');
imgEl.addEventListener('click', () => { downloadEl.href = annotation.url.replace('files', 'export');
imgEl.style.display = 'none'; downloadEl.innerHTML = '&darr;';
new Annotator(
playerEl,
"tags.json",
annotation.url,
{ is_player: true, crop_to_fit: true, autoplay: true }
);
})
playerEl.classList.add('play'); infoEl.appendChild(downloadEl);
liEl.appendChild(imgEl);
liEl.appendChild(playerEl); liEl.appendChild(playerEl);
liEl.appendChild(selectEl); liEl.appendChild(selectEl);
liEl.appendChild(infoEl); liEl.appendChild(infoEl);
@ -209,6 +202,10 @@ class AnnotationManager {
this.buildAnnotationActions() this.buildAnnotationActions()
} }
/**
* Build the form items to select & move the annotations
* @returns undefined
*/
buildAnnotationActions() { buildAnnotationActions() {
if (!this.actionsEl || !this.annotations.length) return if (!this.actionsEl || !this.annotations.length) return
this.actionsEl.innerHTML = ""; this.actionsEl.innerHTML = "";