Bewegungserkennung pro Kamera (Pillow), Bewegungsbilder in der Timelapse-Galerie mit Filter

This commit is contained in:
Joachim Hummel committed 2026-10-18 14:08:09 +02:00
1 parent 420e8e1550
commit ff3dc7ee2b
4 files changed
+125 -32

No files matched your search

+35 -8
View File
@@ -78,6 +78,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
#tl .big .when{position:absolute;left:12px;bottom:10px;background:rgba(0,0,0,.6);padding:2px 10px;border-radius:999px;font-size:13px;font-variant-numeric:tabular-nums}
#tl .thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px}
#tl .thumbs img{height:72px;aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:2px solid transparent;cursor:pointer;flex:none;background:#000}
#tl .thumbs img.mot{border-color:#f59e0b}
#tl .thumbs img.sel{border-color:var(--accent)}
#tl .empty{color:var(--muted);margin:auto}
@media (max-width:700px){.grid{grid-template-columns:1fr}}
@@ -125,6 +126,7 @@ const ICONS = {
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
motion: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>',
off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>'
@@ -180,7 +182,7 @@ function makeTile(name, mode, hasSetup) {
}, "alt");
actions.append(setup);
}
const o = { name, mode, t, img, state, ovText, rec: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null };
const o = { name, mode, t, img, state, ovText, rec: null, mot: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null };
if (mode === "live") {
actions.append(
btn("Start", "play", () => startTile(o), "ok"),
@@ -188,9 +190,11 @@ function makeTile(name, mode, hasSetup) {
btn("Reload", "reload", () => reloadTile(o), "alt"));
}
const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt");
const mot = btn("Bewegung aus", "motion", () => setTimelapse(name, { motion: !tiles[name].tl.motion }), "alt");
const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt");
o.rec = rec;
actions.append(snap, rec, gallery, full);
o.mot = mot;
actions.append(snap, rec, mot, gallery, full);
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
t.append(view, cap);
@@ -219,12 +223,21 @@ async function openTimelapse(name) {
TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v)));
ivSel.value = tiles[name].tl.interval;
ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value });
const sensSel = el("select");
sensSel.title = "Empfindlichkeit der Bewegungserkennung";
[["low", "Bewegung: niedrig"], ["mid", "Bewegung: mittel"], ["high", "Bewegung: hoch"]].forEach(([v, t]) => sensSel.append(new Option(t, v)));
sensSel.value = tiles[name].tl.sensitivity;
sensSel.onchange = () => setTimelapse(name, { sensitivity: sensSel.value });
sensSel.hidden = !tiles[name].motionOk;
const kindSel = el("select");
kindSel.title = "Anzeige filtern";
[["all", "Alle Bilder"], ["lapse", "Nur Zeitraffer"], ["motion", "Nur Bewegung"]].forEach(([v, t]) => kindSel.append(new Option(t, v)));
const fpsSel = el("select");
fpsSel.title = "Bilder pro Sekunde im Video";
[5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v)));
fpsSel.value = 10;
const video = btn("Video", "down", () => {}, "alt");
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, fpsSel, video, el("span", "sp"), close);
bar.append(el("h2", "", "Timelapse " + name), daySel, kindSel, play, ivSel, sensSel, fpsSel, video, el("span", "sp"), close);
bar.querySelector(".sp").style.flex = "1";
const body = el("div", "body");
tl.replaceChildren(bar, body);
@@ -240,7 +253,7 @@ async function openTimelapse(name) {
}
const days = Object.keys(data.days).filter(d => data.days[d].length);
if (!days.length) {
body.append(el("div", "empty", "Noch keine Bilder – Timelapse mit dem Button in der Kachel starten"));
body.append(el("div", "empty", "Noch keine Bilder – Timelapse oder Bewegungserkennung in der Kachel starten"));
play.hidden = daySel.hidden = true;
return;
}
@@ -255,7 +268,7 @@ async function openTimelapse(name) {
idx = (i + files.length) % files.length;
const d = daySel.value, f = files[idx];
bigImg.src = url(d, f);
when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6);
when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6) + (f.includes("_m") ? " · Bewegung" : "");
thumbs.querySelectorAll("img").forEach((im, k) => im.classList.toggle("sel", k === idx));
const sel = thumbs.children[idx];
if (sel) sel.scrollIntoView({ block: "nearest", inline: "center" });
@@ -263,9 +276,15 @@ async function openTimelapse(name) {
const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; };
const loadDay = () => {
stop();
files = data.days[daySel.value];
files = data.days[daySel.value].filter(f => kindSel.value === "all" || (kindSel.value === "motion") === f.includes("_m"));
if (!files.length) {
thumbs.replaceChildren();
bigImg.removeAttribute("src");
when.textContent = "Keine Bilder dieser Art an diesem Tag";
return;
}
thumbs.replaceChildren(...files.map((f, i) => {
const im = el("img");
const im = el("img", f.includes("_m") ? "mot" : "");
im.loading = "lazy";
im.src = url(daySel.value, f);
im.onclick = () => { stop(); show(i); };
@@ -274,12 +293,13 @@ async function openTimelapse(name) {
show(files.length - 1);
};
daySel.onchange = loadDay;
kindSel.onchange = loadDay;
video.onclick = async () => {
const label = video.querySelector("span");
video.disabled = true;
label.textContent = "erstelle …";
try {
const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value);
const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value + (kindSel.value === "motion" ? "&kind=motion" : ""));
if (!r.ok) throw new Error(r.status);
const a = el("a");
a.href = URL.createObjectURL(await r.blob());
@@ -294,6 +314,7 @@ async function openTimelapse(name) {
video.disabled = false;
};
play.onclick = () => {
if (!files.length) return;
if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; }
play.querySelector("span").textContent = "Pause";
if (idx === files.length - 1) show(0);
@@ -311,6 +332,11 @@ function renderTimelapse(o) {
o.rec.classList.toggle("alt", !on);
o.rec.title = on ? "Timelapse läuft (alle " + fmtInterval(o.tl.interval) + ") – klicken zum Stoppen" : "Timelapse starten";
o.rec.querySelector("span").textContent = on ? "Timelapse " + fmtInterval(o.tl.interval) : "Timelapse aus";
o.mot.hidden = !o.motionOk;
o.mot.classList.toggle("rec", !!o.tl.motion);
o.mot.classList.toggle("alt", !o.tl.motion);
o.mot.title = o.tl.motion ? "Bewegungserkennung läuft – klicken zum Stoppen" : "Bewegungserkennung starten";
o.mot.querySelector("span").textContent = o.tl.motion ? "Bewegung an" : "Bewegung aus";
}
async function setTimelapse(name, change) {
@@ -456,6 +482,7 @@ async function poll() {
data.cameras.forEach(c => {
setOnline(tiles[c.name], c.online);
tiles[c.name].tl = c.timelapse;
tiles[c.name].motionOk = c.motion_ok;
renderTimelapse(tiles[c.name]);
});
const n = data.cameras.filter(c => c.online).length;