diff --git a/frontend.html b/frontend.html index ccbabc2..140ec84 100644 --- a/frontend.html +++ b/frontend.html @@ -124,6 +124,7 @@ const ICONS = { stop: '', reload: '', full: '', + down: '', rec: '', film: '', off: '' @@ -218,7 +219,12 @@ 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 }); - bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, el("span", "sp"), close); + 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.querySelector(".sp").style.flex = "1"; const body = el("div", "body"); tl.replaceChildren(bar, body); @@ -268,6 +274,25 @@ async function openTimelapse(name) { show(files.length - 1); }; daySel.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); + if (!r.ok) throw new Error(r.status); + const a = el("a"); + a.href = URL.createObjectURL(await r.blob()); + a.download = (r.headers.get("Content-Disposition") || "").match(/filename="(.+)"/)?.[1] || name + "-" + daySel.value + ".mp4"; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); + label.textContent = "Video"; + } catch (e) { + label.textContent = "Fehler ✗"; + setTimeout(() => { label.textContent = "Video"; }, 2500); + } + video.disabled = false; + }; play.onclick = () => { if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; } play.querySelector("span").textContent = "Pause"; diff --git a/relay.py b/relay.py index 81fa8b0..9316b45 100644 --- a/relay.py +++ b/relay.py @@ -12,6 +12,7 @@ Endpunkte: POST //setup ruft die in SETUP_ hinterlegte Kamera-URL auf //timelapse.json Liste der Timelapse-Bilder je Tag //timelapse// einzelnes Timelapse-Bild + //timelapse//video?fps=10 Tagesvideo (MP4 mit ffmpeg, sonst MJPEG-AVI) /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität) /health Status aller Kameras @@ -32,6 +33,9 @@ import json import os import re import shutil +import struct +import subprocess +import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -196,6 +200,79 @@ def prune(cam_dir): shutil.rmtree(os.path.join(cam_dir, day), ignore_errors=True) +def jpeg_size(data): + """Breite und Höhe aus dem SOF-Marker eines JPEG.""" + i = 2 + while i + 9 < len(data): + if data[i] != 0xFF: + i += 1 + continue + m = data[i + 1] + if m in (0xC0, 0xC1, 0xC2): + h, w = struct.unpack(">HH", data[i + 5:i + 9]) + return w, h + i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0] + raise ValueError("kein JPEG") + + +def write_avi(path, files, fps): + """Schreibt die JPEGs als MJPEG-AVI (ohne ffmpeg).""" + with open(files[0], "rb") as f: + w, h = jpeg_size(f.read()) + sizes = [os.path.getsize(x) for x in files] + n, big = len(files), max(sizes) + pad = lambda v: v + (v & 1) + movi = 4 + sum(8 + pad(v) for v in sizes) + hdrl = (b"avih" + struct.pack("