Last active
April 4, 2022 05:08
Star
You must be signed in to star a gist
[browser] Time Place marker Editor for video files
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: `© <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
demo: https://gist.githack.com/bellbind/c77617cd0ec6ac84c5430fac44e901a1/raw/index.html