1894 lines
66 KiB
JavaScript
1894 lines
66 KiB
JavaScript
class Annotation {
|
|
constructor(tag, t_in, t_out, comment) {
|
|
this.tag = tag;
|
|
this.t_in = Number.parseFloat(t_in);
|
|
this.t_out = Number.parseFloat(t_out);
|
|
this.comment = comment;
|
|
}
|
|
}
|
|
|
|
class StrokeGroup {
|
|
constructor(group_element, player) {
|
|
this.g = group_element;
|
|
this.player = player;
|
|
}
|
|
|
|
setStrokes(strokes) {
|
|
const pathEls = this.g.querySelectorAll('path');
|
|
let indexes = Object.keys(strokes);
|
|
for (let pathEl of pathEls) {
|
|
const i = pathEl.dataset.path_i;
|
|
if (!indexes.includes(pathEl.dataset.path_i)) {
|
|
pathEl.parentNode.removeChild(pathEl);
|
|
} else {
|
|
// check in and outpoint using pathEl.dataset
|
|
if (strokes[i].getSliceId() != pathEl.dataset.slice) {
|
|
const d = this.points2D(strokes[i].points);
|
|
pathEl.dataset.slice = strokes[i].getSliceId();
|
|
pathEl.setAttribute('d', d);
|
|
}
|
|
}
|
|
|
|
// this has now been processed
|
|
indexes.splice(indexes.indexOf(i), 1);
|
|
}
|
|
|
|
// new strokes
|
|
indexes.forEach(index => {
|
|
const stroke = strokes[index];
|
|
|
|
let pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
pathEl.style.stroke = stroke.color;
|
|
pathEl.classList.add('path');
|
|
pathEl.dataset.path_i = index;
|
|
pathEl.dataset.slice = stroke.getSliceId();
|
|
this.g.appendChild(pathEl);
|
|
|
|
const d = this.points2D(stroke.points);
|
|
pathEl.setAttribute('d', d);
|
|
});
|
|
}
|
|
|
|
// convert array of points to a d-attribute
|
|
points2D(strokes) {
|
|
// strokes to a d attribute for a path
|
|
let d = "";
|
|
let last_stroke = undefined;
|
|
let cmd = "";
|
|
for (let stroke of strokes) {
|
|
if (!last_stroke) {
|
|
d += `M${stroke[0]},${stroke[1]} `;
|
|
cmd = 'M';
|
|
} else {
|
|
if (last_stroke[2] == 1) {
|
|
d += " m";
|
|
cmd = 'm';
|
|
} else if (cmd != 'l') {
|
|
d += ' l ';
|
|
cmd = 'l';
|
|
}
|
|
let rel_stroke = [stroke[0] - last_stroke[0], stroke[1] - last_stroke[1]];
|
|
d += `${rel_stroke[0]},${rel_stroke[1]} `;
|
|
}
|
|
last_stroke = stroke;
|
|
|
|
}
|
|
return d;
|
|
}
|
|
|
|
setPrecomputedStrokes(strokeDs) {
|
|
const pathEls = this.g.querySelectorAll('path');
|
|
for (let pathEl of pathEls) {
|
|
pathEl.parentNode.removeChild(pathEl);
|
|
}
|
|
strokeDs.forEach((strokeD, index) => {
|
|
let pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
// pathEl.style.stroke = stroke.color;
|
|
// pathEl.classList.add('path');
|
|
pathEl.setAttribute('d', strokeD);
|
|
this.g.appendChild(pathEl);
|
|
|
|
});
|
|
}
|
|
}
|
|
|
|
class Stroke {
|
|
constructor(color, points) {
|
|
this.color = color;
|
|
this.points = points; // [[x1,y1,t1], [x2,y2,t2], ...]
|
|
}
|
|
|
|
getSliceId() {
|
|
return 'all';
|
|
}
|
|
}
|
|
|
|
class StrokeSlice {
|
|
constructor(stroke, i_in, i_out) {
|
|
this.stroke = stroke; // Stroke
|
|
this.i_in = typeof i_in === 'undefined' ? 0 : i_in;
|
|
this.i_out = typeof i_out === 'undefined' ? this.stroke.points.length - 1 : i_out;
|
|
}
|
|
|
|
getSliceId() {
|
|
return `${this.i_in}-${this.i_out}`;
|
|
}
|
|
|
|
// compatible with Stroke()
|
|
get points() {
|
|
return this.stroke.points.slice(this.i_in, this.i_out + 1);
|
|
}
|
|
|
|
// compatible with Stroke()
|
|
get color() {
|
|
return this.stroke.color;
|
|
}
|
|
}
|
|
|
|
const CropOptions = {
|
|
Fit_Selection: 'selection',
|
|
Follow_Drawing: 'follow',
|
|
Whole_Drawing: 'whole',
|
|
};
|
|
|
|
const CropDescriptions = {
|
|
selection: 'Crop to annotation',
|
|
follow: 'Follow drawing canvas',
|
|
whole: 'Show whole drawing',
|
|
}
|
|
|
|
class Annotator extends EventTarget {
|
|
constructor(wrapperEl, tagFile, fileurl, config) {
|
|
fileurl = fileurl.replace("&", "&"); // little hack: tornadoweb does this automatically for some reason
|
|
super();
|
|
|
|
this.config = {
|
|
is_player: config && config.hasOwnProperty('is_player') ? config.is_player : false, // in player mode annotations are not loaded, nor is the annotator shown
|
|
crop_to_fit: config && config.hasOwnProperty('crop_to_fit') ? config.crop_to_fit : false, // DEPRECATED don't animate viewport, but show the whole drawing
|
|
crop: config && config.hasOwnProperty('crop') && Object.values(CropOptions).indexOf(config.crop) !== -1 ? config.crop : CropOptions.Fit_Selection, // don't animate viewport, but show the whole drawing
|
|
autoplay: config && config.hasOwnProperty('autoplay') ? config.autoplay : false, // immediately start playback
|
|
url_prefix: config && config.hasOwnProperty('url_prefix') ? config.url_prefix : '',
|
|
}
|
|
|
|
this.formatter = wNumb({
|
|
decimals: 2,
|
|
edit: (time) => {
|
|
let neg = "";
|
|
if (time < 0) {
|
|
neg = "-";
|
|
time *= -1;
|
|
}
|
|
const s = Math.floor(time / 1000);
|
|
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(/[\.\,]/);
|
|
ms = parseFloat(typeof ms === "undefined" ? 0 : ms);
|
|
let factor = 1000;
|
|
rest.split(':').reverse().forEach((v, i) => {
|
|
ms += v * factor;
|
|
factor *= 60;
|
|
});
|
|
return `${ms} `;
|
|
}
|
|
});
|
|
|
|
this.wrapperEl = wrapperEl;
|
|
|
|
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("crop-" + this.config.crop);
|
|
|
|
|
|
this.controlsEl = document.createElement('div');
|
|
this.controlsEl.classList.add('controls')
|
|
this.wrapperEl.appendChild(this.controlsEl);
|
|
|
|
this.playbackControlsEl = document.createElement('div');
|
|
this.playbackControlsEl.classList.add('controls--playback')
|
|
this.controlsEl.appendChild(this.playbackControlsEl);
|
|
|
|
this.playheadEl = document.createElement('input');
|
|
this.playheadEl.type = "range";
|
|
this.playheadEl.min = 0;
|
|
this.playheadEl.step = 0.01;
|
|
this.playbackControlsEl.appendChild(this.playheadEl);
|
|
|
|
this.playheadEl.addEventListener("input", (ev) => {
|
|
this.scrubTo(ev.target.value);
|
|
});
|
|
this.playheadEl.addEventListener('keydown', (ev) => {
|
|
ev.preventDefault(); // we don't want to use arrow keys, as these are captured in the overall keydown event
|
|
})
|
|
|
|
this.timeCodeEl = document.createElement('input');
|
|
this.timeCodeEl.type = 'numeric';
|
|
this.timeCodeEl.classList.add('timecode');
|
|
this.timeCodeEl.disabled = true;
|
|
this.playbackControlsEl.appendChild(this.timeCodeEl);
|
|
|
|
this.playPauseEl = document.createElement('button');
|
|
this.playPauseEl.classList.add('paused');
|
|
this.playbackControlsEl.appendChild(this.playPauseEl);
|
|
|
|
this.playPauseEl.addEventListener("click", (ev) => {
|
|
this.playPause()
|
|
})
|
|
this.playPauseEl.addEventListener('keydown', (ev) => {
|
|
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);
|
|
|
|
|
|
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 = "…";
|
|
extraEl.appendChild(summaryEl);
|
|
|
|
const extraControlsEl = document.createElement('ul');
|
|
|
|
const toggleFutureHeaderEl = document.createElement('li');
|
|
toggleFutureHeaderEl.classList.add('config-header', 'config-future');
|
|
toggleFutureHeaderEl.innerText = "Preview drawing";
|
|
extraControlsEl.appendChild(toggleFutureHeaderEl);
|
|
|
|
// TODO: add handlers to change text
|
|
const toggleFutureEl = document.createElement('li');
|
|
toggleFutureEl.classList.add('config-future');
|
|
toggleFutureEl.innerText = "Show"
|
|
toggleFutureEl.addEventListener('click', () => this.wrapperEl.classList.toggle('hide-drawing-preview'));
|
|
extraControlsEl.appendChild(toggleFutureEl);
|
|
|
|
const toggleCropHeaderEl = document.createElement('li');
|
|
toggleCropHeaderEl.classList.add('config-header');
|
|
toggleCropHeaderEl.innerText = "Crop";
|
|
extraControlsEl.appendChild(toggleCropHeaderEl);
|
|
|
|
this.toggleCropPlayerEl = document.createElement('li');
|
|
this.toggleCropPlayerEl.innerText = CropDescriptions[this.config.crop];
|
|
this.toggleCropPlayerEl.addEventListener('click', () => this.toggleCrop());
|
|
extraControlsEl.appendChild(this.toggleCropPlayerEl);
|
|
|
|
extraEl.appendChild(extraControlsEl);
|
|
|
|
this.playbackControlsEl.appendChild(extraEl);
|
|
|
|
|
|
|
|
const fullScreenEl = document.createElement('div');
|
|
fullScreenEl.classList.add('controls-fs');
|
|
fullScreenEl.innerHTML = "⛶";
|
|
fullScreenEl.addEventListener('click', () => {
|
|
if(document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
// console.log(this.wrapperEl, this.shadowRoot);
|
|
this.wrapperEl.requestFullscreen();
|
|
}
|
|
})
|
|
this.playbackControlsEl.appendChild(fullScreenEl);
|
|
|
|
}
|
|
|
|
|
|
this.inPointPosition = [0, 0];
|
|
this.inPointTimeMs = null;
|
|
this.outPointPosition = null;
|
|
this.outPointTimeMs = null;
|
|
this._currentTimeMs = 0;
|
|
this.videoIsPlaying = false;
|
|
|
|
const groups = ['background', 'before', 'annotation', 'after']
|
|
this.strokeGroups = {};
|
|
groups.forEach(group => {
|
|
let groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
groupEl.classList.add(group)
|
|
this.svgEl.appendChild(groupEl);
|
|
this.strokeGroups[group] = new StrokeGroup(groupEl, this);
|
|
});
|
|
|
|
this.annotations = [];
|
|
|
|
if (this.config.is_player) {
|
|
this.load(fileurl);
|
|
} else {
|
|
|
|
this.loadTags(tagFile).then(() => {
|
|
this.tagsEl = document.createElement('ul');
|
|
this.tagsEl.classList.add('tags');
|
|
const addTags = (tags, tagsEl) => {
|
|
tags.forEach((tag) => {
|
|
let tagLiEl = document.createElement('li');
|
|
let tagEl = document.createElement('div');
|
|
|
|
tagEl.classList.add('tag');
|
|
tagEl.dataset.tag = tag.id;
|
|
tagEl.innerText = tag.hasOwnProperty('name') ? tag.name : tag.id;
|
|
tagEl.addEventListener('click', (e) => {
|
|
this.addTag(tag.id, this.inPointPosition, this.outPointPosition);
|
|
});
|
|
|
|
tagEl.title = tag.hasOwnProperty('description') ? tag.description : "";
|
|
|
|
let signEl = document.createElement('span');
|
|
signEl.classList.add('annotation-' + tag.id);
|
|
signEl.style.backgroundColor = this.getColorForTag(tag.id);
|
|
tagEl.prepend(signEl);
|
|
|
|
tagLiEl.appendChild(tagEl);
|
|
|
|
if (tag.hasOwnProperty('children')) {
|
|
const subEl = document.createElement('ul');
|
|
subEl.classList.add('subtags');
|
|
addTags(tag.children, subEl);
|
|
tagLiEl.appendChild(subEl);
|
|
}
|
|
|
|
tagsEl.appendChild(tagLiEl);
|
|
});
|
|
};
|
|
addTags(this.tags, this.tagsEl);
|
|
|
|
let tagEl = document.createElement('li');
|
|
tagEl.classList.add('tag');
|
|
tagEl.classList.add('annotation-rm');
|
|
tagEl.dataset.tag = 'rm';
|
|
tagEl.title = "Remove annotation";
|
|
tagEl.innerHTML = "🚮"; // ×
|
|
tagEl.addEventListener('click', (e) => {
|
|
if (this.selectedAnnotation) {
|
|
this.removeAnnotation(this.selectedAnnotationI);
|
|
}
|
|
});
|
|
this.tagsEl.appendChild(tagEl);
|
|
|
|
this.wrapperEl.appendChild(this.tagsEl);
|
|
|
|
this.commentEl = document.createElement('input');
|
|
this.commentEl.type = 'text';
|
|
this.commentEl.classList.add('annotation-comment');
|
|
this.commentEl.title = "Add comment to annotation";
|
|
this.commentEl.placeholder = "comment";
|
|
this.commentEl.value = "";
|
|
this.commentEl.addEventListener('keyup', (e) => {
|
|
if (e.key == 'Escape') {
|
|
this.commentEl.blur() // deselect annotation, and deselect commentEl
|
|
} else {
|
|
e.stopPropagation(); // prevent keyup event to propagate and set i/o points
|
|
}
|
|
});
|
|
this.commentEl.addEventListener('input', (e) => {
|
|
e.stopPropagation(); // prevent keyup event
|
|
if (this.selectedAnnotation) {
|
|
this.selectedAnnotation.comment = this.commentEl.value;
|
|
this.updateAnnotations(true)
|
|
}
|
|
});
|
|
this.controlsEl.appendChild(this.commentEl);
|
|
|
|
this.load(fileurl);
|
|
});
|
|
}
|
|
}
|
|
|
|
getColorForTag(tag_id) {
|
|
const tag = this.tagMap[tag_id];
|
|
// console.log(tag_id, tag);
|
|
if (tag && tag.hasOwnProperty('color') && tag.color) {
|
|
return tag.color;
|
|
}
|
|
if (tag && tag.hasOwnProperty('parent') && tag.parent) {
|
|
return this.getColorForTag(tag['parent'].id);
|
|
}
|
|
return 'black';
|
|
}
|
|
|
|
updateAnnotations(save) {
|
|
|
|
if (this.config.is_player) {
|
|
return false;
|
|
}
|
|
|
|
this.annotationsEl.innerHTML = "";
|
|
for (let annotation_i in this.annotations) {
|
|
const annotation = this.annotations[annotation_i];
|
|
this.annotationEl = document.createElement('div');
|
|
const prerollDiff = Number.parseFloat(this.audioOffset < 0 ? this.audioOffset * -1000 : 0);
|
|
// console.log('diff', prerollDiff, annotation.t_in, typeof annotation.t_in, this.duration,annotation.t_in + prerollDiff, (annotation.t_in + prerollDiff) / this.duration);
|
|
const left = ((annotation.t_in + prerollDiff) / (this.duration * 1000)) * 100;
|
|
const right = 100 - ((annotation.t_out + prerollDiff) / (this.duration * 1000)) * 100;
|
|
this.annotationEl.style.left = left + '%';
|
|
this.annotationEl.style.right = right + '%';
|
|
|
|
this.annotationEl.style.backgroundColor = this.getColorForTag(annotation.tag);
|
|
|
|
this.annotationEl.classList.add('annotation-' + annotation.tag);
|
|
if (this.selectedAnnotationI == annotation_i) {
|
|
this.annotationEl.classList.add('selected');
|
|
}
|
|
this.annotationEl.title = `[${annotation.tag}] ${annotation.comment} `;
|
|
|
|
this.annotationEl.addEventListener('mouseover', (e) => {
|
|
|
|
});
|
|
this.annotationEl.addEventListener('mouseout', (e) => {
|
|
|
|
});
|
|
this.annotationEl.addEventListener('click', (e) => {
|
|
if (this.selectedAnnotationI == annotation_i) {
|
|
this.deselectAnnotation(false);
|
|
} else {
|
|
this.selectAnnotation(annotation_i);
|
|
}
|
|
});
|
|
|
|
this.annotationsEl.appendChild(this.annotationEl);
|
|
}
|
|
|
|
this.tagsEl.querySelectorAll('.tag').forEach(tagEl => {
|
|
if (this.selectedAnnotation && this.selectedAnnotation.tag == tagEl.dataset.tag) {
|
|
tagEl.classList.add('selected')
|
|
} else {
|
|
tagEl.classList.remove('selected')
|
|
}
|
|
});
|
|
|
|
if (save) {
|
|
this.updateState();
|
|
}
|
|
}
|
|
|
|
selectAnnotation(annotation_i) {
|
|
this.selectedAnnotationI = annotation_i;
|
|
this.selectedAnnotation = this.annotations[annotation_i];
|
|
|
|
this.slider.set([this.selectedAnnotation.t_in, this.selectedAnnotation.t_out]);
|
|
|
|
this.inPointPosition = this.findPositionForTime(this.selectedAnnotation.t_in);
|
|
this.outPointPosition = this.findPositionForTime(this.selectedAnnotation.t_out);
|
|
this.inPointTimeMs = this.selectedAnnotation.t_in;
|
|
this.outPointTimeMs = this.selectedAnnotation.t_out;
|
|
this._seekByTimeMs(this.selectedAnnotation.t_in);
|
|
// draw full stroke of annotation:
|
|
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
|
|
|
this.updateAnnotations(false); //selects the right tag & highlights the annotation
|
|
|
|
this.wrapperEl.classList.add('selected-annotation');
|
|
this.commentEl.value = this.selectedAnnotation.comment;
|
|
}
|
|
|
|
deselectAnnotation(keep_position) {
|
|
if (this.selectedAnnotation) {
|
|
this._seekByTimeMs(this.selectedAnnotation.t_out);
|
|
}
|
|
|
|
this.wrapperEl.classList.remove('selected-annotation');
|
|
this.commentEl.value = "";
|
|
this.commentEl.blur(); // make sure we're not typing anymore
|
|
|
|
this.selectedAnnotationI = null;
|
|
this.selectedAnnotation = null;
|
|
|
|
if (!keep_position) {
|
|
this.setUpAnnotator();
|
|
}
|
|
this.updateAnnotations(false); // selects the right tag & highlights the annotation
|
|
}
|
|
|
|
setInPoint(time_ms) {
|
|
this.setInOutPoint(time_ms, this.outPointTimeMs);
|
|
}
|
|
setOutPoint(time_ms) {
|
|
this.setInOutPoint(this.inPointTimeMs, time_ms);
|
|
}
|
|
|
|
setInOutPoint(in_ms, out_ms) {
|
|
this.inPointPosition = this.findPositionForTime(in_ms);
|
|
this.inPointTimeMs = in_ms;
|
|
this.outPointPosition = this.findPositionForTime(out_ms);
|
|
this.outPointTimeMs = out_ms;
|
|
// this._seekByTimeMs(this.audioOffset < 0 ? this.audioOffset * 1000 : 0);
|
|
// draw full stroke of annotation
|
|
console.debug('setInOut');
|
|
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
|
console.debug([`${this.inPointTimeMs} `, `${this.outPointTimeMs} `])
|
|
this.slider.set([this.inPointTimeMs, this.outPointTimeMs]);
|
|
|
|
// console.debug(this.selectedAnnotation);
|
|
if (this.selectedAnnotation) {
|
|
this.selectedAnnotation.t_in = in_ms;
|
|
this.selectedAnnotation.t_out = out_ms;
|
|
this.updateAnnotations(false);
|
|
}
|
|
}
|
|
|
|
resetInOutPoint() {
|
|
this.inPointPosition = [0, 0];
|
|
this.inPointTimeMs = null;
|
|
this.outPointPosition = null;
|
|
this.outPointTimeMs = null;
|
|
this._seekByTimeMs(this.audioOffset < 0 ? this.audioOffset * 1000 : 0);
|
|
// draw full stroke of annotation
|
|
console.debug('reset!');
|
|
this.drawStrokePosition(this.inPointPosition, [Infinity, Infinity]);
|
|
this.setUpAnnotator();
|
|
}
|
|
|
|
load(file) {
|
|
const request = new Request(file, {
|
|
method: 'GET',
|
|
});
|
|
|
|
this.wrapperEl.classList.add('loading');
|
|
|
|
fetch(request)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (!this.config.is_player) {
|
|
|
|
const metadata_req = new Request(`/annotations/${data.file}`, {
|
|
method: 'GET',
|
|
});
|
|
return fetch(metadata_req)
|
|
.then(response => response.ok ? response.json() : null)
|
|
.then(metadata => {
|
|
if (metadata !== null) {
|
|
metadata.annotations = metadata.annotations.map((a) => new Annotation(a.tag, a.t_in, a.t_out, a.hasOwnProperty('comment') ? a.comment : ""))
|
|
}
|
|
return this.loadStrokes(data, metadata)
|
|
})
|
|
.catch(e => console.error(e));
|
|
} else {
|
|
return this.loadStrokes(data, null);
|
|
}
|
|
})
|
|
.then(() => {
|
|
// play on click for 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) {
|
|
this.play(); // play should remove loading
|
|
} else {
|
|
this.wrapperEl.classList.remove('loading');
|
|
}
|
|
})
|
|
.catch(e => console.debug(e));
|
|
}
|
|
|
|
updateState() {
|
|
const state = {
|
|
'title': this.title,
|
|
'file': this.filename,
|
|
'annotations': this.annotations,
|
|
'audio': {
|
|
'file': this.audioFile,
|
|
'offset': this.audioOffset,
|
|
}
|
|
}
|
|
const newState = JSON.stringify(state);
|
|
if (newState == this.state) {
|
|
return;
|
|
}
|
|
|
|
this.wrapperEl.classList.remove('saved');
|
|
this.wrapperEl.classList.add('unsaved');
|
|
this.state = newState;
|
|
// autosave on state change:
|
|
this.save(newState);
|
|
}
|
|
|
|
setSaved(state) {
|
|
if (this.state != state) {
|
|
console.debug('already outdated');
|
|
}
|
|
else {
|
|
this.wrapperEl.classList.add('saved');
|
|
this.wrapperEl.classList.remove('unsaved');
|
|
}
|
|
}
|
|
|
|
save(state) {
|
|
const request = new Request("/annotations/" + this.filename, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: state
|
|
});
|
|
fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
this.setSaved(state);
|
|
}
|
|
else {
|
|
throw Error('Something went wrong');
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
});
|
|
|
|
}
|
|
|
|
removeAnnotation(annotation_i) {
|
|
this.deselectAnnotation(true);
|
|
this.annotations.splice(annotation_i, 1);
|
|
this.updateAnnotations(true);
|
|
}
|
|
|
|
addTag(tag) {
|
|
if (this.selectedAnnotation) {
|
|
this.selectedAnnotation.tag = tag;
|
|
this.updateAnnotations(true);
|
|
} else {
|
|
|
|
// TODO this.slider values for in and out
|
|
const [t_in, t_out] = this.slider.get();
|
|
if (this.slider) {
|
|
this.slider.destroy();
|
|
}
|
|
|
|
this.annotations.push(new Annotation(tag, t_in, t_out, ""));
|
|
this.updateAnnotations(true);
|
|
|
|
this._currentTimeMs = t_out;
|
|
this._updatePlayhead();
|
|
this.setUpAnnotator();
|
|
}
|
|
}
|
|
|
|
|
|
setUpAnnotator() {
|
|
|
|
this.playheadEl.min = this.audioOffset < 0 ? this.audioOffset * 1000 : 0;
|
|
this.playheadEl.max = this.getEndTimeMs();
|
|
this._updatePlayhead();
|
|
|
|
|
|
this.inPointPosition = this.findPositionForTime(this.currentTime);
|
|
this.inPointTimeMs = this._currentTimeMs;
|
|
this.outPointPosition = this.findPositionForTime(this.lastFrameTime); // TODO: simplify to get the last frame indexes directly
|
|
this.outPointTimeMs = this.getEndTimeMs();
|
|
|
|
|
|
if (!this.config.is_player) {
|
|
this.buildAnnotator();
|
|
}
|
|
|
|
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
|
}
|
|
|
|
buildAnnotator() {
|
|
|
|
if (this.scrubberEl.noUiSlider) {
|
|
this.slider.destroy();
|
|
}
|
|
|
|
// console.log(this._currentTimeMs, )
|
|
const sliderMin = this.audioOffset < 0 ? this.audioOffset * 1000 : 0;
|
|
const sliderMax = this.getEndTimeMs();
|
|
this.slider = noUiSlider.create(this.scrubberEl, {
|
|
start: [this._currentTimeMs, this.getEndTimeMs()],
|
|
connect: true,
|
|
range: {
|
|
'min': sliderMin,
|
|
'max': sliderMax,
|
|
},
|
|
keyboardDefaultStep: (sliderMax - sliderMin) / 1000,
|
|
keyboardPageMultiplier: 10, // page up/down 10s
|
|
tooltips: [
|
|
this.formatter,
|
|
this.formatter
|
|
],
|
|
// pips: {
|
|
// mode: 'range',
|
|
// density: 3,
|
|
// format: this.formatter
|
|
// }
|
|
});
|
|
|
|
this.slider.on("slide", (values, handle) => {
|
|
this.videoIsPlaying = false;
|
|
this.inPointPosition = this.findPositionForTime(values[0]);
|
|
this.inPointTimeMs = Number.parseFloat(values[0]);
|
|
this.outPointPosition = this.findPositionForTime(values[1]);
|
|
this.outPointTimeMs = Number.parseFloat(values[1]);
|
|
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
|
|
|
// console.log(this.selectedAnnotation);
|
|
if (this.selectedAnnotation) {
|
|
this.selectedAnnotation.t_in = Number.parseFloat(values[0]);
|
|
this.selectedAnnotation.t_out = Number.parseFloat(values[1]);
|
|
this.updateAnnotations(false);
|
|
}
|
|
});
|
|
this.slider.on("end", (values, handle) => {
|
|
if (this.selectedAnnotation) {
|
|
this.updateAnnotations(true);
|
|
}
|
|
this._seekByTimeMs(values[0]);
|
|
this.play();
|
|
// this.playAudioSegment(values[0], values[1]);
|
|
});
|
|
|
|
this.slider.getTooltips().forEach((ttEl, i) => {
|
|
// console.log(ttEl, i);
|
|
ttEl.addEventListener('click', (e) => {
|
|
let ttInputEl = document.createElement('input');
|
|
ttInputEl.value = ttEl.innerHTML
|
|
ttEl.innerHTML = "";
|
|
ttEl.appendChild(ttInputEl);
|
|
ttInputEl.focus();
|
|
|
|
const submit = () => {
|
|
console.debug(ttInputEl.value);
|
|
const tcMs = this.formatter.from(ttInputEl.value);
|
|
let points = this.slider.get();
|
|
points[i] = tcMs;
|
|
console.debug(points);
|
|
this.slider.set(points);
|
|
};
|
|
ttInputEl.addEventListener('keydown', (keyE) => {
|
|
keyE.stopPropagation(); //prevent movement of tooltip
|
|
if (keyE.key == "Enter") {
|
|
submit();
|
|
}
|
|
})
|
|
ttInputEl.addEventListener('click', (clickE) => {
|
|
clickE.stopPropagation(); //prevent retrigger on selectino
|
|
})
|
|
ttInputEl.addEventListener('blur', submit);
|
|
});
|
|
})
|
|
}
|
|
|
|
loadStrokes(drawing, metadata) {
|
|
this.audioOffset = 0;
|
|
|
|
if (metadata) {
|
|
this.annotations = metadata.annotations;
|
|
}
|
|
|
|
if ((metadata && metadata.hasOwnProperty('audio')) || (drawing.hasOwnProperty('audio') && drawing.audio)) {
|
|
if (metadata && metadata.hasOwnProperty('audio')) {
|
|
this.audioFile = this.config.url_prefix + metadata.audio.file
|
|
this.audioOffset = Number.parseFloat(metadata.audio.offset);
|
|
} else {
|
|
this.audioFile = this.config.url_prefix + drawing.audio.file
|
|
this.audioOffset = Number.parseFloat(drawing.audio.offset);
|
|
}
|
|
this._currentTimeMs = this.audioOffset < 0 ? this.audioOffset * 1000 : 0;
|
|
this._updatePlayhead();
|
|
}
|
|
|
|
this.title = null;
|
|
if (metadata && metadata.hasOwnProperty('title')) {
|
|
this.title = metadata.title;
|
|
}
|
|
else if (drawing.hasOwnProperty('title')) {
|
|
this.title = drawing.title;
|
|
}
|
|
|
|
this.filename = drawing.file;
|
|
this.strokes = drawing.shape.map(s => new Stroke(s['color'], s['points']));
|
|
this.backgroundStrokes = drawing.hasOwnProperty('background') ? drawing.background : [];
|
|
this.backgroundBoundingBox = drawing.hasOwnProperty('background_bounding_box') ? drawing.background_bounding_box : null;
|
|
this.viewboxes = drawing.viewboxes;
|
|
this.currentPathI = null;
|
|
this.currentPointI = null;
|
|
this.currentViewboxI = null;
|
|
this.dimensions = drawing.dimensions;
|
|
this.bounding_box = drawing.bounding_box;
|
|
this.updateViewbox();
|
|
|
|
// let bgEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
// bgEl.setAttribute("x", 0);
|
|
// bgEl.setAttribute("y", 0);
|
|
// bgEl.setAttribute("width", this.dimensions[0]);
|
|
// 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;
|
|
this.nextFrameTimeout = null;
|
|
this.nextViewboxTimeout = null;
|
|
this._setPausedFlag(true);
|
|
|
|
if(this.backgroundStrokes && this.backgroundStrokes.length){
|
|
this.strokeGroups['background'].setPrecomputedStrokes(this.backgroundStrokes)
|
|
}
|
|
|
|
return this.setupAudioConfig().then(() => {
|
|
// this.setUpAnnotator()
|
|
let keyEl;
|
|
if (this.config.is_player) {
|
|
keyEl = this.wrapperEl;
|
|
} else {
|
|
keyEl = document.body; // always capture
|
|
this.updateAnnotations(false);
|
|
}
|
|
|
|
keyEl.addEventListener('keyup', (ev) => {
|
|
if (ev.key == ' ') {
|
|
this.playPause();
|
|
}
|
|
|
|
// shift+arrow keys, jump playhead (search position)
|
|
// FIXME doesn't keep playback after initial load. Only after unfocussing the window, and refocussing it, do the keys capture.
|
|
// Probably a wrong order
|
|
if (ev.key == 'ArrowLeft' && ev.shiftKey) {
|
|
const p = this._paused;
|
|
const diff = ev.ctrlKey ? 10000 : 1000;
|
|
this.scrubTo(this._currentTimeMs - diff);
|
|
if (!p) { this.play(); } // scrubTo() causes a pause();
|
|
}
|
|
if (ev.key == 'ArrowRight' && ev.shiftKey) {
|
|
const p = this._paused;
|
|
const diff = ev.ctrlKey ? 10000 : 1000;
|
|
this.scrubTo(this._currentTimeMs + diff);
|
|
if (!p) { this.play(); } // scrubTo() causes a pause();
|
|
}
|
|
|
|
// additional keys only for annotation mode
|
|
if (!this.config.is_player) {
|
|
if (ev.key == 'i') {
|
|
this.setInPoint(this.currentTime * 1000);
|
|
}
|
|
if (ev.key == 'o') {
|
|
this.setOutPoint(this.currentTime * 1000);
|
|
}
|
|
if (ev.key == 'I') {
|
|
// shift+i == jump to in point
|
|
this.scrubTo(this.inPointTimeMs);
|
|
}
|
|
if (ev.key == 'O') {
|
|
// shift+o == jump to end point
|
|
this.scrubTo(this.outPointTimeMs);
|
|
}
|
|
if (ev.key == 'Escape') {
|
|
if (this.selectedAnnotation) {
|
|
this.deselectAnnotation();
|
|
} else {
|
|
this.resetInOutPoint();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// this.playStrokePosition(0, 1);
|
|
}
|
|
|
|
|
|
loadTags(tagFile) {
|
|
// tags config
|
|
const request = new Request(tagFile);
|
|
return fetch(request)
|
|
.then(response => response.json())
|
|
.then(rootTag => {
|
|
this.tags = rootTag.children;
|
|
this.tagMap = {};
|
|
const addTagsToMap = (tags, parent) => {
|
|
tags.forEach((tag) => {
|
|
tag['parent'] = typeof parent != "undefined" ? parent : null;
|
|
this.tagMap[tag.id] = tag;
|
|
if (tag.hasOwnProperty("children")) {
|
|
addTagsToMap(tag.children, tag);
|
|
}
|
|
});
|
|
};
|
|
addTagsToMap(this.tags);
|
|
});
|
|
}
|
|
|
|
setupAudioConfig() {
|
|
// audio config
|
|
return new Promise((resolve, reject) => {
|
|
this.audioEl = document.createElement('audio');
|
|
if (!this.config.is_player)
|
|
this.audioEl.setAttribute('controls', true);
|
|
|
|
this.audioEl.addEventListener('canplaythrough', (ev) => {
|
|
console.debug('loaded audio');
|
|
// this.audioEl.play();
|
|
});
|
|
|
|
if (this.config.is_player) {
|
|
this.wrapperEl.prepend(this.audioEl);
|
|
}
|
|
else {
|
|
|
|
let mdConfigEl = document.createElement('div');
|
|
mdConfigEl.classList.add('metadataconfig')
|
|
this.wrapperEl.appendChild(mdConfigEl);
|
|
|
|
|
|
let titleEl = document.createElement('div');
|
|
titleEl.classList.add('drawing-title');
|
|
titleEl.innerText = this.title ?? "[add title]"
|
|
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
|
|
titleEl.innerText = title.length ? title : "[add title]";
|
|
this.title = title.length ? title : null;
|
|
this.updateState();
|
|
})
|
|
mdConfigEl.appendChild(titleEl);
|
|
|
|
let audioConfigEl = document.createElement('div');
|
|
audioConfigEl.classList.add('audioconfig')
|
|
mdConfigEl.appendChild(audioConfigEl);
|
|
|
|
|
|
audioConfigEl.appendChild(this.audioEl);
|
|
|
|
let audioSelectEl = document.createElement('select');
|
|
audioSelectEl.classList.add('audioselect');
|
|
audioConfigEl.appendChild(audioSelectEl);
|
|
|
|
fetch('/audio')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
data.unshift(''); // add empty, to deselect any file
|
|
data.forEach(audioFile => {
|
|
let optionEl = document.createElement('option');
|
|
optionEl.selected = this.audioFile == audioFile;
|
|
optionEl.innerText = audioFile;
|
|
audioSelectEl.appendChild(optionEl);
|
|
});
|
|
})
|
|
|
|
audioSelectEl.addEventListener('change', (ev) => {
|
|
this.setAudioFile(ev.target.value);
|
|
});
|
|
|
|
|
|
let audioOffsetTextEl = document.createElement('label');
|
|
audioOffsetTextEl.innerText = "Offset (s)";
|
|
audioConfigEl.appendChild(audioOffsetTextEl);
|
|
|
|
let audioOffsetEl = document.createElement('input');
|
|
audioOffsetEl.setAttribute('type', 'number');
|
|
audioOffsetEl.setAttribute('step', '.01');
|
|
audioOffsetEl.value = this.audioOffset ?? 0;
|
|
audioOffsetEl.addEventListener('change', (ev) => {
|
|
this.setAudioOffset(ev.target.value);
|
|
});
|
|
audioOffsetTextEl.appendChild(audioOffsetEl);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.audioEl.addEventListener('loadedmetadata', (ev) => {
|
|
// resolve the 'set up audio' when metadata has loaded
|
|
this.setUpAnnotator(); // if offset is negative, annotator starts at negative time
|
|
resolve();
|
|
})
|
|
if (this.audioFile) {
|
|
this.audioEl.setAttribute('src', this.audioFile);
|
|
} else {
|
|
this.setUpAnnotator();
|
|
resolve();
|
|
}
|
|
|
|
});
|
|
}
|
|
|
|
setAudioFile(audioFile) {
|
|
this.audioFile = audioFile;
|
|
this.audioEl.setAttribute('src', this.audioFile);
|
|
// this.audioEl.play();
|
|
// TODO update playhead
|
|
// TODO update this.duration after load
|
|
this.updateState();
|
|
}
|
|
|
|
setAudioOffset(audioOffset) {
|
|
this.audioOffset = Number.parseFloat(audioOffset);
|
|
// TODO update playhead
|
|
// TODO update this.duration
|
|
this.setUpAnnotator(); // if offset is negative, annotator starts at negative time
|
|
this.updateState();
|
|
}
|
|
|
|
/**
|
|
* @param float time time is ms
|
|
* @returns float
|
|
*/
|
|
getAudioTime(time) {
|
|
return Number.parseFloat(time) - (this.audioOffset * 1000 ?? 0);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param float t_in in point time, in ms
|
|
* @param float t_out out point time, in ms
|
|
*/
|
|
playAudioSegment(t_in, t_out) {
|
|
if (this.audioStartTimeout) clearTimeout(this.audioStartTimeout);
|
|
if (this.audioEndTimeout) clearTimeout(this.audioEndTimeout);
|
|
|
|
// TODO, handle playback delay
|
|
const t_start = this.getAudioTime(t_in); // in ms
|
|
const t_diff = (t_out ?? this.audioEl.duration * 1000) - t_in; // in ms
|
|
|
|
this.audioEl.pause();
|
|
|
|
if (t_start < 0) {
|
|
if (t_diff <= t_start * -1) {
|
|
console.debug('no audio playback in segment', t_start, t_diff);
|
|
} else {
|
|
console.debug('delay audio playback', t_start, t_diff);
|
|
// a negative audiooffset delays playback from the start
|
|
// this.audioStartTimeout = setTimeout((e) => this.audioEl.play(), t*-1000);
|
|
this.audioStartTimeout = setTimeout((e) => { this.audioEl.currentTime = 0; this.audioEl.play(); }, t_start * -1); // triggers play with "seeked" event
|
|
// this.audioEl.currentTime = 0;
|
|
}
|
|
} else {
|
|
if (this.audioEl.currentTime !== t_start / 1000) {
|
|
console.debug(this.audioEl.currentTime, t_start / 1000);
|
|
this.audioEl.currentTime = t_start / 1000;
|
|
}
|
|
this.audioEl.play();
|
|
// this.audioEl.play(); // play is done in "seeked" evenlistener
|
|
console.debug(this.audioEl.currentTime, t_start, t_in, t_out);
|
|
}
|
|
|
|
this.audioIsPlaying = true; // also state as playing in preroll
|
|
this.audioEndTimeout = setTimeout((e) => {
|
|
this.audioEl.pause();
|
|
this.audioIsPlaying = false;
|
|
console.debug('done playing audio');
|
|
}, t_diff);
|
|
}
|
|
|
|
_scrubAudio(time_ms) {
|
|
this.audioEl.currentTime = Math.max(0, this.getAudioTime(time_ms)) / 1000;
|
|
}
|
|
|
|
getFinalFrameTime() {
|
|
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];
|
|
}
|
|
|
|
getStrokesSliceForPathRange(in_point, out_point) {
|
|
// get paths for given range. Also, split path at in & out if necessary.
|
|
let slices = {};
|
|
for (let i = in_point[0]; i <= out_point[0]; i++) {
|
|
const stroke = this.strokes[i];
|
|
if (typeof stroke === 'undefined') {
|
|
// out point can be Infinity. So interrupt whenever the end is reached
|
|
break;
|
|
}
|
|
const in_i = (in_point[0] === i) ? in_point[1] : 0;
|
|
const out_i = (out_point[0] === i) ? out_point[1] : Infinity;
|
|
|
|
slices[i] = new StrokeSlice(stroke, in_i, out_i);
|
|
}
|
|
return slices;
|
|
}
|
|
|
|
|
|
// TODO: when drawing, have a group active & inactive.
|
|
// active is getPathRange(currentIn, currentOut)
|
|
// inactive is what comes before and after.
|
|
// then, playing the video is just running pathRanghe(0, playhead)
|
|
drawStrokePosition(in_point, out_point, show_all) {
|
|
if (typeof show_all === 'undefined')
|
|
show_all = true;
|
|
|
|
this.strokeGroups['before'].setStrokes(this.getStrokesSliceForPathRange([0, 0], in_point));
|
|
this.strokeGroups['annotation'].setStrokes(this.getStrokesSliceForPathRange(in_point, out_point));
|
|
this.strokeGroups['after'].setStrokes(this.getStrokesSliceForPathRange(out_point, [Infinity, Infinity]));
|
|
|
|
|
|
// // an inpoint is set, so we're annotating
|
|
// // make everything coming before translucent
|
|
// if (this.inPointPosition !== null) {
|
|
// const [inPath_i, inPoint_i] = this.inPointPosition;
|
|
// // returns a static NodeList
|
|
// const currentBeforeEls = this.svgEl.querySelectorAll(`.before_in`);
|
|
// for (let currentBeforeEl of currentBeforeEls) {
|
|
// currentBeforeEl.classList.remove('before_in');
|
|
// }
|
|
|
|
// for (let index = 0; index < inPath_i; index++) {
|
|
// const pathEl = this.svgEl.querySelector(`.path${ index }`);
|
|
// if (pathEl) {
|
|
// pathEl.classList.add('before_in');
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// this.currentPathI = path_i;
|
|
// this.currentPointI = point_i;
|
|
|
|
// const path = this.strokes[path_i];
|
|
// // console.log(path);
|
|
// 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;
|
|
// pathEl.classList.add('path' + path_i)
|
|
// this.svgEl.appendChild(pathEl)
|
|
// }
|
|
|
|
// const stroke = path.points.slice(0, point_i);
|
|
// const d = this.strokes2D(stroke);
|
|
// pathEl.setAttribute('d', d);
|
|
|
|
// this.scrubberElOld.value = path.points[point_i][3];
|
|
// this.currentTime = path.points[point_i][3];
|
|
}
|
|
|
|
setViewboxPosition(box_i) {
|
|
if (this.currentViewboxI == box_i) {
|
|
return;
|
|
}
|
|
this.currentViewboxI = box_i
|
|
if (!this.config.crop_to_fit) {
|
|
this.updateViewbox();
|
|
}
|
|
}
|
|
|
|
updateViewbox() {
|
|
if (this.config.crop == CropOptions.Fit_Selection) {
|
|
this.svgEl.setAttribute('viewBox', `${this.bounding_box.x} ${this.bounding_box.y} ${this.bounding_box.width} ${this.bounding_box.height}`);
|
|
} else if (this.config.crop == CropOptions.Whole_Drawing && this.backgroundBoundingBox) {
|
|
this.svgEl.setAttribute('viewBox', `${this.backgroundBoundingBox.x} ${this.backgroundBoundingBox.y} ${this.backgroundBoundingBox.width} ${this.backgroundBoundingBox.height}`);
|
|
} else {
|
|
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];
|
|
} else {
|
|
x = 0,
|
|
y = 0,
|
|
w = this.dimensions[0],
|
|
h = this.dimensions[1];
|
|
}
|
|
this.svgEl.setAttribute('viewBox', `${x} ${y} ${w} ${h}`);
|
|
}
|
|
}
|
|
|
|
setCrop(crop_option) {
|
|
if(Object.values(CropOptions).indexOf(crop_option) === -1) {
|
|
console.error('invalid crop option', crop_option);
|
|
crop_option = CropOptions.Fit_Selection;
|
|
}
|
|
|
|
this.config.crop = crop_option;
|
|
for(let option of Object.values(CropOptions)) {
|
|
if (this.config.crop == option) {
|
|
this.wrapperEl.classList.add('crop-' + option);
|
|
} else {
|
|
this.wrapperEl.classList.remove('crop-' + option);
|
|
}
|
|
}
|
|
|
|
this.toggleCropPlayerEl.innerText = CropDescriptions[this.config.crop];
|
|
this.updateViewbox();
|
|
}
|
|
|
|
toggleCrop() {
|
|
console.log(this.config.crop, Object.values(CropOptions), Object.values(CropOptions).indexOf(this.config.crop))
|
|
const i = (Object.values(CropOptions).indexOf(this.config.crop) + 1) % Object.keys(CropOptions).length;
|
|
const newCrop = Object.values(CropOptions)[i];
|
|
console.log(i, newCrop);
|
|
this.setCrop(newCrop);
|
|
}
|
|
|
|
getNextPosition(path_i, point_i) {
|
|
const path = this.strokes[path_i];
|
|
let next_path, next_point;
|
|
if (path.points.length > point_i + 1) {
|
|
next_path = path_i;
|
|
next_point = point_i + 1;
|
|
// setTimeout(() => this.playStroke(next_path, next_point), dt);
|
|
} else if (this.strokes.length > path_i + 1) {
|
|
next_path = path_i + 1;
|
|
next_point = 0;
|
|
// use starttime instead of diff, to prevent floating
|
|
} else {
|
|
return [null, null];
|
|
}
|
|
|
|
// when an outpoint is set, stop playing there
|
|
if (this.outPointPosition && (next_path > this.outPointPosition[0] ||
|
|
(next_path == this.outPointPosition[0] && next_point > this.outPointPosition[1]))) {
|
|
console.debug('> out point', this.outPointPosition)
|
|
return [null, null];
|
|
}
|
|
|
|
return [next_path, next_point];
|
|
}
|
|
|
|
playStrokePosition(path_i, point_i, allow_interrupt) {
|
|
if (this.strokes.length === 0) {
|
|
console.debug('No video to play back');
|
|
this.videoIsPlaying = false;
|
|
return;
|
|
}
|
|
|
|
if (allow_interrupt) {
|
|
if (!this.videoIsPlaying) {
|
|
console.debug('not playing because of interrupt');
|
|
return;
|
|
}
|
|
} else {
|
|
this.videoIsPlaying = true;
|
|
}
|
|
this.drawStrokePosition(this.inPointPosition, [path_i, point_i]);
|
|
|
|
const [next_path, next_point] = this.getNextPosition(path_i, point_i);
|
|
if (next_path === null) {
|
|
console.debug('done playing video');
|
|
this.videoIsPlaying = false;
|
|
return;
|
|
}
|
|
|
|
const t = this.strokes[next_path].points[next_point][3];// - path.points[point_i][3];
|
|
|
|
// calculate interval based on playback start to avoid drifting of time
|
|
const dt = t - (window.performance.now() - this.startTimeMs);
|
|
this.nextFrameTimeout = setTimeout(() => this.playStrokePosition(next_path, next_point, true), dt);
|
|
}
|
|
|
|
|
|
playViewboxPosition(box_i, allow_interrupt) {
|
|
if (allow_interrupt) {
|
|
if (!this.videoIsPlaying) {
|
|
console.debug('not playing because of interrupt');
|
|
return;
|
|
}
|
|
}
|
|
// else {
|
|
// this.videoIsPlaying = true;
|
|
// }
|
|
this.setViewboxPosition(box_i);
|
|
|
|
const next_box_i = box_i + 1;
|
|
if (this.viewboxes.length <= next_box_i) {
|
|
console.debug('done playing viewbox');
|
|
return;
|
|
}
|
|
|
|
const t = this.viewboxes[next_box_i].t;
|
|
|
|
// calculate interval based on playback start to avoid drifting of time
|
|
const dt = t - (window.performance.now() - this.startTimeMs);
|
|
this.nextViewboxTimeout = setTimeout(() => this.playViewboxPosition(next_box_i, true), dt);
|
|
}
|
|
|
|
scrubTo(ms) {
|
|
// const [path_i, point_i] = this.findPositionForTime(ms);
|
|
// console.log(path_i, point_i);
|
|
this.pause();
|
|
this._seekByTime(ms / 1000);
|
|
// this.playHead = ms;
|
|
}
|
|
|
|
playPause() {
|
|
if (this.paused) {
|
|
this.play();
|
|
} else {
|
|
this.pause()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compatibility with HTMLMediaElement API
|
|
* @returns None
|
|
*/
|
|
pause() {
|
|
this._interruptPlayback();
|
|
}
|
|
|
|
_interruptPlayback() {
|
|
clearTimeout(this.nextFrameTimeout);
|
|
clearTimeout(this.nextViewboxTimeout);
|
|
clearTimeout(this.audioEndTimeout);
|
|
clearTimeout(this.audioStartTimeout);
|
|
clearTimeout(this.startVideoTimeout);
|
|
this.audioEl.pause();
|
|
this.videoIsPlaying = false;
|
|
this.audioIsPlaying = false;
|
|
this._setPausedFlag(true);
|
|
}
|
|
|
|
/**
|
|
* Compatibility with HTMLMediaElement API
|
|
* @returns Promise
|
|
*/
|
|
play() {
|
|
return new Promise((resolve, reject) => {
|
|
this._interruptPlayback();
|
|
|
|
if (this._currentTimeMs > this.outPointTimeMs) {
|
|
this._seekByTimeMs(this.inPointTimeMs);
|
|
} else {
|
|
this._seekByTimeMs(this._currentTimeMs); // prevent playback issue for initial load
|
|
}
|
|
|
|
this._setPausedFlag(false);
|
|
|
|
const startPlayback = () => {
|
|
console.debug('start playback');
|
|
this.wrapperEl.classList.remove('loading'); // no loading anymore
|
|
|
|
this.startTimeMs = window.performance.now() - this._currentTimeMs;
|
|
// strokes
|
|
if (this._currentTimeMs < 0) {
|
|
this.startVideoTimeout = setTimeout((e) => this.playStrokePosition(this.currentPathI, this.currentPointI), this._currentTimeMs * -1);
|
|
} else {
|
|
this.playStrokePosition(this.currentPathI, this.currentPointI);
|
|
}
|
|
// viewboxes
|
|
// const nextViewboxI = Math.max(this.currentViewboxI++, this.viewboxes.length-1);
|
|
this.playViewboxPosition(this.currentViewboxI);
|
|
|
|
// audio
|
|
// TODO: use this.audioEl.readyState == 4 : play immediately, otherwise after event
|
|
this.playAudioSegment(this._currentTimeMs, this.outPointTimeMs);
|
|
// this.playStrokePosition(this.currentPathI, this.currentPointI);
|
|
|
|
this.dispatchEvent(new CustomEvent('play', {}));
|
|
this._animationFrame();
|
|
resolve();
|
|
}
|
|
|
|
if (this.audioEl.src.length && this.audioEl.readyState !== 4) { // not ready to play after seeking audio.
|
|
console.debug('wait for audio before playback');
|
|
this.wrapperEl.classList.add('loading');
|
|
this.audioEl.addEventListener('canplaythrough', () => {
|
|
startPlayback()
|
|
}, { once: true }); // only once
|
|
} else {
|
|
startPlayback();
|
|
}
|
|
|
|
});
|
|
}
|
|
|
|
_setPausedFlag(paused) {
|
|
this._paused = !!paused; //convert to boolean
|
|
if (paused) {
|
|
this.playPauseEl.classList.remove('playing');
|
|
this.playPauseEl.classList.add('paused');
|
|
} else {
|
|
this.playPauseEl.classList.remove('paused');
|
|
this.playPauseEl.classList.add('playing');
|
|
}
|
|
}
|
|
|
|
get paused() {
|
|
return this._paused;
|
|
}
|
|
|
|
_updatePlayhead() {
|
|
this.playheadEl.value = this._currentTimeMs;
|
|
this.timeCodeEl.value = this.formatter.to(this._currentTimeMs);
|
|
}
|
|
|
|
|
|
// on playback, run every windowAnimationFrame
|
|
_animationFrame(timestamp) {
|
|
// TODO, move time at end of playStrokePosition to here
|
|
const nextTime = window.performance.now() - this.startTimeMs;
|
|
const endTime = this.outPointTimeMs ?? this.duration * 1000;
|
|
let interrupt = false;
|
|
if (nextTime > endTime) {
|
|
this._currentTimeMs = endTime;
|
|
interrupt = true;
|
|
} else {
|
|
this._currentTimeMs = nextTime;
|
|
}
|
|
this._updatePlayhead();
|
|
if (!interrupt && (this.videoIsPlaying || this.audioIsPlaying)) {
|
|
window.requestAnimationFrame((timestamp) => this._animationFrame(timestamp));
|
|
} else {
|
|
console.debug('finished playback');
|
|
this._interruptPlayback(true);
|
|
// this.resetPlayhead(); // Disable to not jump to start on pause. TODO: check if this causes issues e.g. on end
|
|
}
|
|
}
|
|
|
|
resetPlayhead() {
|
|
this._seekByTimeMs(this.inPointTimeMs);
|
|
if (this.selectedAnnotation) {
|
|
// show the hole selected annotation
|
|
this.drawStrokePosition(this.inPointPosition, this.outPointPosition);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Note that both t_in and t_out can be negative
|
|
* @param float|Array t_in in point time, in ms or array with path/frame points
|
|
* @param float|Array t_out out point time, in ms or array with path/frame points
|
|
*/
|
|
playSegment(in_point, out_point) {
|
|
if (!Array.isArray(in_point)) in_point = this.findPositionForTime(in_point);
|
|
if (!Array.isArray(out_point)) out_point = this.findPositionForTime(out_point);
|
|
|
|
this.inPointPosition = in_point;
|
|
this.outPointPosition = out_point;
|
|
this._seekByPoint(in_point);
|
|
|
|
this.play();
|
|
}
|
|
|
|
_seekByPoint(point) {
|
|
this.dispatchEvent(new CustomEvent('seeking', {}));
|
|
this._currentTimeMs = this.strokes[point[0]].points[point[1]][2];
|
|
this.audioEl.currentTime = this.getAudioTime(this._currentTimeMs) / 1000;
|
|
[this.currentPathI, this.currentPointI] = point;
|
|
this._updatePlayhead();
|
|
this._updateFrame();
|
|
// TODO set audio, wait for promise to finish
|
|
this.dispatchEvent(new CustomEvent('seeked', {}));
|
|
|
|
}
|
|
_seekByTimeMs(time) {
|
|
this._seekByTime(Number.parseFloat(time) / 1000);
|
|
}
|
|
_seekByTime(time) {
|
|
this.dispatchEvent(new CustomEvent('seeking', { detail: time }));
|
|
this._currentTimeMs = Number.parseFloat(time) * 1000;
|
|
this.audioEl.currentTime = this.getAudioTime(this._currentTimeMs) / 1000;
|
|
[this.currentPathI, this.currentPointI] = this.findPositionForTime(this._currentTimeMs);
|
|
|
|
this._updatePlayhead();
|
|
this._updateFrame();
|
|
this.dispatchEvent(new CustomEvent('seeked', { detail: this.currentTime }));
|
|
}
|
|
|
|
_updateFrame() {
|
|
this.drawStrokePosition(this.inPointPosition, [this.currentPathI, this.currentPointI]);
|
|
this.setViewboxPosition(this.findViewboxForTime(this._currentTimeMs));
|
|
}
|
|
|
|
/**
|
|
* For compatibility with HTMLMediaElement API convert seconds to ms of internal timer
|
|
*/
|
|
set currentTime(time) {
|
|
this._seekByTime(time);
|
|
}
|
|
|
|
get currentTime() {
|
|
return this._currentTimeMs / 1000;
|
|
}
|
|
|
|
getEndTimeMs() {
|
|
const videoDuration = this.getFinalFrameTime();
|
|
const audioDuration = (this.audioEl && this.audioEl.src) ? this.audioEl.duration + this.audioOffset : 0;
|
|
|
|
return Math.max(videoDuration, audioDuration * 1000);
|
|
}
|
|
|
|
get duration() {
|
|
|
|
const prerollDuration = this.audioOffset < 0 ? this.audioOffset * -1 : 0;
|
|
|
|
return prerollDuration + this.getEndTimeMs() / 1000;
|
|
}
|
|
|
|
findPositionForTime(ms) {
|
|
ms = Math.min(Math.max(ms, 0), this.lastFrameTime);
|
|
// console.log('scrub to', ms)
|
|
let path_i = 0;
|
|
let point_i = 0;
|
|
this.strokes.every((stroke, index) => {
|
|
const startAt = stroke.points[0][3];
|
|
const endAt = stroke.points[stroke.points.length - 1][3];
|
|
|
|
if (startAt > ms) {
|
|
return false; // too far
|
|
}
|
|
if (endAt > ms) {
|
|
// we're getting close. Find the right point_i
|
|
path_i = index;
|
|
stroke.points.every((point, pi) => {
|
|
if (point[3] > ms) {
|
|
// too far
|
|
return false;
|
|
}
|
|
point_i = pi;
|
|
return true;
|
|
});
|
|
return false;
|
|
} else {
|
|
// in case nothings comes after, we store the last best option thus far
|
|
path_i = index;
|
|
point_i = stroke.points.length - 1;
|
|
return true;
|
|
}
|
|
|
|
});
|
|
return [path_i, point_i];
|
|
}
|
|
|
|
findViewboxForTime(ms) {
|
|
ms = Math.min(Math.max(ms, 0), this.lastFrameTime);
|
|
// console.log('scrub to', ms)
|
|
let box_i = 0;
|
|
this.viewboxes.every((viewbox, index) => {
|
|
const startAt = viewbox.t;
|
|
|
|
if (startAt > ms) {
|
|
return false; // too far
|
|
} else {
|
|
// in case nothings comes after, we store the last best option thus far
|
|
box_i = index;
|
|
return true;
|
|
}
|
|
|
|
});
|
|
return box_i;
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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-crop (any of CropOptions)
|
|
// - autoplay
|
|
// - preload
|
|
// - data-poster-src
|
|
// - data-annotation-url
|
|
}
|
|
|
|
connectedCallback() {
|
|
// Create a shadow root
|
|
this.attachShadow({ mode: "open" });
|
|
|
|
const imgWrapEl = document.createElement('div');
|
|
imgWrapEl.classList.add('imgWrap');
|
|
|
|
const imgEl = document.createElement('img');
|
|
imgWrapEl.appendChild(imgEl);
|
|
|
|
const playerEl = document.createElement('div');
|
|
const url_prefix = this.hasAttribute('data-url-prefix') ? this.getAttribute('data-url-prefix') + '/' : '';
|
|
|
|
const config = {
|
|
is_player: true,
|
|
crop: this.hasAttribute('data-crop') ? this.getAttribute('data-crop') : null,
|
|
autoplay: true,
|
|
url_prefix: url_prefix,
|
|
stroke_color: this.hasAttribute('stroke') ? this.getAttribute('stroke') : null
|
|
}
|
|
|
|
const start = () => {
|
|
imgWrapEl.style.display = 'none';
|
|
this.annotator = new Annotator(
|
|
playerEl,
|
|
null, //"tags.json",
|
|
url_prefix + this.getAttribute('data-annotation-url'),
|
|
config
|
|
);
|
|
}
|
|
|
|
if(this.hasAttribute('data-poster-url')) {
|
|
imgEl.src = url_prefix + this.getAttribute('data-poster-url');
|
|
imgEl.addEventListener('click', start)
|
|
} else {
|
|
config.autoplay = false;
|
|
start();
|
|
}
|
|
|
|
playerEl.classList.add('play');
|
|
|
|
const styleEl = document.createElement('style');
|
|
styleEl.textContent = `
|
|
:host{
|
|
overflow: hidden;
|
|
}
|
|
|
|
:host(.active){
|
|
--override-color: orange
|
|
}
|
|
|
|
svg{
|
|
--disactive-path: rgba(220,220,220,.7)
|
|
}
|
|
|
|
div.imgWrap{
|
|
cursor:pointer;
|
|
}
|
|
div.imgWrap::before {
|
|
color:white;
|
|
content: '\u25B6';
|
|
background: rgba(0,0,0,0.5);
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
width: 50px;
|
|
display: block;
|
|
position: absolute;
|
|
left: calc(50% - 25px);
|
|
top: calc(50% - 25px);
|
|
text-align: center;
|
|
line-height: 47px;
|
|
height: 50px;
|
|
font-size: 20px;
|
|
pointer-events: none;
|
|
cursor:pointer;
|
|
}
|
|
div.imgWrap:hover::before{
|
|
background: rgba(0,0,0,0.2);
|
|
}
|
|
|
|
div.play, div.imgWrap {
|
|
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 .controls-fs {
|
|
width: 30px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
}
|
|
.controls .controls-fs:hover {
|
|
font-weight:bold;
|
|
}
|
|
|
|
.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%;
|
|
color:white;
|
|
}
|
|
svg .background {
|
|
fill: white
|
|
}
|
|
|
|
path {
|
|
fill: none;
|
|
stroke: var(--disactive-path);
|
|
stroke-width: 1mm;
|
|
stroke-linecap: round;
|
|
}
|
|
|
|
g.before path {
|
|
opacity: 0.5;
|
|
stroke: var(--disactive-path) !important;
|
|
}
|
|
|
|
g.after path,
|
|
path.before_in {
|
|
opacity: .1;
|
|
stroke: var(--disactive-path) !important;
|
|
}
|
|
|
|
.hide-drawing-preview g.after path, .hide-drawing-preview path.before_in{
|
|
opacity:0;
|
|
}
|
|
|
|
.background{
|
|
visibility: hidden
|
|
}
|
|
.play:not(.crop-selection) .background{
|
|
visibility: visible;
|
|
}
|
|
|
|
.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: 35px;
|
|
background: rgba(0,0,0, .5);
|
|
border-radius: 3px;
|
|
right: 0;
|
|
padding: 5px;
|
|
margin: 0;
|
|
list-style: none;
|
|
font-size: 10pt;
|
|
width: 150px;
|
|
}
|
|
|
|
details > ul li:not(.config-header):hover{
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
details .config-header {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.play:not(.hide-drawing-preview) details > ul li:first-child{
|
|
/*text-decoration: line-through;*/
|
|
font-weight:bold;
|
|
}
|
|
.play.crop-selection details > ul li:nth-child(2){
|
|
/*text-decoration: line-through;*/
|
|
/*font-weight:bold;*/
|
|
}
|
|
.play:not(.crop-selection) details .config-future{
|
|
display:none;
|
|
}
|
|
.annotation path.path{stroke: var(--override-color) !important; transition: stroke 1s;}
|
|
`;
|
|
|
|
if(config.stroke_color) {
|
|
// styleEl.textContent += `.annotation path.path{stroke: ${config.stroke_color} !important;}`
|
|
styleEl.textContent += `:host{--override-color: ${config.stroke_color}; }`
|
|
}
|
|
|
|
this.shadowRoot.appendChild(styleEl);
|
|
this.shadowRoot.appendChild(imgWrapEl);
|
|
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`)
|
|
}
|
|
|
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
// console.log(name, oldValue, newValue);
|
|
if (name == 'data-crop') {
|
|
if (!this.annotator) {
|
|
return;
|
|
}
|
|
this.annotator.setCrop(this.hasAttribute('data-crop'));
|
|
}
|
|
}
|
|
|
|
// required for attributeChangedCallback()
|
|
static get observedAttributes() { return ['data-crop']; }
|
|
|
|
}
|
|
|
|
window.customElements.define('annotation-player', AnnotationPlayer);
|