Skip to content

Instantly share code, notes, and snippets.

@bellbind
Last active April 4, 2022 05:08
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save bellbind/c77617cd0ec6ac84c5430fac44e901a1 to your computer and use it in GitHub Desktop.
[browser] Time Place marker Editor for video files
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Time Place Editor for Video files</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%2290%22 font-size=%2290%22>📌</text></svg>"/>
<script type="module" src="./main.js"></script>
<style>
#map {height: 40vh; width: 90vw;}
</style>
</head>
<body>
<div>
<label>Open movie file: <input id="file" type="file" /></label>
</div>
<div>
<div>
<video id="video" controls="controls" style="width: 90vw;" />
</div>
<div>
<button onclick="document.getElementById('video').currentTime = Math.ceil(document.getElementById('video').currentTime) - 1"></button>
<button onclick="document.getElementById('video').currentTime = Math.floor(document.getElementById('video').currentTime) + 1"></button>
</div>
</div>
<div>
<div>
<label>Overlay GPX: <input id="gpx" type="file" accept=".gpx" /></label>
<label>(info span: <input id="gpx-span" size="3" type="number" value="60" min="1" pattern="[1-9][0-9]*" />)</label>
<button id="gpx-clear">clear gpx</button>
<label>Import KML: <input id="kml" type="file" accept=".kml" /></label>
</div>
<div id="map"></div>
</div>
<div>
<label>Timelink Base URL: <input id="url" /></label>
(<label>type: <select id="url-type"><option value="youtube">Youtube</option></select></label>)<button id="export">export kml</button>
<label>WebVTT subtitle span: <input id="cue-sec" type="number" value="1" min="1" max="10" step="1" /> sec</label>
<button id="vtt">export vtt</button>
</div>
<div>
<div>
Time place list <button id="copy">copy</button>
</div>
<textarea id="list" style="width: 90vw; height: 20vh;"></textarea>
</div>
</body>
</html>
import {Dexie} from "https://unpkg.com/dexie@latest/dist/dexie.mjs";
import * as L from "https://unpkg.com/leaflet@1.7.1/dist/leaflet-src.esm.js";
const cssLink = document.createElement("link");
[cssLink.rel, cssLink.href] = ["stylesheet", "https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"];
document.getElementById("map").append(cssLink);
// TBD: save load as KML(DOMParser/XMLSerializer)
const state = {video: "", plots: []};
const map = L.map("map");
const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors`,
});
tileLayer.addTo(map);
map.locate({setView: true, maxZoom: 16});
const plotLayer = L.layerGroup();
plotLayer.addTo(map);
const gpxLayer = L.layerGroup();
gpxLayer.addTo(map);
const db = new Dexie("TimeMap");
//db.delete();
db.version(1).stores({
plots: "++id,[video+sec],lat,lng,label",
});
const video = document.getElementById("video");
video.addEventListener("timeupdate", ev => {
if (video.paused) return;
// Tracking markers
const t = video.currentTime;
const nextIndex = state.plots.findIndex(p => p.sec >= t);
if (nextIndex >= 0) {
const next = state.plots[nextIndex];
if (!next.marker.isPopupOpen()) next.marker.openPopup();
map.setView([next.lat, next.lng], map.getZoom(), {animate: false});
}
if (nextIndex > 0) {
const prev = state.plots[nextIndex - 1];
const next = state.plots[nextIndex];
const rate = (t - prev.sec) / (next.sec - prev.sec);
const lat = prev.lat + rate * (next.lat - prev.lat);
const lng = prev.lng + rate * (next.lng - prev.lng);
//map.setView([lat, lng], map.getZoom(), {animate: true});
map.setView([lat, lng], map.getZoom(), {animate: false});
}
});
video.addEventListener("keydown", ev => {
switch (ev.code) {
case "ArrowLeft":
video.currentTime = Math.ceil(video.currentTime) - (ev.shiftKey ? 10 : 1);
ev.preventDefault();
ev.stopPropagation();
return;
case "ArrowRight":
video.currentTime = Math.floor(video.currentTime) + (ev.shiftKey ? 10 : 1);
ev.preventDefault();
ev.stopPropagation();
return;
default: return;
}
}, {capture: true});
window.addEventListener("keydown", ev => {
switch (ev.code) {
case "ArrowLeft":
video.currentTime = Math.ceil(video.currentTime) - (ev.shiftKey ? 10 : 1);
return;
case "ArrowRight":
video.currentTime = Math.floor(video.currentTime) + (ev.shiftKey ? 10 : 1);
return;
default: return;
}
});
const fileInput = document.getElementById("file");
fileInput.addEventListener("input", ev => {
// load video
if (fileInput.files.length !== 1) return;
const file = fileInput.files[0];
const fileUrl = URL.createObjectURL(file); // each times mapped a diffrent url from a same file
video.src = fileUrl; //TBD
const track = document.createElement("track");
track.default = true;
track.kind = "captions";
for (const oldTrack of video.querySelectorAll("track")) oldTrack.remove();
video.append(track);
state.video = file.name;
/*
// blob.arrayBuffer() limited to < 2GB files
file.arrayBuffer().then(buf => crypto.subtle.digest("SHA-256", buf)).then(
hash => [...new Uint8Array(hash)].map(n => n.toString(16).padStart(2, "0")).join("")
).then(hex => {
state.video = hex;
console.log({hex});
});
*/
//console.log({url: video.src, path: file.name});
state.plots.forEach(({marker}) => marker.remove());
state.plots = [];
// load plots
db.transaction("r", db.plots, async () => {
const plots = await db.plots.where("video").equals(state.video).toArray();
//console.log(plots);
plots.forEach(plot => {
addMarker(plot);
setCue(plot);
});
document.getElementById("list").value = plotsToTimeList(state.plots);
});
});
const setCue = ({sec, label}) => {
const tt = video.textTracks[0];
for (const cue of tt.cues) {
if (cue.startTime === sec) {
cue.text = label;
return;
}
}
const span = Math.max(document.getElementById("cue-sec").value, 0);
const cue = new VTTCue(sec, sec + span, label);
cue.snapToLines = false;
cue.line = 90;
tt.addCue(cue);
};
const putPlot = plot => {
db.transaction("rw", db.plots, async () => {
await db.plots.where({video: state.video, sec: plot.sec}).delete();
await db.plots.put({
video: state.video, sec: plot.sec,
lat: plot.lat, lng: plot.lng,
label: plot.label, desc: plot.desc,
});
});
};
const deletePlot = plot => {
db.transaction("rw", db.plots, async () => {
await db.plots.where({video: state.video, sec: plot.sec}).delete();
});
};
const addMarker = ({sec, lat, lng, label = ""} = {}) => {
const marker = L.marker({lat, lng}, {draggable: true});
const plot = {marker, sec, lat, lng, label};
const befores = state.plots.filter(p => p.sec < sec);
const afters = state.plots.filter(p => sec < p.sec);
const sames = state.plots.filter(p => sec === p.sec);
state.plots = [].concat(befores, [plot], afters);
const labelInput = document.createElement("input");
labelInput.value = plot.label;
labelInput.addEventListener("input", ev => {
plot.label = labelInput.value;
putPlot(plot);
setCue(plot);
document.getElementById("list").value = plotsToTimeList(state.plots);
});
const del = document.createElement("button");
del.textContent = "🗑";
del.addEventListener("click", ev => {
marker.remove();
state.plots = state.plots.filter(e => e !== plot);
deletePlot(plot);
document.getElementById("list").value = plotsToTimeList(state.plots);
});
const jump = document.createElement("button");
jump.textContent = "⏫";
jump.addEventListener("click", ev => {
console.log(plot.sec);
video.currentTime = plot.sec;
});
const content = document.createElement("div");
content.append(labelInput, `${sec}s`, jump, del);
marker.bindPopup(content);
marker.on("dragend", () => {
const {lat, lng} = marker.getLatLng();
[plot.lat, plot.lng] = [lat, lng];
putPlot(plot);
});
marker.addTo(plotLayer).openPopup();
sames.forEach(({marker}) => marker.remove());
return plot;
};
const plotsToKml = (plots, title, baseUrl = "") => {
// export as KML placemarks
const ns = "http://www.opengis.net/kml/2.2";
const kml = document.implementation.createDocument(ns, "kml");
const doc = kml.createElementNS(ns, "Document");
const name = kml.createElementNS(ns, "name");
kml.documentElement.appendChild(doc);
name.appendChild(kml.createTextNode(`${title}`));
doc.appendChild(name);
for (const {sec, lat, lng, label} of plots) {
const mark = kml.createElementNS(ns, "Placemark");
const name = kml.createElementNS(ns, "name");
const desc = kml.createElementNS(ns, "description");
const point = kml.createElementNS(ns, "Point");
const coord = kml.createElementNS(ns, "coordinates");
name.appendChild(kml.createTextNode(`${label}`));
desc.appendChild(kml.createCDATASection(`${baseUrl}${sec}s`));
coord.appendChild(kml.createTextNode(`${lng},${lat},0`));
point.appendChild(coord);
mark.appendChild(name);
mark.appendChild(desc);
mark.appendChild(point);
doc.appendChild(mark);
}
const xs = new XMLSerializer();
const xml = `<?xml version="1.0" encoding="UTF-8"?>${xs.serializeToString(kml)}`;
//console.log(xml);
return xml;
};
const kmlToPlotData = xml => {
const parser = new DOMParser();
const kml = parser.parseFromString(xml, "application/xml");
// use Placemark Point only
const markers = [...kml.querySelectorAll("Placemark")];
const plots = markers.map(pm => {
const point = pm.querySelector("Point");
if (!point) return null;
const label = pm.querySelector("name").textContent;
const desc = pm.querySelector("description").textContent;
const [lng,lat,alt] = point.querySelector("coordinates").textContent.split(",").map(t => Number(t));
let secText = desc;
try {
secText = new URL(desc).searchParams.get("t");
} catch (ex) {}
const match = secText.match(/^(\d+)s$/);
if (!match) return null;
const sec = Number(match[1]);
return {sec, lat, lng, label};
});
return plots.filter(p => p);
};
const secToTime = sec => {
const s = sec % 60;
const minute = (sec - s) / 60;
const m = minute % 60;
const h = (minute - m) / 60;
return {h, m, s};
};
const plotsToTimeList = plots => {
const list = plots.map(({sec,label}) => ({time: secToTime(sec), label}));
const maxH = list.reduce((max, {time}) => Math.max(max, time.h), 0);
const maxM = list.reduce((max, {time}) => Math.max(max, time.m), 0);
const lenH = maxH === 0 ? 0 : maxH.toString().length;
const lenM = maxH > 0 ? 2 : maxM.toString().length;
return list.map(({time, label}) => {
const hPart = lenH === 0 ? "" : `${time.h.toString().padStart(lenH, "0")}:`;
const mPart = `${time.m.toString().padStart(lenM, "0")}:`;
const timePart = `${hPart}${mPart}${time.s.toString().padStart(2, "0")}`;
return `${timePart} ${label}`;
}).join("\n");
};
const plotsToVtt = plots => {
const pad2 = n => n.toString().padStart(2, "0");
const vttTime = ({h, m, s}) => `${pad2(h)}:${pad2(m)}:${pad2(s)}.000`;
const span = Math.max(document.getElementById("cue-sec").value, 0);
const cues = plots.map(({sec, label}, i) => {
const start = vttTime(secToTime(sec));
const end = vttTime(secToTime(sec + span));
const id = `plot-${i}`;
return `${id}\n${start} --> ${end}\n${label}\n\n`;
});
return `WEBVTT\n\n${cues.join("")}`;
};
const gpxToCoords = xml => {
const parser = new DOMParser();
const gpx = parser.parseFromString(xml, "application/xml");
const trkpts = [...gpx.querySelectorAll("trkpt")];
return trkpts.map(trkpt => [
Number(trkpt.getAttribute("lat")), Number(trkpt.getAttribute("lon")),
]);
};
const gpxToInfos = (xml, span = 60) => {
const parser = new DOMParser();
const gpx = parser.parseFromString(xml, "application/xml");
const trkpts = [...gpx.querySelectorAll("trkpt")];
const infos = trkpts.map((trkpt, i) => ({
latlng: [Number(trkpt.getAttribute("lat")), Number(trkpt.getAttribute("lon"))],
date: new Date(trkpt.querySelector("time")?.textContent),
speed: Number(trkpt.querySelector("speed")?.textContent),
ele: Number(trkpt.querySelector("ele")?.textContent),
sat: Number(trkpt.querySelector("sat")?.textContent),
}));
infos.forEach((info, i) => {
info.distance = i === 0 ? 0 : infos[i - 1].distance + gpsDistance(info.latlng, infos[i - 1].latlng);
info.time = info.date.getTime() - infos[0].date.getTime();
});
return infos.filter((info, i) => i % span === 0 || i === infos.length - 1);
};
const gpsDistance = (latlng1, latlng2) => {
return new L.LatLng(...latlng1).distanceTo(new L.LatLng(...latlng2));
/*
const {PI, acos, sin, cos} = Math, rad = PI / 180, r = 6371000;
const [p1, p2, q1, q2] = [latlng1[0], latlng2[0], latlng1[1], latlng2[1]].map(v => v * rad);
return r * acos(cos(p1) * cos(p2) * cos(q1 - q2) + sin(p1) * sin(p2));
*/
};
const getBaseUrl = () => {
const url = document.getElementById("url").value.trim();
const type = document.getElementById("url-type").value;
switch (type) {
case "youtube": return `${url}?t=`;
default: return url;
}
};
map.on("click", ev => {
// click to add a plot
if (!video.src) return;
const sec = Math.floor(video.currentTime);
if (state.plots.find(p => p.sec === sec)) return;
const {lat, lng} = ev.latlng;
const {h, m, s} = secToTime(sec);
const label = (h > 0 ? `${h}h` : ``) + (h > 0 || m > 0 ? `${m}m` : ``) + `${s}s`;
//console.log({lat, lng});
const plot = addMarker({sec, lat, lng, label});
setCue(plot);
putPlot(plot);
document.getElementById("list").value = plotsToTimeList(state.plots);
});
document.getElementById("export").addEventListener("click", ev => {
const baseUrl = getBaseUrl();
const xml = plotsToKml(state.plots, state.video, baseUrl);
const a = document.createElement("a");
a.href = `data:application/xml;charset=utf-8,${encodeURIComponent(xml)}`;
a.download = `${state.video}.kml`;
a.click();
});
const kmlInput = document.getElementById("kml");
kmlInput.addEventListener("input", ev => {
if (!video.src) return;
if (kmlInput.files.length !== 1) return;
const file = kmlInput.files[0];
file.text().then(xml => {
//console.log({xml});
const plots = kmlToPlotData(xml);
//console.log({plots});
plots.forEach(p => {
const plot = addMarker(p);
putPlot(plot);
});
document.getElementById("list").value = plotsToTimeList(state.plots);
});
});
const gpxInput = document.getElementById("gpx");
gpxInput.addEventListener("input", ev => {
//if (!video.src) return;
if (gpxInput.files.length !== 1) return;
const file = gpxInput.files[0];
file.text().then(xml => {
const coords = gpxToCoords(xml);
//console.log(coords);
L.polyline(coords, {color: "blue", weight: 5, opacity: 0.5}).addTo(gpxLayer);
const span = Math.max(1, Math.trunc(document.getElementById("gpx-span")?.value ?? 60));
const infos = gpxToInfos(xml, span);
const speedNf = new Intl.NumberFormat(undefined, {unit: "kilometer", style: "unit", unitDisplay: "narrow", maximumFractionDigits: 1, minimumFractionDigits: 1});
const eleNf = new Intl.NumberFormat(undefined, {unit: "meter", style: "unit", unitDisplay: "narrow", maximumFractionDigits: 2, minimumFractionDigits: 2});
const distanceNf = new Intl.NumberFormat(undefined, {unit: "kilometer", style: "unit", unitDisplay: "narrow", maximumFractionDigits: 3, minimumFractionDigits: 3});
infos.forEach(({latlng, date, speed, ele, distance, time}, i) => {
const color = i / infos.length > 0.5 ? "magenta" : "cyan";
const {h, m, s} = secToTime(Math.trunc(time / 1000));
const labels = [
Number.isFinite(date.getTime()) ? date.toLocaleString() : "",
Number.isFinite(speed) ? `speed : ${speedNf.format(speed * 3.6).padStart(8)}/h` : "",
Number.isFinite(ele) ? `elevation: ${eleNf.format(ele).padStart(8)}` : "",
Number.isFinite(distance) ? `distance : ${distanceNf.format(distance / 1000).padStart(9)}` : "",
Number.isFinite(s) ? `time : ${h.toString().padStart(2)}h${m.toString().padStart(2, "0")}m${s.toString().padStart(2, "0")}s` : "",
].filter(e => e);
L.circle(latlng, {color, radius: 5}).addTo(gpxLayer).
on("mouseover", ev => ev.target.openPopup()).on("mouseout", ev => ev.target.closePopup()).
bindPopup(`<pre style='font-family: "Noto Mono", "Menlo", "Consolas", monospace !important;'>${labels.join("\n")}</pre>`);
});
});
});
document.getElementById("gpx-clear").addEventListener("click", ev => {
gpxInput.value = "";
for (const gpx of gpxLayer.getLayers()) gpx.remove();
});
document.getElementById("copy").addEventListener("click", ev => {
navigator.clipboard.writeText(document.getElementById("list").value);
});
document.getElementById("vtt").addEventListener("click", ev => {
const txt = plotsToVtt(state.plots);
const a = document.createElement("a");
a.href = `data:text/plain;charset=utf-8,${encodeURIComponent(txt)}`;
a.download = `${state.video}.vtt`;
a.click();
});
document.getElementById("cue-sec").addEventListener("input", ev => {
const span = Math.max(document.getElementById("cue-sec").value, 0);
const tt = video.textTracks[0];
if (!tt) return;
for (const cue of tt.cues) {
cue.endTime = cue.startTime + span;
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment