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(/[\.\,]/);
@ -143,7 +144,7 @@ class Annotator extends EventTarget {
ms += v * factor;
factor *= 60;
});
return `${ms}`;
return `${ms} `;
}
});
@ -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
})
this.scrubberEl = document.createElement('div');
this.scrubberEl.classList.add('scrubber')
this.controlsEl.appendChild(this.scrubberEl);
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);
}
@ -339,7 +366,7 @@ class Annotator extends EventTarget {
if (this.selectedAnnotationI == annotation_i) {
this.annotationEl.classList.add('selected');
}
this.annotationEl.title = `[${annotation.tag}] ${annotation.comment}`;
this.annotationEl.title = `[${annotation.tag}] ${annotation.comment} `;
this.annotationEl.addEventListener('mouseover', (e) => {
@ -425,7 +452,7 @@ class Annotator extends EventTarget {
// draw full stroke of annotation
console.debug('setInOut');
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
console.debug([`${this.inPointTimeMs}`, `${this.outPointTimeMs}`])
console.debug([`${this.inPointTimeMs} `, `${this.outPointTimeMs} `])
this.slider.set([this.inPointTimeMs, this.outPointTimeMs]);
// console.debug(this.selectedAnnotation);
@ -460,7 +487,7 @@ class Annotator extends EventTarget {
.then(data => {
if (!this.config.is_player) {
const metadata_req = new Request(`/annotations/${data.file}`, {
const metadata_req = new Request(`/ annotations / ${data.file} `, {
method: 'GET',
});
return fetch(metadata_req)
@ -478,17 +505,17 @@ class Annotator extends EventTarget {
})
.then(() => {
// play on click for player
if(this.config.is_player) {
if (this.config.is_player) {
this.svgEl.addEventListener('click', (ev) => {
console.debug('clicked for play/pause');
this.playPause();
});
}
// autoplay if necessary
if(this.config.autoplay){
if (this.config.autoplay) {
this.play(); // play should remove loading
} else{
} else {
this.wrapperEl.classList.remove('loading');
}
})
@ -726,7 +753,7 @@ class Annotator extends EventTarget {
// bgEl.setAttribute("height", this.dimensions[1]);
// bgEl.classList.add('background');
// this.svgEl.prepend(bgEl);
this.firstFrameTime = this.strokes.length == 0 ? 0 : this.strokes[0].points[0][3];
this.lastFrameTime = this.getFinalFrameTime();
this.playheadEl.max = this.lastFrameTime;
@ -845,7 +872,7 @@ class Annotator extends EventTarget {
titleEl.title = this.title ?? "[click to add title for this diagram]"
titleEl.addEventListener('click', (ev) => {
const title = prompt("Change the title for the drawing", this.title ?? "");
if(title === null) return; //cancel
if (title === null) return; //cancel
titleEl.innerText = title.length ? title : "[add title]";
this.title = title.length ? title : null;
this.updateState();
@ -895,7 +922,7 @@ class Annotator extends EventTarget {
}
this.audioEl.addEventListener('loadedmetadata', (ev) => {
@ -986,7 +1013,7 @@ class Annotator extends EventTarget {
}
getFinalFrameTime() {
if(this.strokes.length == 0) return null; // when no strokes are loaded (eg. for annotation)
if (this.strokes.length == 0) return null; // when no strokes are loaded (eg. for annotation)
const points = this.strokes[this.strokes.length - 1].points;
return points[points.length - 1][3];
}
@ -1032,7 +1059,7 @@ class Annotator extends EventTarget {
// }
// for (let index = 0; index < inPath_i; index++) {
// const pathEl = this.svgEl.querySelector(`.path${index}`);
// const pathEl = this.svgEl.querySelector(`.path${ index } `);
// if (pathEl) {
// pathEl.classList.add('before_in');
// }
@ -1044,7 +1071,7 @@ class Annotator extends EventTarget {
// const path = this.strokes[path_i];
// // console.log(path);
// let pathEl = this.svgEl.querySelector(`.path${path_i}`);
// let pathEl = this.svgEl.querySelector(`.path${ path_i } `);
// if (!pathEl) {
// pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// pathEl.style.stroke = path.color;
@ -1072,29 +1099,38 @@ class Annotator extends EventTarget {
updateViewbox() {
if (this.config.crop_to_fit) {
this.svgEl.setAttribute('viewBox', `${this.bounding_box.x} ${this.bounding_box.y} ${this.bounding_box.width} ${this.bounding_box.height}`);
this.svgEl.setAttribute('viewBox', `${this.bounding_box.x} ${this.bounding_box.y} ${this.bounding_box.width} ${this.bounding_box.height} `);
} else {
let x,y,w,h;
if(this.currentViewboxI !== null) {
let x, y, w, h;
if (this.currentViewboxI !== null) {
x = this.viewboxes[this.currentViewboxI].x,
y = this.viewboxes[this.currentViewboxI].y,
w = this.dimensions[0],
h = this.dimensions[1];
y = this.viewboxes[this.currentViewboxI].y,
w = this.dimensions[0],
h = this.dimensions[1];
} else {
x = 0,
y = 0,
w = this.dimensions[0],
h = this.dimensions[1];
y = 0,
w = this.dimensions[0],
h = this.dimensions[1];
}
this.svgEl.setAttribute('viewBox', `${x} ${y} ${w} ${h}`);
this.svgEl.setAttribute('viewBox', `${x} ${y} ${w} ${h} `);
}
}
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

@ -86,7 +86,7 @@ class AnnotationManager {
selectTag(tag) {
this.clearSelectedTag();
this.selectedTag = tag;
this.selectedTag = tag;
tag.menuLiEl.classList.add('selected');
this.loadAnnotationsForTag(tag)
}
@ -141,14 +141,12 @@ 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';
selectEl.id = 'select-'+annotation.id_hash;
selectEl.id = 'select-' + annotation.id_hash;
selectEl.addEventListener('change', (ev) => {
if (ev.target.checked) {
this.addAnnotationToSelection(annotation);
@ -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);
@ -195,22 +188,26 @@ class AnnotationManager {
}
addAnnotationToSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) === -1){
if (this.selectedAnnotations.indexOf(annotation) === -1) {
this.selectedAnnotations.push(annotation);
}
this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = true;
this.annotationsEl.querySelector('#select-' + annotation.id_hash).checked = true;
this.buildAnnotationActions()
}
removeAnnotationFromSelection(annotation) {
if (this.selectedAnnotations.indexOf(annotation) !== -1){
if (this.selectedAnnotations.indexOf(annotation) !== -1) {
this.selectedAnnotations.splice(this.selectedAnnotations.indexOf(annotation), 1)
}
this.annotationsEl.querySelector('#select-'+annotation.id_hash).checked = false;
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
if (!this.actionsEl || !this.annotations.length) return
this.actionsEl.innerHTML = "";
const selectAllLabelEl = document.createElement('label');
@ -220,7 +217,7 @@ class AnnotationManager {
selectAllEl.checked = this.annotations.length === this.selectedAnnotations.length;
// selectAllEl.innerText = `Select all ${this.annotations.length} items`;
selectAllEl.addEventListener('change', (ev) => {
if(ev.target.checked) {
if (ev.target.checked) {
this.annotations.forEach((a) => this.addAnnotationToSelection(a));
} else {
this.resetSelectedAnnotations();
@ -231,7 +228,7 @@ class AnnotationManager {
this.actionsEl.appendChild(selectAllLabelEl)
if(!this.selectedAnnotations.length) return;
if (!this.selectedAnnotations.length) return;
const moveLabelEl = document.createElement('label');
moveLabelEl.innerText = `Change tag for ${this.selectedAnnotations.length} items`
@ -239,7 +236,7 @@ class AnnotationManager {
this.rootTag.descendants().forEach((tag, i) => {
const tagEl = document.createElement('option');
tagEl.value = tag.id;
if(tag.id == this.selectedTag.id){
if (tag.id == this.selectedTag.id) {
tagEl.selected = true;
}
tagEl.innerHTML = tag.get_indented_name();
@ -304,19 +301,19 @@ class AnnotationManager {
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, {
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
}